blob: 66cd96b792185dea22b1c1c511496903ccdfec6b [file] [log] [blame]
Akron49ceeb42025-05-23 17:46:01 +02001package main
2
3import (
Akrond8a76b32026-02-20 09:31:56 +01004 "bytes"
5 "embed"
Akron49ceeb42025-05-23 17:46:01 +02006 "fmt"
Akrond8a76b32026-02-20 09:31:56 +01007 "html/template"
8 "io/fs"
Akron80067202025-06-06 14:16:25 +02009 "net/url"
Akron49ceeb42025-05-23 17:46:01 +020010 "os"
11 "os/signal"
Akron80067202025-06-06 14:16:25 +020012 "path"
Akron14678dc2025-06-05 13:01:38 +020013 "path/filepath"
Akrond8a76b32026-02-20 09:31:56 +010014 "strconv"
Akron49ceeb42025-05-23 17:46:01 +020015 "strings"
16 "syscall"
Akron3caee162025-07-01 17:44:58 +020017 "time"
Akron49ceeb42025-05-23 17:46:01 +020018
Akron2ef703c2025-07-03 15:57:42 +020019 "github.com/KorAP/Koral-Mapper/config"
20 "github.com/KorAP/Koral-Mapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020021 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020022 "github.com/gofiber/fiber/v2"
Akronf1ca8822026-05-20 15:44:00 +020023 "github.com/gofiber/fiber/v2/middleware/cors"
Akrone6767de2026-05-20 10:06:24 +020024 "github.com/gofiber/fiber/v2/middleware/limiter"
Akron49ceeb42025-05-23 17:46:01 +020025 "github.com/rs/zerolog"
26 "github.com/rs/zerolog/log"
27)
28
Akrond8a76b32026-02-20 09:31:56 +010029//go:embed static/*
30var staticFS embed.FS
31
Akron74e1c072025-05-26 14:38:25 +020032const (
33 maxInputLength = 1024 * 1024 // 1MB
34 maxParamLength = 1024 // 1KB
35)
36
Akrona00d4752025-05-26 17:34:36 +020037type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020038 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020039 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
Akron14678dc2025-06-05 13:01:38 +020040 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
Akrona8a66ce2025-06-05 10:50:17 +020041 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020042}
43
Akrond8a76b32026-02-20 09:31:56 +010044type BasePageData struct {
Akron40aaa632025-06-03 17:57:52 +020045 Title string
46 Version string
Akronfc77b5e2025-06-04 11:44:43 +020047 Hash string
48 Date string
Akron40aaa632025-06-03 17:57:52 +020049 Description string
Akron06d21f02025-06-04 14:36:07 +020050 Server string
51 SDK string
Akron43fb1022026-02-20 11:38:49 +010052 Stylesheet string
Akron2ac2ec02025-06-05 15:26:42 +020053 ServiceURL string
Akron43fb1022026-02-20 11:38:49 +010054 CookieName string
Akrond8a76b32026-02-20 09:31:56 +010055}
56
57type SingleMappingPageData struct {
58 BasePageData
Akronc376dcc2025-06-04 17:00:18 +020059 MapID string
Akrond8a76b32026-02-20 09:31:56 +010060 Mappings []config.MappingList
61 QueryURL string
62 ResponseURL string
Akron40aaa632025-06-03 17:57:52 +020063}
64
Akroncb51f812025-06-30 15:24:20 +020065type QueryParams struct {
66 Dir string
67 FoundryA string
68 FoundryB string
69 LayerA string
70 LayerB string
71}
72
Akron49b525c2025-07-03 15:17:06 +020073// requestParams holds common request parameters
74type requestParams struct {
75 MapID string
76 Dir string
77 FoundryA string
78 FoundryB string
79 LayerA string
80 LayerB string
Akron8414ae52026-05-19 13:31:14 +020081 Rewrites *bool // nil = use mapping list default; non-nil = override
Akron49b525c2025-07-03 15:17:06 +020082}
83
Akron247a93a2026-02-20 16:28:40 +010084// MappingSectionData contains per-section UI metadata so request and response
85// rows can be rendered from one shared template block.
86type MappingSectionData struct {
87 Title string
88 Mode string
89 CheckboxClass string
90 CheckboxName string
91 FieldsClass string
92 ArrowClass string
93 ArrowDirection string
94 ArrowLabel string
95 AnnotationLabel string
96}
97
Akrond8a76b32026-02-20 09:31:56 +010098// ConfigPageData holds all data passed to the configuration page template.
99type ConfigPageData struct {
100 BasePageData
101 AnnotationMappings []config.MappingList
102 CorpusMappings []config.MappingList
Akron247a93a2026-02-20 16:28:40 +0100103 MappingSections []MappingSectionData
Akrond8a76b32026-02-20 09:31:56 +0100104}
105
Akrona00d4752025-05-26 17:34:36 +0200106func parseConfig() *appConfig {
107 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +0200108
109 desc := config.Description
110 desc += " [" + config.Version + "]"
111
Akron1fc750e2025-05-26 16:54:18 +0200112 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +0200113 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +0200114 kong.UsageOnError(),
115 )
116 if ctx.Error != nil {
117 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +0200118 os.Exit(1)
119 }
Akron49ceeb42025-05-23 17:46:01 +0200120 return cfg
121}
122
123func setupLogger(level string) {
124 // Parse log level
125 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
126 if err != nil {
127 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
128 lvl = zerolog.InfoLevel
129 }
130
131 // Configure zerolog
132 zerolog.SetGlobalLevel(lvl)
133 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
134}
135
Akron3caee162025-07-01 17:44:58 +0200136// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
137func setupFiberLogger() fiber.Handler {
138 // Check if HTTP request logging should be enabled based on current log level
139 currentLevel := zerolog.GlobalLevel()
140
141 // Only enable HTTP request logging if log level is debug or info
142 if currentLevel > zerolog.InfoLevel {
143 return func(c *fiber.Ctx) error {
144 return c.Next()
145 }
146 }
147
148 return func(c *fiber.Ctx) error {
149 // Record start time
150 start := time.Now()
151
152 // Process request
153 err := c.Next()
154
155 // Calculate latency
156 latency := time.Since(start)
157 status := c.Response().StatusCode()
158
159 // Determine log level based on status code
160 logEvent := log.Info()
161 if status >= 400 && status < 500 {
162 logEvent = log.Warn()
163 } else if status >= 500 {
164 logEvent = log.Error()
165 }
166
167 // Log the request
168 logEvent.
169 Int("status", status).
170 Dur("latency", latency).
171 Str("method", c.Method()).
172 Str("path", c.Path()).
173 Str("ip", c.IP()).
174 Str("user_agent", c.Get("User-Agent")).
175 Msg("HTTP request")
176
177 return err
178 }
179}
180
Akron49b525c2025-07-03 15:17:06 +0200181// extractRequestParams extracts and validates common request parameters
182func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
183 params := &requestParams{
184 MapID: c.Params("map"),
185 Dir: c.Query("dir", "atob"),
186 FoundryA: c.Query("foundryA", ""),
187 FoundryB: c.Query("foundryB", ""),
188 LayerA: c.Query("layerA", ""),
189 LayerB: c.Query("layerB", ""),
190 }
191
Akron8414ae52026-05-19 13:31:14 +0200192 if rewrites := c.Query("rewrites", ""); rewrites != "" {
193 v := rewrites == "true"
194 params.Rewrites = &v
195 }
196
Akron49b525c2025-07-03 15:17:06 +0200197 // Validate input parameters
198 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
199 return nil, err
200 }
201
202 // Validate direction
203 if params.Dir != "atob" && params.Dir != "btoa" {
204 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
205 }
206
207 return params, nil
208}
209
210// parseRequestBody parses JSON request body and direction
211func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
212 var jsonData any
213 if err := c.BodyParser(&jsonData); err != nil {
214 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
215 }
216
217 direction, err := mapper.ParseDirection(dir)
218 if err != nil {
219 return nil, mapper.BtoA, err
220 }
221
222 return jsonData, direction, nil
223}
224
Akron49ceeb42025-05-23 17:46:01 +0200225func main() {
Akroned787d02026-05-20 12:31:07 +0200226 // Confine config file loading to the current working directory tree
227 // (path traversal prevention). Can be overridden via the "basePath"
228 // YAML field or the KORAL_MAPPER_BASE_PATH environment variable.
229 // In Docker (WORKDIR /), the default "/" naturally allows all paths.
230 cwd, err := os.Getwd()
231 if err != nil {
232 log.Fatal().Err(err).Msg("Failed to determine working directory")
233 }
234 config.AllowedBasePath = cwd
235
Akron49ceeb42025-05-23 17:46:01 +0200236 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200237 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200238
Akrone1cff7c2025-06-04 18:43:32 +0200239 // Validate command line arguments
240 if cfg.Config == "" && len(cfg.Mappings) == 0 {
241 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
242 }
243
Akron14678dc2025-06-05 13:01:38 +0200244 // Expand glob patterns in mapping files
245 expandedMappings, err := expandGlobs(cfg.Mappings)
246 if err != nil {
247 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
248 }
249
Akrone1cff7c2025-06-04 18:43:32 +0200250 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200251 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200252 if err != nil {
253 log.Fatal().Err(err).Msg("Failed to load configuration")
254 }
255
Akroned787d02026-05-20 12:31:07 +0200256 // Apply basePath from config/env if specified (overrides CWD default)
257 if yamlConfig.BasePath != "" {
258 config.AllowedBasePath = yamlConfig.BasePath
259 }
260
Akrona8a66ce2025-06-05 10:50:17 +0200261 finalPort := yamlConfig.Port
262 finalLogLevel := yamlConfig.LogLevel
263
264 // Use command line values if provided (they override config file)
265 if cfg.Port != nil {
266 finalPort = *cfg.Port
267 }
268 if cfg.LogLevel != nil {
269 finalLogLevel = *cfg.LogLevel
270 }
271
272 // Set up logging with the final log level
273 setupLogger(finalLogLevel)
274
Akron49ceeb42025-05-23 17:46:01 +0200275 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200276 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200277 if err != nil {
278 log.Fatal().Err(err).Msg("Failed to create mapper")
279 }
280
281 // Create fiber app
282 app := fiber.New(fiber.Config{
283 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200284 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200285 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
286 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200287 })
288
Akron3caee162025-07-01 17:44:58 +0200289 // Add zerolog-integrated logger middleware
290 app.Use(setupFiberLogger())
291
Akron49ceeb42025-05-23 17:46:01 +0200292 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200293 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200294
295 // Start server
296 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200297 log.Info().Int("port", finalPort).Msg("Starting server")
Akrond8a76b32026-02-20 09:31:56 +0100298 fmt.Printf("Starting server port=%d\n", finalPort)
Akronae3ffde2025-06-05 14:04:06 +0200299
300 for _, list := range yamlConfig.Lists {
301 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
Akrond8a76b32026-02-20 09:31:56 +0100302 fmt.Printf("Loaded mapping desc=%s id=%s\n",
303 formatConsoleField(list.Description),
304 list.ID,
305 )
Akronae3ffde2025-06-05 14:04:06 +0200306 }
307
Akrona8a66ce2025-06-05 10:50:17 +0200308 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200309 log.Fatal().Err(err).Msg("Server error")
310 }
311 }()
312
313 // Wait for interrupt signal
314 sigChan := make(chan os.Signal, 1)
315 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
316 <-sigChan
317
318 // Graceful shutdown
319 log.Info().Msg("Shutting down server")
320 if err := app.Shutdown(); err != nil {
321 log.Error().Err(err).Msg("Error during shutdown")
322 }
323}
324
Akron06d21f02025-06-04 14:36:07 +0200325func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akrond8a76b32026-02-20 09:31:56 +0100326 configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
Akronbeee5052026-05-20 09:39:45 +0200327 pluginTmpl := template.Must(template.ParseFS(staticFS, "static/plugin.html"))
Akrond8a76b32026-02-20 09:31:56 +0100328
Akrone6767de2026-05-20 10:06:24 +0200329 // Security headers middleware to mitigate MIME-sniffing and referrer
330 // information leaks (OWASP Secure Headers). X-Frame-Options is
331 // intentionally omitted because the service is designed to be embedded
332 // in cross-origin iframes (Kalamar plugin).
333 app.Use(func(c *fiber.Ctx) error {
334 c.Set("X-Content-Type-Options", "nosniff")
335 c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
336 return c.Next()
337 })
338
Akronf1ca8822026-05-20 15:44:00 +0200339 // CORS middleware to allow cross-origin requests from trusted
340 // origins. Required because the service is designed to be
341 // called as a KorAP/Kalamar plugin from cross-origin iframes.
342 // Configurable via the "allowOrigins" YAML key or the
343 // KORAL_MAPPER_ALLOW_ORIGINS environment variable
344 // (default: "https://korap.ids-mannheim.de").
345 app.Use(cors.New(cors.Config{
346 AllowOrigins: yamlConfig.AllowOrigins,
347 AllowMethods: "GET,POST",
348 AllowHeaders: "Content-Type",
349 }))
350
Akrone6767de2026-05-20 10:06:24 +0200351 // Rate limiting middleware to prevent resource exhaustion from
352 // request floods. The maximum number of requests per minute
353 // per IP is configurable via the "rateLimit" YAML key or the
354 // KORAL_MAPPER_RATE_LIMIT environment variable (default: 100).
355 rateLimit := yamlConfig.RateLimit
356 if rateLimit <= 0 {
357 rateLimit = 100
358 }
359 app.Use(limiter.New(limiter.Config{
360 Max: rateLimit,
361 Expiration: 1 * time.Minute,
362 LimiterMiddleware: limiter.SlidingWindow{},
363 }))
364
Akron49ceeb42025-05-23 17:46:01 +0200365 // Health check endpoint
366 app.Get("/health", func(c *fiber.Ctx) error {
367 return c.SendString("OK")
368 })
369
Akrond8a76b32026-02-20 09:31:56 +0100370 // Static file serving from embedded FS
371 app.Get("/static/*", handleStaticFile())
372
Akronbf73a122026-02-27 15:02:16 +0100373 // Composite cascade transformation endpoints (cfg in path)
Akronf7bba072026-05-21 12:36:19 +0200374 app.Post("/query/:cfg", handleCompositeQueryTransform(m, yamlConfig))
375 app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig))
Akron512aab62026-02-20 08:36:12 +0100376
Akron49ceeb42025-05-23 17:46:01 +0200377 // Transformation endpoint
Akronf7bba072026-05-21 12:36:19 +0200378 app.Post("/:map/query", handleTransform(m, yamlConfig))
Akron40aaa632025-06-03 17:57:52 +0200379
Akron4de47a92025-06-27 11:58:11 +0200380 // Response transformation endpoint
Akronf7bba072026-05-21 12:36:19 +0200381 app.Post("/:map/response", handleResponseTransform(m, yamlConfig))
Akron4de47a92025-06-27 11:58:11 +0200382
Akron40aaa632025-06-03 17:57:52 +0200383 // Kalamar plugin endpoint
Akrond8a76b32026-02-20 09:31:56 +0100384 app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
385 app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
386}
387
388func handleStaticFile() fiber.Handler {
389 return func(c *fiber.Ctx) error {
390 name := c.Params("*")
391 data, err := fs.ReadFile(staticFS, "static/"+name)
392 if err != nil {
393 return c.Status(fiber.StatusNotFound).SendString("not found")
394 }
395 switch {
396 case strings.HasSuffix(name, ".js"):
397 c.Set("Content-Type", "text/javascript; charset=utf-8")
398 case strings.HasSuffix(name, ".css"):
399 c.Set("Content-Type", "text/css; charset=utf-8")
400 case strings.HasSuffix(name, ".html"):
401 c.Set("Content-Type", "text/html; charset=utf-8")
402 }
403 return c.Send(data)
404 }
405}
406
407func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
408 return BasePageData{
409 Title: config.Title,
410 Version: config.Version,
411 Hash: config.Buildhash,
412 Date: config.Buildtime,
413 Description: config.Description,
414 Server: yamlConfig.Server,
415 SDK: yamlConfig.SDK,
Akron43fb1022026-02-20 11:38:49 +0100416 Stylesheet: yamlConfig.Stylesheet,
Akrond8a76b32026-02-20 09:31:56 +0100417 ServiceURL: yamlConfig.ServiceURL,
Akron43fb1022026-02-20 11:38:49 +0100418 CookieName: yamlConfig.CookieName,
Akrond8a76b32026-02-20 09:31:56 +0100419 }
420}
421
422func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
423 data := ConfigPageData{
424 BasePageData: buildBasePageData(yamlConfig),
425 }
426
427 for _, list := range yamlConfig.Lists {
428 normalized := list
429 if normalized.Type == "" {
430 normalized.Type = "annotation"
431 }
432 if list.IsCorpus() {
433 data.CorpusMappings = append(data.CorpusMappings, normalized)
434 } else {
435 data.AnnotationMappings = append(data.AnnotationMappings, normalized)
436 }
437 }
Akron247a93a2026-02-20 16:28:40 +0100438
439 data.MappingSections = []MappingSectionData{
440 {
Akron8bdf5202026-02-24 10:01:15 +0100441 Title: "Request",
442 Mode: "request",
443 CheckboxClass: "request-cb",
444 CheckboxName: "request",
445 FieldsClass: "request-fields",
446 ArrowClass: "request-dir-arrow",
447 ArrowDirection: "atob",
448 ArrowLabel: "\u2192",
Akron247a93a2026-02-20 16:28:40 +0100449 },
450 {
Akron8bdf5202026-02-24 10:01:15 +0100451 Title: "Response",
452 Mode: "response",
453 CheckboxClass: "response-cb",
454 CheckboxName: "response",
455 FieldsClass: "response-fields",
456 ArrowClass: "response-dir-arrow",
457 ArrowDirection: "btoa",
458 ArrowLabel: "\u2190",
Akron247a93a2026-02-20 16:28:40 +0100459 },
460 }
461
Akrond8a76b32026-02-20 09:31:56 +0100462 return data
Akron49ceeb42025-05-23 17:46:01 +0200463}
464
Akronf7bba072026-05-21 12:36:19 +0200465func handleCompositeQueryTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
466 listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
467 for i := range yamlConfig.Lists {
468 listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
Akron8414ae52026-05-19 13:31:14 +0200469 }
470
Akron512aab62026-02-20 08:36:12 +0100471 return func(c *fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100472 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100473 if len(cfgRaw) > maxParamLength {
474 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
475 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
476 })
477 }
478
479 var jsonData any
480 if err := c.BodyParser(&jsonData); err != nil {
481 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
482 "error": "invalid JSON in request body",
483 })
484 }
485
Akronf7bba072026-05-21 12:36:19 +0200486 entries, err := ParseCfgParam(cfgRaw, yamlConfig.Lists)
Akron512aab62026-02-20 08:36:12 +0100487 if err != nil {
488 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
489 "error": err.Error(),
490 })
491 }
492
493 if len(entries) == 0 {
494 return c.JSON(jsonData)
495 }
496
Akron8414ae52026-05-19 13:31:14 +0200497 rewrites := c.Query("rewrites", "")
498 var rewritesOverride *bool
499 if rewrites != "" {
500 v := rewrites == "true"
501 rewritesOverride = &v
502 }
503
Akron512aab62026-02-20 08:36:12 +0100504 orderedIDs := make([]string, 0, len(entries))
505 opts := make([]mapper.MappingOptions, 0, len(entries))
506 for _, entry := range entries {
507 dir := mapper.AtoB
508 if entry.Direction == "btoa" {
509 dir = mapper.BtoA
510 }
511
Akronf7bba072026-05-21 12:36:19 +0200512 addRewrites := yamlConfig.Rewrites
Akron8414ae52026-05-19 13:31:14 +0200513 if list, ok := listsByID[entry.ID]; ok {
Akronf7bba072026-05-21 12:36:19 +0200514 addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
Akron8414ae52026-05-19 13:31:14 +0200515 }
516 if rewritesOverride != nil {
517 addRewrites = *rewritesOverride
518 }
519
Akron512aab62026-02-20 08:36:12 +0100520 orderedIDs = append(orderedIDs, entry.ID)
521 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200522 Direction: dir,
523 FoundryA: entry.FoundryA,
524 LayerA: entry.LayerA,
525 FoundryB: entry.FoundryB,
526 LayerB: entry.LayerB,
527 FieldA: entry.FieldA,
528 FieldB: entry.FieldB,
529 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100530 })
531 }
532
533 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
534 if err != nil {
535 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
536 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
537 "error": err.Error(),
538 })
539 }
540
541 return c.JSON(result)
542 }
543}
544
Akronf7bba072026-05-21 12:36:19 +0200545func handleCompositeResponseTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
546 listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
547 for i := range yamlConfig.Lists {
548 listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
Akron8414ae52026-05-19 13:31:14 +0200549 }
550
Akron512aab62026-02-20 08:36:12 +0100551 return func(c *fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100552 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100553 if len(cfgRaw) > maxParamLength {
554 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
555 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
556 })
557 }
558
559 var jsonData any
560 if err := c.BodyParser(&jsonData); err != nil {
561 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
562 "error": "invalid JSON in request body",
563 })
564 }
565
Akronf7bba072026-05-21 12:36:19 +0200566 entries, err := ParseCfgParam(cfgRaw, yamlConfig.Lists)
Akron512aab62026-02-20 08:36:12 +0100567 if err != nil {
568 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
569 "error": err.Error(),
570 })
571 }
572
573 if len(entries) == 0 {
574 return c.JSON(jsonData)
575 }
576
Akron8414ae52026-05-19 13:31:14 +0200577 rewrites := c.Query("rewrites", "")
578 var rewritesOverride *bool
579 if rewrites != "" {
580 v := rewrites == "true"
581 rewritesOverride = &v
582 }
583
Akron512aab62026-02-20 08:36:12 +0100584 orderedIDs := make([]string, 0, len(entries))
585 opts := make([]mapper.MappingOptions, 0, len(entries))
586 for _, entry := range entries {
587 dir := mapper.AtoB
588 if entry.Direction == "btoa" {
589 dir = mapper.BtoA
590 }
591
Akronf7bba072026-05-21 12:36:19 +0200592 addRewrites := yamlConfig.Rewrites
Akron8414ae52026-05-19 13:31:14 +0200593 if list, ok := listsByID[entry.ID]; ok {
Akronf7bba072026-05-21 12:36:19 +0200594 addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
Akron8414ae52026-05-19 13:31:14 +0200595 }
596 if rewritesOverride != nil {
597 addRewrites = *rewritesOverride
598 }
599
Akron512aab62026-02-20 08:36:12 +0100600 orderedIDs = append(orderedIDs, entry.ID)
601 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200602 Direction: dir,
603 FoundryA: entry.FoundryA,
604 LayerA: entry.LayerA,
605 FoundryB: entry.FoundryB,
606 LayerB: entry.LayerB,
607 FieldA: entry.FieldA,
608 FieldB: entry.FieldB,
609 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100610 })
611 }
612
613 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
614 if err != nil {
615 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
616 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
617 "error": err.Error(),
618 })
619 }
620
621 return c.JSON(result)
622 }
623}
624
Akronf7bba072026-05-21 12:36:19 +0200625func handleTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
626 listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
627 for i := range yamlConfig.Lists {
628 listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
Akron8414ae52026-05-19 13:31:14 +0200629 }
630
Akron49ceeb42025-05-23 17:46:01 +0200631 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200632 // Extract and validate parameters
633 params, err := extractRequestParams(c)
634 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200635 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
636 "error": err.Error(),
637 })
638 }
639
Akron49ceeb42025-05-23 17:46:01 +0200640 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200641 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200642 if err != nil {
643 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
644 "error": err.Error(),
645 })
646 }
647
Akronf7bba072026-05-21 12:36:19 +0200648 // Resolve rewrites: global default -> per-list -> query param
649 addRewrites := yamlConfig.Rewrites
Akron8414ae52026-05-19 13:31:14 +0200650 if list, ok := listsByID[params.MapID]; ok {
Akronf7bba072026-05-21 12:36:19 +0200651 addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
Akron8414ae52026-05-19 13:31:14 +0200652 }
653 if params.Rewrites != nil {
654 addRewrites = *params.Rewrites
655 }
656
Akron49ceeb42025-05-23 17:46:01 +0200657 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200658 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200659 Direction: direction,
660 FoundryA: params.FoundryA,
661 FoundryB: params.FoundryB,
662 LayerA: params.LayerA,
663 LayerB: params.LayerB,
664 AddRewrites: addRewrites,
Akron49ceeb42025-05-23 17:46:01 +0200665 }, jsonData)
666
667 if err != nil {
668 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200669 Str("mapID", params.MapID).
670 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200671 Msg("Failed to apply mappings")
672
673 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
674 "error": err.Error(),
675 })
676 }
677
678 return c.JSON(result)
679 }
680}
Akron74e1c072025-05-26 14:38:25 +0200681
Akronf7bba072026-05-21 12:36:19 +0200682func handleResponseTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
683 listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
684 for i := range yamlConfig.Lists {
685 listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
Akron8414ae52026-05-19 13:31:14 +0200686 }
687
Akron4de47a92025-06-27 11:58:11 +0200688 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200689 // Extract and validate parameters
690 params, err := extractRequestParams(c)
691 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200692 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
693 "error": err.Error(),
694 })
695 }
696
Akron4de47a92025-06-27 11:58:11 +0200697 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200698 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200699 if err != nil {
700 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
701 "error": err.Error(),
702 })
703 }
704
Akronf7bba072026-05-21 12:36:19 +0200705 // Resolve rewrites: global default -> per-list -> query param
706 addRewrites := yamlConfig.Rewrites
Akron8414ae52026-05-19 13:31:14 +0200707 if list, ok := listsByID[params.MapID]; ok {
Akronf7bba072026-05-21 12:36:19 +0200708 addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
Akron8414ae52026-05-19 13:31:14 +0200709 }
710 if params.Rewrites != nil {
711 addRewrites = *params.Rewrites
712 }
713
Akron4de47a92025-06-27 11:58:11 +0200714 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200715 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200716 Direction: direction,
717 FoundryA: params.FoundryA,
718 FoundryB: params.FoundryB,
719 LayerA: params.LayerA,
720 LayerB: params.LayerB,
721 AddRewrites: addRewrites,
Akron4de47a92025-06-27 11:58:11 +0200722 }, jsonData)
723
724 if err != nil {
725 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200726 Str("mapID", params.MapID).
727 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200728 Msg("Failed to apply response mappings")
729
730 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
731 "error": err.Error(),
732 })
733 }
734
735 return c.JSON(result)
736 }
737}
738
Akron74e1c072025-05-26 14:38:25 +0200739// validateInput checks if the input parameters are valid
740func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200741 // Define parameter checks
742 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200743 name string
744 value string
745 }{
746 {"mapID", mapID},
747 {"dir", dir},
748 {"foundryA", foundryA},
749 {"foundryB", foundryB},
750 {"layerA", layerA},
751 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200752 }
753
754 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200755 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200756 if len(param.value) > maxParamLength {
757 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
758 }
Akron74e1c072025-05-26 14:38:25 +0200759 if strings.ContainsAny(param.value, "<>{}[]\\") {
760 return fmt.Errorf("%s contains invalid characters", param.name)
761 }
762 }
763
Akron69d43bf2025-05-26 17:09:00 +0200764 if len(body) > maxInputLength {
765 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
766 }
767
Akron74e1c072025-05-26 14:38:25 +0200768 return nil
769}
Akron40aaa632025-06-03 17:57:52 +0200770
Akronbeee5052026-05-20 09:39:45 +0200771func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *template.Template) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200772 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200773 mapID := c.Params("map")
774
Akrond8a76b32026-02-20 09:31:56 +0100775 // Config page (GET /)
776 if mapID == "" {
777 data := buildConfigPageData(yamlConfig)
778 var buf bytes.Buffer
779 if err := configTmpl.Execute(&buf, data); err != nil {
780 log.Error().Err(err).Msg("Failed to execute config template")
781 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
782 }
783 c.Set("Content-Type", "text/html")
784 return c.Send(buf.Bytes())
785 }
786
787 // Single-mapping page (GET /:map) — existing behavior
Akroncb51f812025-06-30 15:24:20 +0200788 // Get query parameters
789 dir := c.Query("dir", "atob")
790 foundryA := c.Query("foundryA", "")
791 foundryB := c.Query("foundryB", "")
792 layerA := c.Query("layerA", "")
793 layerB := c.Query("layerB", "")
794
Akron49b525c2025-07-03 15:17:06 +0200795 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200796 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
797 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
798 "error": err.Error(),
799 })
800 }
801
Akroncb51f812025-06-30 15:24:20 +0200802 if dir != "atob" && dir != "btoa" {
803 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
804 "error": "invalid direction, must be 'atob' or 'btoa'",
805 })
806 }
807
Akroncb51f812025-06-30 15:24:20 +0200808 queryParams := QueryParams{
809 Dir: dir,
810 FoundryA: foundryA,
811 FoundryB: foundryB,
812 LayerA: layerA,
813 LayerB: layerB,
814 }
815
Akrond8a76b32026-02-20 09:31:56 +0100816 queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
817 if err != nil {
818 log.Warn().Err(err).Msg("Failed to build query service URL")
819 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
820 }
821 reversed := queryParams
822 if queryParams.Dir == "btoa" {
823 reversed.Dir = "atob"
824 } else {
825 reversed.Dir = "btoa"
826 }
827 responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
828 if err != nil {
829 log.Warn().Err(err).Msg("Failed to build response service URL")
830 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
831 }
Akron40aaa632025-06-03 17:57:52 +0200832
Akrond8a76b32026-02-20 09:31:56 +0100833 data := SingleMappingPageData{
834 BasePageData: buildBasePageData(yamlConfig),
835 MapID: mapID,
836 Mappings: yamlConfig.Lists,
837 QueryURL: queryURL,
838 ResponseURL: responseURL,
839 }
840
841 var buf bytes.Buffer
842 if err := pluginTmpl.Execute(&buf, data); err != nil {
843 log.Error().Err(err).Msg("Failed to execute plugin template")
844 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
845 }
Akron40aaa632025-06-03 17:57:52 +0200846 c.Set("Content-Type", "text/html")
Akrond8a76b32026-02-20 09:31:56 +0100847 return c.Send(buf.Bytes())
Akron40aaa632025-06-03 17:57:52 +0200848 }
849}
850
Akrond8a76b32026-02-20 09:31:56 +0100851func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
852 service, err := url.Parse(serviceURL)
853 if err != nil {
854 return "", err
Akronc376dcc2025-06-04 17:00:18 +0200855 }
Akrond8a76b32026-02-20 09:31:56 +0100856 service.Path = path.Join(service.Path, mapID, endpoint)
857 service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
858 return service.String(), nil
859}
Akronc376dcc2025-06-04 17:00:18 +0200860
Akrond8a76b32026-02-20 09:31:56 +0100861func formatConsoleField(value string) string {
862 if strings.ContainsAny(value, " \t") {
863 return strconv.Quote(value)
Akron40aaa632025-06-03 17:57:52 +0200864 }
Akrond8a76b32026-02-20 09:31:56 +0100865 return value
Akron40aaa632025-06-03 17:57:52 +0200866}
Akron14678dc2025-06-05 13:01:38 +0200867
Akroncb51f812025-06-30 15:24:20 +0200868// buildQueryParams builds a query string from the provided parameters
869func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
870 params := url.Values{}
871 if dir != "" {
872 params.Add("dir", dir)
873 }
874 if foundryA != "" {
875 params.Add("foundryA", foundryA)
876 }
877 if foundryB != "" {
878 params.Add("foundryB", foundryB)
879 }
880 if layerA != "" {
881 params.Add("layerA", layerA)
882 }
883 if layerB != "" {
884 params.Add("layerB", layerB)
885 }
886 return params.Encode()
887}
888
Akron14678dc2025-06-05 13:01:38 +0200889// expandGlobs expands glob patterns in the slice of file paths
890// Returns the expanded list of files or an error if glob expansion fails
891func expandGlobs(patterns []string) ([]string, error) {
892 var expanded []string
893
894 for _, pattern := range patterns {
895 // Use filepath.Glob which works cross-platform
896 matches, err := filepath.Glob(pattern)
897 if err != nil {
898 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
899 }
900
901 // If no matches found, treat as literal filename (consistent with shell behavior)
902 if len(matches) == 0 {
903 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
904 expanded = append(expanded, pattern)
905 } else {
906 expanded = append(expanded, matches...)
907 }
908 }
909
910 return expanded, nil
911}