blob: eac2984d47858f1f74cf1ee78f65afde746f2b6b [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"
Akrone6767de2026-05-20 10:06:24 +020023 "github.com/gofiber/fiber/v2/middleware/limiter"
Akron49ceeb42025-05-23 17:46:01 +020024 "github.com/rs/zerolog"
25 "github.com/rs/zerolog/log"
26)
27
Akrond8a76b32026-02-20 09:31:56 +010028//go:embed static/*
29var staticFS embed.FS
30
Akron74e1c072025-05-26 14:38:25 +020031const (
32 maxInputLength = 1024 * 1024 // 1MB
33 maxParamLength = 1024 // 1KB
34)
35
Akrona00d4752025-05-26 17:34:36 +020036type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020037 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020038 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
Akron14678dc2025-06-05 13:01:38 +020039 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
Akrona8a66ce2025-06-05 10:50:17 +020040 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020041}
42
Akrond8a76b32026-02-20 09:31:56 +010043type BasePageData struct {
Akron40aaa632025-06-03 17:57:52 +020044 Title string
45 Version string
Akronfc77b5e2025-06-04 11:44:43 +020046 Hash string
47 Date string
Akron40aaa632025-06-03 17:57:52 +020048 Description string
Akron06d21f02025-06-04 14:36:07 +020049 Server string
50 SDK string
Akron43fb1022026-02-20 11:38:49 +010051 Stylesheet string
Akron2ac2ec02025-06-05 15:26:42 +020052 ServiceURL string
Akron43fb1022026-02-20 11:38:49 +010053 CookieName string
Akrond8a76b32026-02-20 09:31:56 +010054}
55
56type SingleMappingPageData struct {
57 BasePageData
Akronc376dcc2025-06-04 17:00:18 +020058 MapID string
Akrond8a76b32026-02-20 09:31:56 +010059 Mappings []config.MappingList
60 QueryURL string
61 ResponseURL string
Akron40aaa632025-06-03 17:57:52 +020062}
63
Akroncb51f812025-06-30 15:24:20 +020064type QueryParams struct {
65 Dir string
66 FoundryA string
67 FoundryB string
68 LayerA string
69 LayerB string
70}
71
Akron49b525c2025-07-03 15:17:06 +020072// requestParams holds common request parameters
73type requestParams struct {
74 MapID string
75 Dir string
76 FoundryA string
77 FoundryB string
78 LayerA string
79 LayerB string
Akron8414ae52026-05-19 13:31:14 +020080 Rewrites *bool // nil = use mapping list default; non-nil = override
Akron49b525c2025-07-03 15:17:06 +020081}
82
Akron247a93a2026-02-20 16:28:40 +010083// MappingSectionData contains per-section UI metadata so request and response
84// rows can be rendered from one shared template block.
85type MappingSectionData struct {
86 Title string
87 Mode string
88 CheckboxClass string
89 CheckboxName string
90 FieldsClass string
91 ArrowClass string
92 ArrowDirection string
93 ArrowLabel string
94 AnnotationLabel string
95}
96
Akrond8a76b32026-02-20 09:31:56 +010097// ConfigPageData holds all data passed to the configuration page template.
98type ConfigPageData struct {
99 BasePageData
100 AnnotationMappings []config.MappingList
101 CorpusMappings []config.MappingList
Akron247a93a2026-02-20 16:28:40 +0100102 MappingSections []MappingSectionData
Akrond8a76b32026-02-20 09:31:56 +0100103}
104
Akrona00d4752025-05-26 17:34:36 +0200105func parseConfig() *appConfig {
106 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +0200107
108 desc := config.Description
109 desc += " [" + config.Version + "]"
110
Akron1fc750e2025-05-26 16:54:18 +0200111 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +0200112 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +0200113 kong.UsageOnError(),
114 )
115 if ctx.Error != nil {
116 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +0200117 os.Exit(1)
118 }
Akron49ceeb42025-05-23 17:46:01 +0200119 return cfg
120}
121
122func setupLogger(level string) {
123 // Parse log level
124 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
125 if err != nil {
126 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
127 lvl = zerolog.InfoLevel
128 }
129
130 // Configure zerolog
131 zerolog.SetGlobalLevel(lvl)
132 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
133}
134
Akron3caee162025-07-01 17:44:58 +0200135// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
136func setupFiberLogger() fiber.Handler {
137 // Check if HTTP request logging should be enabled based on current log level
138 currentLevel := zerolog.GlobalLevel()
139
140 // Only enable HTTP request logging if log level is debug or info
141 if currentLevel > zerolog.InfoLevel {
142 return func(c *fiber.Ctx) error {
143 return c.Next()
144 }
145 }
146
147 return func(c *fiber.Ctx) error {
148 // Record start time
149 start := time.Now()
150
151 // Process request
152 err := c.Next()
153
154 // Calculate latency
155 latency := time.Since(start)
156 status := c.Response().StatusCode()
157
158 // Determine log level based on status code
159 logEvent := log.Info()
160 if status >= 400 && status < 500 {
161 logEvent = log.Warn()
162 } else if status >= 500 {
163 logEvent = log.Error()
164 }
165
166 // Log the request
167 logEvent.
168 Int("status", status).
169 Dur("latency", latency).
170 Str("method", c.Method()).
171 Str("path", c.Path()).
172 Str("ip", c.IP()).
173 Str("user_agent", c.Get("User-Agent")).
174 Msg("HTTP request")
175
176 return err
177 }
178}
179
Akron49b525c2025-07-03 15:17:06 +0200180// extractRequestParams extracts and validates common request parameters
181func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
182 params := &requestParams{
183 MapID: c.Params("map"),
184 Dir: c.Query("dir", "atob"),
185 FoundryA: c.Query("foundryA", ""),
186 FoundryB: c.Query("foundryB", ""),
187 LayerA: c.Query("layerA", ""),
188 LayerB: c.Query("layerB", ""),
189 }
190
Akron8414ae52026-05-19 13:31:14 +0200191 if rewrites := c.Query("rewrites", ""); rewrites != "" {
192 v := rewrites == "true"
193 params.Rewrites = &v
194 }
195
Akron49b525c2025-07-03 15:17:06 +0200196 // Validate input parameters
197 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
198 return nil, err
199 }
200
201 // Validate direction
202 if params.Dir != "atob" && params.Dir != "btoa" {
203 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
204 }
205
206 return params, nil
207}
208
209// parseRequestBody parses JSON request body and direction
210func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
211 var jsonData any
212 if err := c.BodyParser(&jsonData); err != nil {
213 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
214 }
215
216 direction, err := mapper.ParseDirection(dir)
217 if err != nil {
218 return nil, mapper.BtoA, err
219 }
220
221 return jsonData, direction, nil
222}
223
Akron49ceeb42025-05-23 17:46:01 +0200224func main() {
Akroned787d02026-05-20 12:31:07 +0200225 // Confine config file loading to the current working directory tree
226 // (path traversal prevention). Can be overridden via the "basePath"
227 // YAML field or the KORAL_MAPPER_BASE_PATH environment variable.
228 // In Docker (WORKDIR /), the default "/" naturally allows all paths.
229 cwd, err := os.Getwd()
230 if err != nil {
231 log.Fatal().Err(err).Msg("Failed to determine working directory")
232 }
233 config.AllowedBasePath = cwd
234
Akron49ceeb42025-05-23 17:46:01 +0200235 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200236 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200237
Akrone1cff7c2025-06-04 18:43:32 +0200238 // Validate command line arguments
239 if cfg.Config == "" && len(cfg.Mappings) == 0 {
240 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
241 }
242
Akron14678dc2025-06-05 13:01:38 +0200243 // Expand glob patterns in mapping files
244 expandedMappings, err := expandGlobs(cfg.Mappings)
245 if err != nil {
246 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
247 }
248
Akrone1cff7c2025-06-04 18:43:32 +0200249 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200250 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200251 if err != nil {
252 log.Fatal().Err(err).Msg("Failed to load configuration")
253 }
254
Akroned787d02026-05-20 12:31:07 +0200255 // Apply basePath from config/env if specified (overrides CWD default)
256 if yamlConfig.BasePath != "" {
257 config.AllowedBasePath = yamlConfig.BasePath
258 }
259
Akrona8a66ce2025-06-05 10:50:17 +0200260 finalPort := yamlConfig.Port
261 finalLogLevel := yamlConfig.LogLevel
262
263 // Use command line values if provided (they override config file)
264 if cfg.Port != nil {
265 finalPort = *cfg.Port
266 }
267 if cfg.LogLevel != nil {
268 finalLogLevel = *cfg.LogLevel
269 }
270
271 // Set up logging with the final log level
272 setupLogger(finalLogLevel)
273
Akron49ceeb42025-05-23 17:46:01 +0200274 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200275 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200276 if err != nil {
277 log.Fatal().Err(err).Msg("Failed to create mapper")
278 }
279
280 // Create fiber app
281 app := fiber.New(fiber.Config{
282 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200283 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200284 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
285 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200286 })
287
Akron3caee162025-07-01 17:44:58 +0200288 // Add zerolog-integrated logger middleware
289 app.Use(setupFiberLogger())
290
Akron49ceeb42025-05-23 17:46:01 +0200291 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200292 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200293
294 // Start server
295 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200296 log.Info().Int("port", finalPort).Msg("Starting server")
Akrond8a76b32026-02-20 09:31:56 +0100297 fmt.Printf("Starting server port=%d\n", finalPort)
Akronae3ffde2025-06-05 14:04:06 +0200298
299 for _, list := range yamlConfig.Lists {
300 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
Akrond8a76b32026-02-20 09:31:56 +0100301 fmt.Printf("Loaded mapping desc=%s id=%s\n",
302 formatConsoleField(list.Description),
303 list.ID,
304 )
Akronae3ffde2025-06-05 14:04:06 +0200305 }
306
Akrona8a66ce2025-06-05 10:50:17 +0200307 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200308 log.Fatal().Err(err).Msg("Server error")
309 }
310 }()
311
312 // Wait for interrupt signal
313 sigChan := make(chan os.Signal, 1)
314 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
315 <-sigChan
316
317 // Graceful shutdown
318 log.Info().Msg("Shutting down server")
319 if err := app.Shutdown(); err != nil {
320 log.Error().Err(err).Msg("Error during shutdown")
321 }
322}
323
Akron06d21f02025-06-04 14:36:07 +0200324func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akrond8a76b32026-02-20 09:31:56 +0100325 configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
Akronbeee5052026-05-20 09:39:45 +0200326 pluginTmpl := template.Must(template.ParseFS(staticFS, "static/plugin.html"))
Akrond8a76b32026-02-20 09:31:56 +0100327
Akrone6767de2026-05-20 10:06:24 +0200328 // Security headers middleware to mitigate MIME-sniffing and referrer
329 // information leaks (OWASP Secure Headers). X-Frame-Options is
330 // intentionally omitted because the service is designed to be embedded
331 // in cross-origin iframes (Kalamar plugin).
332 app.Use(func(c *fiber.Ctx) error {
333 c.Set("X-Content-Type-Options", "nosniff")
334 c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
335 return c.Next()
336 })
337
338 // Rate limiting middleware to prevent resource exhaustion from
339 // request floods. The maximum number of requests per minute
340 // per IP is configurable via the "rateLimit" YAML key or the
341 // KORAL_MAPPER_RATE_LIMIT environment variable (default: 100).
342 rateLimit := yamlConfig.RateLimit
343 if rateLimit <= 0 {
344 rateLimit = 100
345 }
346 app.Use(limiter.New(limiter.Config{
347 Max: rateLimit,
348 Expiration: 1 * time.Minute,
349 LimiterMiddleware: limiter.SlidingWindow{},
350 }))
351
Akron49ceeb42025-05-23 17:46:01 +0200352 // Health check endpoint
353 app.Get("/health", func(c *fiber.Ctx) error {
354 return c.SendString("OK")
355 })
356
Akrond8a76b32026-02-20 09:31:56 +0100357 // Static file serving from embedded FS
358 app.Get("/static/*", handleStaticFile())
359
Akronbf73a122026-02-27 15:02:16 +0100360 // Composite cascade transformation endpoints (cfg in path)
361 app.Post("/query/:cfg", handleCompositeQueryTransform(m, yamlConfig.Lists))
362 app.Post("/response/:cfg", handleCompositeResponseTransform(m, yamlConfig.Lists))
Akron512aab62026-02-20 08:36:12 +0100363
Akron49ceeb42025-05-23 17:46:01 +0200364 // Transformation endpoint
Akron8414ae52026-05-19 13:31:14 +0200365 app.Post("/:map/query", handleTransform(m, yamlConfig.Lists))
Akron40aaa632025-06-03 17:57:52 +0200366
Akron4de47a92025-06-27 11:58:11 +0200367 // Response transformation endpoint
Akron8414ae52026-05-19 13:31:14 +0200368 app.Post("/:map/response", handleResponseTransform(m, yamlConfig.Lists))
Akron4de47a92025-06-27 11:58:11 +0200369
Akron40aaa632025-06-03 17:57:52 +0200370 // Kalamar plugin endpoint
Akrond8a76b32026-02-20 09:31:56 +0100371 app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
372 app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
373}
374
375func handleStaticFile() fiber.Handler {
376 return func(c *fiber.Ctx) error {
377 name := c.Params("*")
378 data, err := fs.ReadFile(staticFS, "static/"+name)
379 if err != nil {
380 return c.Status(fiber.StatusNotFound).SendString("not found")
381 }
382 switch {
383 case strings.HasSuffix(name, ".js"):
384 c.Set("Content-Type", "text/javascript; charset=utf-8")
385 case strings.HasSuffix(name, ".css"):
386 c.Set("Content-Type", "text/css; charset=utf-8")
387 case strings.HasSuffix(name, ".html"):
388 c.Set("Content-Type", "text/html; charset=utf-8")
389 }
390 return c.Send(data)
391 }
392}
393
394func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
395 return BasePageData{
396 Title: config.Title,
397 Version: config.Version,
398 Hash: config.Buildhash,
399 Date: config.Buildtime,
400 Description: config.Description,
401 Server: yamlConfig.Server,
402 SDK: yamlConfig.SDK,
Akron43fb1022026-02-20 11:38:49 +0100403 Stylesheet: yamlConfig.Stylesheet,
Akrond8a76b32026-02-20 09:31:56 +0100404 ServiceURL: yamlConfig.ServiceURL,
Akron43fb1022026-02-20 11:38:49 +0100405 CookieName: yamlConfig.CookieName,
Akrond8a76b32026-02-20 09:31:56 +0100406 }
407}
408
409func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
410 data := ConfigPageData{
411 BasePageData: buildBasePageData(yamlConfig),
412 }
413
414 for _, list := range yamlConfig.Lists {
415 normalized := list
416 if normalized.Type == "" {
417 normalized.Type = "annotation"
418 }
419 if list.IsCorpus() {
420 data.CorpusMappings = append(data.CorpusMappings, normalized)
421 } else {
422 data.AnnotationMappings = append(data.AnnotationMappings, normalized)
423 }
424 }
Akron247a93a2026-02-20 16:28:40 +0100425
426 data.MappingSections = []MappingSectionData{
427 {
Akron8bdf5202026-02-24 10:01:15 +0100428 Title: "Request",
429 Mode: "request",
430 CheckboxClass: "request-cb",
431 CheckboxName: "request",
432 FieldsClass: "request-fields",
433 ArrowClass: "request-dir-arrow",
434 ArrowDirection: "atob",
435 ArrowLabel: "\u2192",
Akron247a93a2026-02-20 16:28:40 +0100436 },
437 {
Akron8bdf5202026-02-24 10:01:15 +0100438 Title: "Response",
439 Mode: "response",
440 CheckboxClass: "response-cb",
441 CheckboxName: "response",
442 FieldsClass: "response-fields",
443 ArrowClass: "response-dir-arrow",
444 ArrowDirection: "btoa",
445 ArrowLabel: "\u2190",
Akron247a93a2026-02-20 16:28:40 +0100446 },
447 }
448
Akrond8a76b32026-02-20 09:31:56 +0100449 return data
Akron49ceeb42025-05-23 17:46:01 +0200450}
451
Akron512aab62026-02-20 08:36:12 +0100452func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
Akron8414ae52026-05-19 13:31:14 +0200453 listsByID := make(map[string]*config.MappingList, len(lists))
454 for i := range lists {
455 listsByID[lists[i].ID] = &lists[i]
456 }
457
Akron512aab62026-02-20 08:36:12 +0100458 return func(c *fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100459 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100460 if len(cfgRaw) > maxParamLength {
461 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
462 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
463 })
464 }
465
466 var jsonData any
467 if err := c.BodyParser(&jsonData); err != nil {
468 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
469 "error": "invalid JSON in request body",
470 })
471 }
472
473 entries, err := ParseCfgParam(cfgRaw, lists)
474 if err != nil {
475 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
476 "error": err.Error(),
477 })
478 }
479
480 if len(entries) == 0 {
481 return c.JSON(jsonData)
482 }
483
Akron8414ae52026-05-19 13:31:14 +0200484 rewrites := c.Query("rewrites", "")
485 var rewritesOverride *bool
486 if rewrites != "" {
487 v := rewrites == "true"
488 rewritesOverride = &v
489 }
490
Akron512aab62026-02-20 08:36:12 +0100491 orderedIDs := make([]string, 0, len(entries))
492 opts := make([]mapper.MappingOptions, 0, len(entries))
493 for _, entry := range entries {
494 dir := mapper.AtoB
495 if entry.Direction == "btoa" {
496 dir = mapper.BtoA
497 }
498
Akron8414ae52026-05-19 13:31:14 +0200499 addRewrites := false
500 if list, ok := listsByID[entry.ID]; ok {
501 addRewrites = list.Rewrites
502 }
503 if rewritesOverride != nil {
504 addRewrites = *rewritesOverride
505 }
506
Akron512aab62026-02-20 08:36:12 +0100507 orderedIDs = append(orderedIDs, entry.ID)
508 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200509 Direction: dir,
510 FoundryA: entry.FoundryA,
511 LayerA: entry.LayerA,
512 FoundryB: entry.FoundryB,
513 LayerB: entry.LayerB,
514 FieldA: entry.FieldA,
515 FieldB: entry.FieldB,
516 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100517 })
518 }
519
520 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
521 if err != nil {
522 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
523 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
524 "error": err.Error(),
525 })
526 }
527
528 return c.JSON(result)
529 }
530}
531
532func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
Akron8414ae52026-05-19 13:31:14 +0200533 listsByID := make(map[string]*config.MappingList, len(lists))
534 for i := range lists {
535 listsByID[lists[i].ID] = &lists[i]
536 }
537
Akron512aab62026-02-20 08:36:12 +0100538 return func(c *fiber.Ctx) error {
Akronbf73a122026-02-27 15:02:16 +0100539 cfgRaw := c.Params("cfg")
Akron512aab62026-02-20 08:36:12 +0100540 if len(cfgRaw) > maxParamLength {
541 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
542 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
543 })
544 }
545
546 var jsonData any
547 if err := c.BodyParser(&jsonData); err != nil {
548 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
549 "error": "invalid JSON in request body",
550 })
551 }
552
553 entries, err := ParseCfgParam(cfgRaw, lists)
554 if err != nil {
555 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
556 "error": err.Error(),
557 })
558 }
559
560 if len(entries) == 0 {
561 return c.JSON(jsonData)
562 }
563
Akron8414ae52026-05-19 13:31:14 +0200564 rewrites := c.Query("rewrites", "")
565 var rewritesOverride *bool
566 if rewrites != "" {
567 v := rewrites == "true"
568 rewritesOverride = &v
569 }
570
Akron512aab62026-02-20 08:36:12 +0100571 orderedIDs := make([]string, 0, len(entries))
572 opts := make([]mapper.MappingOptions, 0, len(entries))
573 for _, entry := range entries {
574 dir := mapper.AtoB
575 if entry.Direction == "btoa" {
576 dir = mapper.BtoA
577 }
578
Akron8414ae52026-05-19 13:31:14 +0200579 addRewrites := false
580 if list, ok := listsByID[entry.ID]; ok {
581 addRewrites = list.Rewrites
582 }
583 if rewritesOverride != nil {
584 addRewrites = *rewritesOverride
585 }
586
Akron512aab62026-02-20 08:36:12 +0100587 orderedIDs = append(orderedIDs, entry.ID)
588 opts = append(opts, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200589 Direction: dir,
590 FoundryA: entry.FoundryA,
591 LayerA: entry.LayerA,
592 FoundryB: entry.FoundryB,
593 LayerB: entry.LayerB,
594 FieldA: entry.FieldA,
595 FieldB: entry.FieldB,
596 AddRewrites: addRewrites,
Akron512aab62026-02-20 08:36:12 +0100597 })
598 }
599
600 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
601 if err != nil {
602 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
603 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
604 "error": err.Error(),
605 })
606 }
607
608 return c.JSON(result)
609 }
610}
611
Akron8414ae52026-05-19 13:31:14 +0200612func handleTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
613 listsByID := make(map[string]*config.MappingList, len(lists))
614 for i := range lists {
615 listsByID[lists[i].ID] = &lists[i]
616 }
617
Akron49ceeb42025-05-23 17:46:01 +0200618 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200619 // Extract and validate parameters
620 params, err := extractRequestParams(c)
621 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200622 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
623 "error": err.Error(),
624 })
625 }
626
Akron49ceeb42025-05-23 17:46:01 +0200627 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200628 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200629 if err != nil {
630 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
631 "error": err.Error(),
632 })
633 }
634
Akron8414ae52026-05-19 13:31:14 +0200635 // Determine rewrites: query param overrides YAML default
636 addRewrites := false
637 if list, ok := listsByID[params.MapID]; ok {
638 addRewrites = list.Rewrites
639 }
640 if params.Rewrites != nil {
641 addRewrites = *params.Rewrites
642 }
643
Akron49ceeb42025-05-23 17:46:01 +0200644 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200645 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200646 Direction: direction,
647 FoundryA: params.FoundryA,
648 FoundryB: params.FoundryB,
649 LayerA: params.LayerA,
650 LayerB: params.LayerB,
651 AddRewrites: addRewrites,
Akron49ceeb42025-05-23 17:46:01 +0200652 }, jsonData)
653
654 if err != nil {
655 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200656 Str("mapID", params.MapID).
657 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200658 Msg("Failed to apply mappings")
659
660 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
661 "error": err.Error(),
662 })
663 }
664
665 return c.JSON(result)
666 }
667}
Akron74e1c072025-05-26 14:38:25 +0200668
Akron8414ae52026-05-19 13:31:14 +0200669func handleResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
670 listsByID := make(map[string]*config.MappingList, len(lists))
671 for i := range lists {
672 listsByID[lists[i].ID] = &lists[i]
673 }
674
Akron4de47a92025-06-27 11:58:11 +0200675 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200676 // Extract and validate parameters
677 params, err := extractRequestParams(c)
678 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200679 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
680 "error": err.Error(),
681 })
682 }
683
Akron4de47a92025-06-27 11:58:11 +0200684 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200685 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200686 if err != nil {
687 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
688 "error": err.Error(),
689 })
690 }
691
Akron8414ae52026-05-19 13:31:14 +0200692 // Determine rewrites: query param overrides YAML default
693 addRewrites := false
694 if list, ok := listsByID[params.MapID]; ok {
695 addRewrites = list.Rewrites
696 }
697 if params.Rewrites != nil {
698 addRewrites = *params.Rewrites
699 }
700
Akron4de47a92025-06-27 11:58:11 +0200701 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200702 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron8414ae52026-05-19 13:31:14 +0200703 Direction: direction,
704 FoundryA: params.FoundryA,
705 FoundryB: params.FoundryB,
706 LayerA: params.LayerA,
707 LayerB: params.LayerB,
708 AddRewrites: addRewrites,
Akron4de47a92025-06-27 11:58:11 +0200709 }, jsonData)
710
711 if err != nil {
712 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200713 Str("mapID", params.MapID).
714 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200715 Msg("Failed to apply response mappings")
716
717 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
718 "error": err.Error(),
719 })
720 }
721
722 return c.JSON(result)
723 }
724}
725
Akron74e1c072025-05-26 14:38:25 +0200726// validateInput checks if the input parameters are valid
727func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200728 // Define parameter checks
729 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200730 name string
731 value string
732 }{
733 {"mapID", mapID},
734 {"dir", dir},
735 {"foundryA", foundryA},
736 {"foundryB", foundryB},
737 {"layerA", layerA},
738 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200739 }
740
741 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200742 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200743 if len(param.value) > maxParamLength {
744 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
745 }
Akron74e1c072025-05-26 14:38:25 +0200746 if strings.ContainsAny(param.value, "<>{}[]\\") {
747 return fmt.Errorf("%s contains invalid characters", param.name)
748 }
749 }
750
Akron69d43bf2025-05-26 17:09:00 +0200751 if len(body) > maxInputLength {
752 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
753 }
754
Akron74e1c072025-05-26 14:38:25 +0200755 return nil
756}
Akron40aaa632025-06-03 17:57:52 +0200757
Akronbeee5052026-05-20 09:39:45 +0200758func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *template.Template) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200759 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200760 mapID := c.Params("map")
761
Akrond8a76b32026-02-20 09:31:56 +0100762 // Config page (GET /)
763 if mapID == "" {
764 data := buildConfigPageData(yamlConfig)
765 var buf bytes.Buffer
766 if err := configTmpl.Execute(&buf, data); err != nil {
767 log.Error().Err(err).Msg("Failed to execute config template")
768 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
769 }
770 c.Set("Content-Type", "text/html")
771 return c.Send(buf.Bytes())
772 }
773
774 // Single-mapping page (GET /:map) — existing behavior
Akroncb51f812025-06-30 15:24:20 +0200775 // Get query parameters
776 dir := c.Query("dir", "atob")
777 foundryA := c.Query("foundryA", "")
778 foundryB := c.Query("foundryB", "")
779 layerA := c.Query("layerA", "")
780 layerB := c.Query("layerB", "")
781
Akron49b525c2025-07-03 15:17:06 +0200782 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200783 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
784 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
785 "error": err.Error(),
786 })
787 }
788
Akroncb51f812025-06-30 15:24:20 +0200789 if dir != "atob" && dir != "btoa" {
790 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
791 "error": "invalid direction, must be 'atob' or 'btoa'",
792 })
793 }
794
Akroncb51f812025-06-30 15:24:20 +0200795 queryParams := QueryParams{
796 Dir: dir,
797 FoundryA: foundryA,
798 FoundryB: foundryB,
799 LayerA: layerA,
800 LayerB: layerB,
801 }
802
Akrond8a76b32026-02-20 09:31:56 +0100803 queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
804 if err != nil {
805 log.Warn().Err(err).Msg("Failed to build query service URL")
806 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
807 }
808 reversed := queryParams
809 if queryParams.Dir == "btoa" {
810 reversed.Dir = "atob"
811 } else {
812 reversed.Dir = "btoa"
813 }
814 responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
815 if err != nil {
816 log.Warn().Err(err).Msg("Failed to build response service URL")
817 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
818 }
Akron40aaa632025-06-03 17:57:52 +0200819
Akrond8a76b32026-02-20 09:31:56 +0100820 data := SingleMappingPageData{
821 BasePageData: buildBasePageData(yamlConfig),
822 MapID: mapID,
823 Mappings: yamlConfig.Lists,
824 QueryURL: queryURL,
825 ResponseURL: responseURL,
826 }
827
828 var buf bytes.Buffer
829 if err := pluginTmpl.Execute(&buf, data); err != nil {
830 log.Error().Err(err).Msg("Failed to execute plugin template")
831 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
832 }
Akron40aaa632025-06-03 17:57:52 +0200833 c.Set("Content-Type", "text/html")
Akrond8a76b32026-02-20 09:31:56 +0100834 return c.Send(buf.Bytes())
Akron40aaa632025-06-03 17:57:52 +0200835 }
836}
837
Akrond8a76b32026-02-20 09:31:56 +0100838func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
839 service, err := url.Parse(serviceURL)
840 if err != nil {
841 return "", err
Akronc376dcc2025-06-04 17:00:18 +0200842 }
Akrond8a76b32026-02-20 09:31:56 +0100843 service.Path = path.Join(service.Path, mapID, endpoint)
844 service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
845 return service.String(), nil
846}
Akronc376dcc2025-06-04 17:00:18 +0200847
Akrond8a76b32026-02-20 09:31:56 +0100848func formatConsoleField(value string) string {
849 if strings.ContainsAny(value, " \t") {
850 return strconv.Quote(value)
Akron40aaa632025-06-03 17:57:52 +0200851 }
Akrond8a76b32026-02-20 09:31:56 +0100852 return value
Akron40aaa632025-06-03 17:57:52 +0200853}
Akron14678dc2025-06-05 13:01:38 +0200854
Akroncb51f812025-06-30 15:24:20 +0200855// buildQueryParams builds a query string from the provided parameters
856func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
857 params := url.Values{}
858 if dir != "" {
859 params.Add("dir", dir)
860 }
861 if foundryA != "" {
862 params.Add("foundryA", foundryA)
863 }
864 if foundryB != "" {
865 params.Add("foundryB", foundryB)
866 }
867 if layerA != "" {
868 params.Add("layerA", layerA)
869 }
870 if layerB != "" {
871 params.Add("layerB", layerB)
872 }
873 return params.Encode()
874}
875
Akron14678dc2025-06-05 13:01:38 +0200876// expandGlobs expands glob patterns in the slice of file paths
877// Returns the expanded list of files or an error if glob expansion fails
878func expandGlobs(patterns []string) ([]string, error) {
879 var expanded []string
880
881 for _, pattern := range patterns {
882 // Use filepath.Glob which works cross-platform
883 matches, err := filepath.Glob(pattern)
884 if err != nil {
885 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
886 }
887
888 // If no matches found, treat as literal filename (consistent with shell behavior)
889 if len(matches) == 0 {
890 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
891 expanded = append(expanded, pattern)
892 } else {
893 expanded = append(expanded, matches...)
894 }
895 }
896
897 return expanded, nil
898}