blob: ccea13bd436feeacf4766442e5393b902bf32156 [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"
Akron2d53b932026-07-15 12:12:54 +020022 "github.com/gofiber/fiber/v3"
23 "github.com/gofiber/fiber/v3/middleware/cors"
24 "github.com/gofiber/fiber/v3/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 {
Akron2d53b932026-07-15 12:12:54 +0200143 return func(c fiber.Ctx) error {
Akron3caee162025-07-01 17:44:58 +0200144 return c.Next()
145 }
146 }
147
Akron2d53b932026-07-15 12:12:54 +0200148 return func(c fiber.Ctx) error {
Akron3caee162025-07-01 17:44:58 +0200149 // 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
Akron2d53b932026-07-15 12:12:54 +0200182func extractRequestParams(c fiber.Ctx) (*requestParams, error) {
183 mapID, err := url.PathUnescape(c.Params("map"))
184 if err != nil {
185 return nil, fmt.Errorf("mapID contains invalid characters")
186 }
187
Akron49b525c2025-07-03 15:17:06 +0200188 params := &requestParams{
Akron2d53b932026-07-15 12:12:54 +0200189 MapID: mapID,
Akron49b525c2025-07-03 15:17:06 +0200190 Dir: c.Query("dir", "atob"),
191 FoundryA: c.Query("foundryA", ""),
192 FoundryB: c.Query("foundryB", ""),
193 LayerA: c.Query("layerA", ""),
194 LayerB: c.Query("layerB", ""),
195 }
196
Akron8414ae52026-05-19 13:31:14 +0200197 if rewrites := c.Query("rewrites", ""); rewrites != "" {
198 v := rewrites == "true"
199 params.Rewrites = &v
200 }
201
Akron49b525c2025-07-03 15:17:06 +0200202 // Validate input parameters
203 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
204 return nil, err
205 }
206
207 // Validate direction
208 if params.Dir != "atob" && params.Dir != "btoa" {
209 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
210 }
211
212 return params, nil
213}
214
215// parseRequestBody parses JSON request body and direction
Akron2d53b932026-07-15 12:12:54 +0200216func parseRequestBody(c fiber.Ctx, dir string) (any, mapper.Direction, error) {
Akron49b525c2025-07-03 15:17:06 +0200217 var jsonData any
Akron2d53b932026-07-15 12:12:54 +0200218 if err := c.Bind().Body(&jsonData); err != nil {
Akron49b525c2025-07-03 15:17:06 +0200219 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
220 }
221
222 direction, err := mapper.ParseDirection(dir)
223 if err != nil {
224 return nil, mapper.BtoA, err
225 }
226
227 return jsonData, direction, nil
228}
229
Akron49ceeb42025-05-23 17:46:01 +0200230func main() {
Akroned787d02026-05-20 12:31:07 +0200231 // Confine config file loading to the current working directory tree
232 // (path traversal prevention). Can be overridden via the "basePath"
233 // YAML field or the KORAL_MAPPER_BASE_PATH environment variable.
234 // In Docker (WORKDIR /), the default "/" naturally allows all paths.
235 cwd, err := os.Getwd()
236 if err != nil {
237 log.Fatal().Err(err).Msg("Failed to determine working directory")
238 }
239 config.AllowedBasePath = cwd
240
Akron49ceeb42025-05-23 17:46:01 +0200241 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200242 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200243
Akrone1cff7c2025-06-04 18:43:32 +0200244 // Validate command line arguments
245 if cfg.Config == "" && len(cfg.Mappings) == 0 {
246 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
247 }
248
Akron14678dc2025-06-05 13:01:38 +0200249 // Expand glob patterns in mapping files
250 expandedMappings, err := expandGlobs(cfg.Mappings)
251 if err != nil {
252 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
253 }
254
Akrone1cff7c2025-06-04 18:43:32 +0200255 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200256 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200257 if err != nil {
258 log.Fatal().Err(err).Msg("Failed to load configuration")
259 }
260
Akroned787d02026-05-20 12:31:07 +0200261 // Apply basePath from config/env if specified (overrides CWD default)
262 if yamlConfig.BasePath != "" {
263 config.AllowedBasePath = yamlConfig.BasePath
264 }
265
Akrona8a66ce2025-06-05 10:50:17 +0200266 finalPort := yamlConfig.Port
267 finalLogLevel := yamlConfig.LogLevel
268
269 // Use command line values if provided (they override config file)
270 if cfg.Port != nil {
271 finalPort = *cfg.Port
272 }
273 if cfg.LogLevel != nil {
274 finalLogLevel = *cfg.LogLevel
275 }
276
277 // Set up logging with the final log level
278 setupLogger(finalLogLevel)
279
Akron49ceeb42025-05-23 17:46:01 +0200280 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200281 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200282 if err != nil {
283 log.Fatal().Err(err).Msg("Failed to create mapper")
284 }
285
286 // Create fiber app
287 app := fiber.New(fiber.Config{
Akron2d53b932026-07-15 12:12:54 +0200288 BodyLimit: maxInputLength,
289 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
290 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size,
Akron49ceeb42025-05-23 17:46:01 +0200291 })
292
Akron3caee162025-07-01 17:44:58 +0200293 // Add zerolog-integrated logger middleware
294 app.Use(setupFiberLogger())
295
Akron49ceeb42025-05-23 17:46:01 +0200296 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200297 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200298
299 // Start server
300 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200301 log.Info().Int("port", finalPort).Msg("Starting server")
Akrond8a76b32026-02-20 09:31:56 +0100302 fmt.Printf("Starting server port=%d\n", finalPort)
Akronae3ffde2025-06-05 14:04:06 +0200303
304 for _, list := range yamlConfig.Lists {
305 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
Akrond8a76b32026-02-20 09:31:56 +0100306 fmt.Printf("Loaded mapping desc=%s id=%s\n",
307 formatConsoleField(list.Description),
308 list.ID,
309 )
Akronae3ffde2025-06-05 14:04:06 +0200310 }
311
Akron2d53b932026-07-15 12:12:54 +0200312 if err := app.Listen(fmt.Sprintf(":%d", finalPort), fiber.ListenConfig{DisableStartupMessage: true}); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200313 log.Fatal().Err(err).Msg("Server error")
314 }
315 }()
316
317 // Wait for interrupt signal
318 sigChan := make(chan os.Signal, 1)
319 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
320 <-sigChan
321
322 // Graceful shutdown
323 log.Info().Msg("Shutting down server")
324 if err := app.Shutdown(); err != nil {
325 log.Error().Err(err).Msg("Error during shutdown")
326 }
327}
328
Akron06d21f02025-06-04 14:36:07 +0200329func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akrond8a76b32026-02-20 09:31:56 +0100330 configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
Akronbeee5052026-05-20 09:39:45 +0200331 pluginTmpl := template.Must(template.ParseFS(staticFS, "static/plugin.html"))
Akrond8a76b32026-02-20 09:31:56 +0100332
Akrone6767de2026-05-20 10:06:24 +0200333 // Security headers middleware to mitigate MIME-sniffing and referrer
334 // information leaks (OWASP Secure Headers). X-Frame-Options is
335 // intentionally omitted because the service is designed to be embedded
336 // in cross-origin iframes (Kalamar plugin).
Akron2d53b932026-07-15 12:12:54 +0200337 app.Use(func(c fiber.Ctx) error {
Akrone6767de2026-05-20 10:06:24 +0200338 c.Set("X-Content-Type-Options", "nosniff")
339 c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
340 return c.Next()
341 })
342
Akronf1ca8822026-05-20 15:44:00 +0200343 // CORS middleware to allow cross-origin requests from trusted
344 // origins. Required because the service is designed to be
345 // called as a KorAP/Kalamar plugin from cross-origin iframes.
346 // Configurable via the "allowOrigins" YAML key or the
347 // KORAL_MAPPER_ALLOW_ORIGINS environment variable
348 // (default: "https://korap.ids-mannheim.de").
349 app.Use(cors.New(cors.Config{
350 AllowOrigins: yamlConfig.AllowOrigins,
Akron2d53b932026-07-15 12:12:54 +0200351 AllowMethods: []string{"GET", "POST"},
352 AllowHeaders: []string{"Content-Type"},
Akronf1ca8822026-05-20 15:44:00 +0200353 }))
354
Akrone6767de2026-05-20 10:06:24 +0200355 // Rate limiting middleware to prevent resource exhaustion from
356 // request floods. The maximum number of requests per minute
357 // per IP is configurable via the "rateLimit" YAML key or the
358 // KORAL_MAPPER_RATE_LIMIT environment variable (default: 100).
359 rateLimit := yamlConfig.RateLimit
360 if rateLimit <= 0 {
361 rateLimit = 100
362 }
363 app.Use(limiter.New(limiter.Config{
364 Max: rateLimit,
365 Expiration: 1 * time.Minute,
366 LimiterMiddleware: limiter.SlidingWindow{},
367 }))
368
Akron49ceeb42025-05-23 17:46:01 +0200369 // Health check endpoint
Akron2d53b932026-07-15 12:12:54 +0200370 app.Get("/health", func(c fiber.Ctx) error {
Akron49ceeb42025-05-23 17:46:01 +0200371 return c.SendString("OK")
372 })
373
Akrond8a76b32026-02-20 09:31:56 +0100374 // Static file serving from embedded FS
375 app.Get("/static/*", handleStaticFile())
376
Akronbf73a122026-02-27 15:02:16 +0100377 // Composite cascade transformation endpoints (cfg in path)
Akronf7bba072026-05-21 12:36:19 +0200378 app.Post("/query/:cfg", handleCompositeQueryTransform(m, yamlConfig))
379 app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig))
Akron512aab62026-02-20 08:36:12 +0100380
Akron49ceeb42025-05-23 17:46:01 +0200381 // Transformation endpoint
Akronf7bba072026-05-21 12:36:19 +0200382 app.Post("/:map/query", handleTransform(m, yamlConfig))
Akron40aaa632025-06-03 17:57:52 +0200383
Akron4de47a92025-06-27 11:58:11 +0200384 // Response transformation endpoint
Akronf7bba072026-05-21 12:36:19 +0200385 app.Post("/:map/response", handleResponseTransform(m, yamlConfig))
Akron4de47a92025-06-27 11:58:11 +0200386
Akron40aaa632025-06-03 17:57:52 +0200387 // Kalamar plugin endpoint
Akrond8a76b32026-02-20 09:31:56 +0100388 app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
389 app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
390}
391
392func handleStaticFile() fiber.Handler {
Akron2d53b932026-07-15 12:12:54 +0200393 return func(c fiber.Ctx) error {
Akrond8a76b32026-02-20 09:31:56 +0100394 name := c.Params("*")
395 data, err := fs.ReadFile(staticFS, "static/"+name)
396 if err != nil {
397 return c.Status(fiber.StatusNotFound).SendString("not found")
398 }
399 switch {
400 case strings.HasSuffix(name, ".js"):
401 c.Set("Content-Type", "text/javascript; charset=utf-8")
402 case strings.HasSuffix(name, ".css"):
403 c.Set("Content-Type", "text/css; charset=utf-8")
404 case strings.HasSuffix(name, ".html"):
405 c.Set("Content-Type", "text/html; charset=utf-8")
406 }
407 return c.Send(data)
408 }
409}
410
411func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
412 return BasePageData{
413 Title: config.Title,
414 Version: config.Version,
415 Hash: config.Buildhash,
416 Date: config.Buildtime,
417 Description: config.Description,
418 Server: yamlConfig.Server,
419 SDK: yamlConfig.SDK,
Akron43fb1022026-02-20 11:38:49 +0100420 Stylesheet: yamlConfig.Stylesheet,
Akrond8a76b32026-02-20 09:31:56 +0100421 ServiceURL: yamlConfig.ServiceURL,
Akron43fb1022026-02-20 11:38:49 +0100422 CookieName: yamlConfig.CookieName,
Akrond8a76b32026-02-20 09:31:56 +0100423 }
424}
425
426func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
427 data := ConfigPageData{
428 BasePageData: buildBasePageData(yamlConfig),
429 }
430
431 for _, list := range yamlConfig.Lists {
432 normalized := list
433 if normalized.Type == "" {
434 normalized.Type = "annotation"
435 }
436 if list.IsCorpus() {
437 data.CorpusMappings = append(data.CorpusMappings, normalized)
438 } else {
439 data.AnnotationMappings = append(data.AnnotationMappings, normalized)
440 }
441 }
Akron247a93a2026-02-20 16:28:40 +0100442
443 data.MappingSections = []MappingSectionData{
444 {
Akron8bdf5202026-02-24 10:01:15 +0100445 Title: "Request",
446 Mode: "request",
447 CheckboxClass: "request-cb",
448 CheckboxName: "request",
449 FieldsClass: "request-fields",
450 ArrowClass: "request-dir-arrow",
451 ArrowDirection: "atob",
452 ArrowLabel: "\u2192",
Akron247a93a2026-02-20 16:28:40 +0100453 },
454 {
Akron8bdf5202026-02-24 10:01:15 +0100455 Title: "Response",
456 Mode: "response",
457 CheckboxClass: "response-cb",
458 CheckboxName: "response",
459 FieldsClass: "response-fields",
460 ArrowClass: "response-dir-arrow",
461 ArrowDirection: "btoa",
462 ArrowLabel: "\u2190",
Akron247a93a2026-02-20 16:28:40 +0100463 },
464 }
465
Akrond8a76b32026-02-20 09:31:56 +0100466 return data
Akron49ceeb42025-05-23 17:46:01 +0200467}
468
Akronf7bba072026-05-21 12:36:19 +0200469func handleCompositeQueryTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
470 listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
471 for i := range yamlConfig.Lists {
472 listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
Akron8414ae52026-05-19 13:31:14 +0200473 }
474
Akron2d53b932026-07-15 12:12:54 +0200475 return func(c fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100476 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100477 if len(cfgRaw) > maxParamLength {
478 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
479 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
480 })
481 }
482
483 var jsonData any
Akron2d53b932026-07-15 12:12:54 +0200484 if err := c.Bind().Body(&jsonData); err != nil {
Akron512aab62026-02-20 08:36:12 +0100485 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
486 "error": "invalid JSON in request body",
487 })
488 }
489
Akronf7bba072026-05-21 12:36:19 +0200490 entries, err := ParseCfgParam(cfgRaw, yamlConfig.Lists)
Akron512aab62026-02-20 08:36:12 +0100491 if err != nil {
492 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
493 "error": err.Error(),
494 })
495 }
496
497 if len(entries) == 0 {
498 return c.JSON(jsonData)
499 }
500
Akron8414ae52026-05-19 13:31:14 +0200501 rewrites := c.Query("rewrites", "")
502 var rewritesOverride *bool
503 if rewrites != "" {
504 v := rewrites == "true"
505 rewritesOverride = &v
506 }
507
Akron512aab62026-02-20 08:36:12 +0100508 orderedIDs := make([]string, 0, len(entries))
509 opts := make([]mapper.MappingOptions, 0, len(entries))
510 for _, entry := range entries {
511 dir := mapper.AtoB
512 if entry.Direction == "btoa" {
513 dir = mapper.BtoA
514 }
515
Akronf7bba072026-05-21 12:36:19 +0200516 addRewrites := yamlConfig.Rewrites
Akron8414ae52026-05-19 13:31:14 +0200517 if list, ok := listsByID[entry.ID]; ok {
Akronf7bba072026-05-21 12:36:19 +0200518 addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
Akron8414ae52026-05-19 13:31:14 +0200519 }
520 if rewritesOverride != nil {
521 addRewrites = *rewritesOverride
522 }
523
Akron512aab62026-02-20 08:36:12 +0100524 orderedIDs = append(orderedIDs, entry.ID)
525 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200526 Direction: dir,
527 FoundryA: entry.FoundryA,
528 LayerA: entry.LayerA,
529 FoundryB: entry.FoundryB,
530 LayerB: entry.LayerB,
531 FieldA: entry.FieldA,
532 FieldB: entry.FieldB,
533 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100534 })
535 }
536
537 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
538 if err != nil {
539 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
540 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
541 "error": err.Error(),
542 })
543 }
544
545 return c.JSON(result)
546 }
547}
548
Akronf7bba072026-05-21 12:36:19 +0200549func handleCompositeResponseTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
550 listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
551 for i := range yamlConfig.Lists {
552 listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
Akron8414ae52026-05-19 13:31:14 +0200553 }
554
Akron2d53b932026-07-15 12:12:54 +0200555 return func(c fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100556 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100557 if len(cfgRaw) > maxParamLength {
558 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
559 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
560 })
561 }
562
563 var jsonData any
Akron2d53b932026-07-15 12:12:54 +0200564 if err := c.Bind().Body(&jsonData); err != nil {
Akron512aab62026-02-20 08:36:12 +0100565 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
566 "error": "invalid JSON in request body",
567 })
568 }
569
Akronf7bba072026-05-21 12:36:19 +0200570 entries, err := ParseCfgParam(cfgRaw, yamlConfig.Lists)
Akron512aab62026-02-20 08:36:12 +0100571 if err != nil {
572 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
573 "error": err.Error(),
574 })
575 }
576
577 if len(entries) == 0 {
578 return c.JSON(jsonData)
579 }
580
Akron8414ae52026-05-19 13:31:14 +0200581 rewrites := c.Query("rewrites", "")
582 var rewritesOverride *bool
583 if rewrites != "" {
584 v := rewrites == "true"
585 rewritesOverride = &v
586 }
587
Akron512aab62026-02-20 08:36:12 +0100588 orderedIDs := make([]string, 0, len(entries))
589 opts := make([]mapper.MappingOptions, 0, len(entries))
590 for _, entry := range entries {
591 dir := mapper.AtoB
592 if entry.Direction == "btoa" {
593 dir = mapper.BtoA
594 }
595
Akronf7bba072026-05-21 12:36:19 +0200596 addRewrites := yamlConfig.Rewrites
Akron8414ae52026-05-19 13:31:14 +0200597 if list, ok := listsByID[entry.ID]; ok {
Akronf7bba072026-05-21 12:36:19 +0200598 addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
Akron8414ae52026-05-19 13:31:14 +0200599 }
600 if rewritesOverride != nil {
601 addRewrites = *rewritesOverride
602 }
603
Akron512aab62026-02-20 08:36:12 +0100604 orderedIDs = append(orderedIDs, entry.ID)
605 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200606 Direction: dir,
607 FoundryA: entry.FoundryA,
608 LayerA: entry.LayerA,
609 FoundryB: entry.FoundryB,
610 LayerB: entry.LayerB,
611 FieldA: entry.FieldA,
612 FieldB: entry.FieldB,
613 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100614 })
615 }
616
617 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
618 if err != nil {
619 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
620 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
621 "error": err.Error(),
622 })
623 }
624
625 return c.JSON(result)
626 }
627}
628
Akronf7bba072026-05-21 12:36:19 +0200629func handleTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
630 listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
631 for i := range yamlConfig.Lists {
632 listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
Akron8414ae52026-05-19 13:31:14 +0200633 }
634
Akron2d53b932026-07-15 12:12:54 +0200635 return func(c fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200636 // Extract and validate parameters
637 params, err := extractRequestParams(c)
638 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200639 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
640 "error": err.Error(),
641 })
642 }
643
Akron49ceeb42025-05-23 17:46:01 +0200644 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200645 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200646 if err != nil {
647 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
648 "error": err.Error(),
649 })
650 }
651
Akronf7bba072026-05-21 12:36:19 +0200652 // Resolve rewrites: global default -> per-list -> query param
653 addRewrites := yamlConfig.Rewrites
Akron8414ae52026-05-19 13:31:14 +0200654 if list, ok := listsByID[params.MapID]; ok {
Akronf7bba072026-05-21 12:36:19 +0200655 addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
Akron8414ae52026-05-19 13:31:14 +0200656 }
657 if params.Rewrites != nil {
658 addRewrites = *params.Rewrites
659 }
660
Akron49ceeb42025-05-23 17:46:01 +0200661 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200662 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200663 Direction: direction,
664 FoundryA: params.FoundryA,
665 FoundryB: params.FoundryB,
666 LayerA: params.LayerA,
667 LayerB: params.LayerB,
668 AddRewrites: addRewrites,
Akron49ceeb42025-05-23 17:46:01 +0200669 }, jsonData)
670
671 if err != nil {
672 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200673 Str("mapID", params.MapID).
674 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200675 Msg("Failed to apply mappings")
676
677 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
678 "error": err.Error(),
679 })
680 }
681
682 return c.JSON(result)
683 }
684}
Akron74e1c072025-05-26 14:38:25 +0200685
Akronf7bba072026-05-21 12:36:19 +0200686func handleResponseTransform(m *mapper.Mapper, yamlConfig *config.MappingConfig) fiber.Handler {
687 listsByID := make(map[string]*config.MappingList, len(yamlConfig.Lists))
688 for i := range yamlConfig.Lists {
689 listsByID[yamlConfig.Lists[i].ID] = &yamlConfig.Lists[i]
Akron8414ae52026-05-19 13:31:14 +0200690 }
691
Akron2d53b932026-07-15 12:12:54 +0200692 return func(c fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200693 // Extract and validate parameters
694 params, err := extractRequestParams(c)
695 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200696 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
697 "error": err.Error(),
698 })
699 }
700
Akron4de47a92025-06-27 11:58:11 +0200701 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200702 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200703 if err != nil {
704 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
705 "error": err.Error(),
706 })
707 }
708
Akronf7bba072026-05-21 12:36:19 +0200709 // Resolve rewrites: global default -> per-list -> query param
710 addRewrites := yamlConfig.Rewrites
Akron8414ae52026-05-19 13:31:14 +0200711 if list, ok := listsByID[params.MapID]; ok {
Akronf7bba072026-05-21 12:36:19 +0200712 addRewrites = list.EffectiveRewrites(yamlConfig.Rewrites)
Akron8414ae52026-05-19 13:31:14 +0200713 }
714 if params.Rewrites != nil {
715 addRewrites = *params.Rewrites
716 }
717
Akron4de47a92025-06-27 11:58:11 +0200718 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200719 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200720 Direction: direction,
721 FoundryA: params.FoundryA,
722 FoundryB: params.FoundryB,
723 LayerA: params.LayerA,
724 LayerB: params.LayerB,
725 AddRewrites: addRewrites,
Akron4de47a92025-06-27 11:58:11 +0200726 }, jsonData)
727
728 if err != nil {
729 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200730 Str("mapID", params.MapID).
731 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200732 Msg("Failed to apply response mappings")
733
734 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
735 "error": err.Error(),
736 })
737 }
738
739 return c.JSON(result)
740 }
741}
742
Akron74e1c072025-05-26 14:38:25 +0200743// validateInput checks if the input parameters are valid
744func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200745 // Define parameter checks
746 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200747 name string
748 value string
749 }{
750 {"mapID", mapID},
751 {"dir", dir},
752 {"foundryA", foundryA},
753 {"foundryB", foundryB},
754 {"layerA", layerA},
755 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200756 }
757
758 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200759 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200760 if len(param.value) > maxParamLength {
761 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
762 }
Akron74e1c072025-05-26 14:38:25 +0200763 if strings.ContainsAny(param.value, "<>{}[]\\") {
764 return fmt.Errorf("%s contains invalid characters", param.name)
765 }
766 }
767
Akron69d43bf2025-05-26 17:09:00 +0200768 if len(body) > maxInputLength {
769 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
770 }
771
Akron74e1c072025-05-26 14:38:25 +0200772 return nil
773}
Akron40aaa632025-06-03 17:57:52 +0200774
Akronbeee5052026-05-20 09:39:45 +0200775func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *template.Template) fiber.Handler {
Akron2d53b932026-07-15 12:12:54 +0200776 return func(c fiber.Ctx) error {
777 mapID, _ := url.PathUnescape(c.Params("map"))
Akronc376dcc2025-06-04 17:00:18 +0200778
Akrond8a76b32026-02-20 09:31:56 +0100779 // Config page (GET /)
780 if mapID == "" {
781 data := buildConfigPageData(yamlConfig)
782 var buf bytes.Buffer
783 if err := configTmpl.Execute(&buf, data); err != nil {
784 log.Error().Err(err).Msg("Failed to execute config template")
785 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
786 }
787 c.Set("Content-Type", "text/html")
788 return c.Send(buf.Bytes())
789 }
790
791 // Single-mapping page (GET /:map) — existing behavior
Akroncb51f812025-06-30 15:24:20 +0200792 // Get query parameters
793 dir := c.Query("dir", "atob")
794 foundryA := c.Query("foundryA", "")
795 foundryB := c.Query("foundryB", "")
796 layerA := c.Query("layerA", "")
797 layerB := c.Query("layerB", "")
798
Akron49b525c2025-07-03 15:17:06 +0200799 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200800 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
801 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
802 "error": err.Error(),
803 })
804 }
805
Akroncb51f812025-06-30 15:24:20 +0200806 if dir != "atob" && dir != "btoa" {
807 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
808 "error": "invalid direction, must be 'atob' or 'btoa'",
809 })
810 }
811
Akroncb51f812025-06-30 15:24:20 +0200812 queryParams := QueryParams{
813 Dir: dir,
814 FoundryA: foundryA,
815 FoundryB: foundryB,
816 LayerA: layerA,
817 LayerB: layerB,
818 }
819
Akrond8a76b32026-02-20 09:31:56 +0100820 queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
821 if err != nil {
822 log.Warn().Err(err).Msg("Failed to build query service URL")
823 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
824 }
825 reversed := queryParams
826 if queryParams.Dir == "btoa" {
827 reversed.Dir = "atob"
828 } else {
829 reversed.Dir = "btoa"
830 }
831 responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
832 if err != nil {
833 log.Warn().Err(err).Msg("Failed to build response service URL")
834 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
835 }
Akron40aaa632025-06-03 17:57:52 +0200836
Akrond8a76b32026-02-20 09:31:56 +0100837 data := SingleMappingPageData{
838 BasePageData: buildBasePageData(yamlConfig),
839 MapID: mapID,
840 Mappings: yamlConfig.Lists,
841 QueryURL: queryURL,
842 ResponseURL: responseURL,
843 }
844
845 var buf bytes.Buffer
846 if err := pluginTmpl.Execute(&buf, data); err != nil {
847 log.Error().Err(err).Msg("Failed to execute plugin template")
848 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
849 }
Akron40aaa632025-06-03 17:57:52 +0200850 c.Set("Content-Type", "text/html")
Akrond8a76b32026-02-20 09:31:56 +0100851 return c.Send(buf.Bytes())
Akron40aaa632025-06-03 17:57:52 +0200852 }
853}
854
Akrond8a76b32026-02-20 09:31:56 +0100855func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
856 service, err := url.Parse(serviceURL)
857 if err != nil {
858 return "", err
Akronc376dcc2025-06-04 17:00:18 +0200859 }
Akrond8a76b32026-02-20 09:31:56 +0100860 service.Path = path.Join(service.Path, mapID, endpoint)
861 service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
862 return service.String(), nil
863}
Akronc376dcc2025-06-04 17:00:18 +0200864
Akrond8a76b32026-02-20 09:31:56 +0100865func formatConsoleField(value string) string {
866 if strings.ContainsAny(value, " \t") {
867 return strconv.Quote(value)
Akron40aaa632025-06-03 17:57:52 +0200868 }
Akrond8a76b32026-02-20 09:31:56 +0100869 return value
Akron40aaa632025-06-03 17:57:52 +0200870}
Akron14678dc2025-06-05 13:01:38 +0200871
Akroncb51f812025-06-30 15:24:20 +0200872// buildQueryParams builds a query string from the provided parameters
873func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
874 params := url.Values{}
875 if dir != "" {
876 params.Add("dir", dir)
877 }
878 if foundryA != "" {
879 params.Add("foundryA", foundryA)
880 }
881 if foundryB != "" {
882 params.Add("foundryB", foundryB)
883 }
884 if layerA != "" {
885 params.Add("layerA", layerA)
886 }
887 if layerB != "" {
888 params.Add("layerB", layerB)
889 }
890 return params.Encode()
891}
892
Akron14678dc2025-06-05 13:01:38 +0200893// expandGlobs expands glob patterns in the slice of file paths
894// Returns the expanded list of files or an error if glob expansion fails
895func expandGlobs(patterns []string) ([]string, error) {
896 var expanded []string
897
898 for _, pattern := range patterns {
899 // Use filepath.Glob which works cross-platform
900 matches, err := filepath.Glob(pattern)
901 if err != nil {
902 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
903 }
904
905 // If no matches found, treat as literal filename (consistent with shell behavior)
906 if len(matches) == 0 {
907 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
908 expanded = append(expanded, pattern)
909 } else {
910 expanded = append(expanded, matches...)
911 }
912 }
913
914 return expanded, nil
915}