blob: 1286af054759e63f9b6e1dbd15aa8ebd301c6a29 [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"
Akrond8a76b32026-02-20 09:31:56 +010017 texttemplate "text/template"
Akron3caee162025-07-01 17:44:58 +020018 "time"
Akron49ceeb42025-05-23 17:46:01 +020019
Akron2ef703c2025-07-03 15:57:42 +020020 "github.com/KorAP/Koral-Mapper/config"
21 "github.com/KorAP/Koral-Mapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020022 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020023 "github.com/gofiber/fiber/v2"
24 "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
Akron2ac2ec02025-06-05 15:26:42 +020051 ServiceURL string
Akrond8a76b32026-02-20 09:31:56 +010052}
53
54type SingleMappingPageData struct {
55 BasePageData
Akronc376dcc2025-06-04 17:00:18 +020056 MapID string
Akrond8a76b32026-02-20 09:31:56 +010057 Mappings []config.MappingList
58 QueryURL string
59 ResponseURL string
Akron40aaa632025-06-03 17:57:52 +020060}
61
Akroncb51f812025-06-30 15:24:20 +020062type QueryParams struct {
63 Dir string
64 FoundryA string
65 FoundryB string
66 LayerA string
67 LayerB string
68}
69
Akron49b525c2025-07-03 15:17:06 +020070// requestParams holds common request parameters
71type requestParams struct {
72 MapID string
73 Dir string
74 FoundryA string
75 FoundryB string
76 LayerA string
77 LayerB string
78}
79
Akrond8a76b32026-02-20 09:31:56 +010080// ConfigPageData holds all data passed to the configuration page template.
81type ConfigPageData struct {
82 BasePageData
83 AnnotationMappings []config.MappingList
84 CorpusMappings []config.MappingList
85}
86
Akrona00d4752025-05-26 17:34:36 +020087func parseConfig() *appConfig {
88 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020089
90 desc := config.Description
91 desc += " [" + config.Version + "]"
92
Akron1fc750e2025-05-26 16:54:18 +020093 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020094 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020095 kong.UsageOnError(),
96 )
97 if ctx.Error != nil {
98 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020099 os.Exit(1)
100 }
Akron49ceeb42025-05-23 17:46:01 +0200101 return cfg
102}
103
104func setupLogger(level string) {
105 // Parse log level
106 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
107 if err != nil {
108 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
109 lvl = zerolog.InfoLevel
110 }
111
112 // Configure zerolog
113 zerolog.SetGlobalLevel(lvl)
114 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
115}
116
Akron3caee162025-07-01 17:44:58 +0200117// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
118func setupFiberLogger() fiber.Handler {
119 // Check if HTTP request logging should be enabled based on current log level
120 currentLevel := zerolog.GlobalLevel()
121
122 // Only enable HTTP request logging if log level is debug or info
123 if currentLevel > zerolog.InfoLevel {
124 return func(c *fiber.Ctx) error {
125 return c.Next()
126 }
127 }
128
129 return func(c *fiber.Ctx) error {
130 // Record start time
131 start := time.Now()
132
133 // Process request
134 err := c.Next()
135
136 // Calculate latency
137 latency := time.Since(start)
138 status := c.Response().StatusCode()
139
140 // Determine log level based on status code
141 logEvent := log.Info()
142 if status >= 400 && status < 500 {
143 logEvent = log.Warn()
144 } else if status >= 500 {
145 logEvent = log.Error()
146 }
147
148 // Log the request
149 logEvent.
150 Int("status", status).
151 Dur("latency", latency).
152 Str("method", c.Method()).
153 Str("path", c.Path()).
154 Str("ip", c.IP()).
155 Str("user_agent", c.Get("User-Agent")).
156 Msg("HTTP request")
157
158 return err
159 }
160}
161
Akron49b525c2025-07-03 15:17:06 +0200162// extractRequestParams extracts and validates common request parameters
163func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
164 params := &requestParams{
165 MapID: c.Params("map"),
166 Dir: c.Query("dir", "atob"),
167 FoundryA: c.Query("foundryA", ""),
168 FoundryB: c.Query("foundryB", ""),
169 LayerA: c.Query("layerA", ""),
170 LayerB: c.Query("layerB", ""),
171 }
172
173 // Validate input parameters
174 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
175 return nil, err
176 }
177
178 // Validate direction
179 if params.Dir != "atob" && params.Dir != "btoa" {
180 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
181 }
182
183 return params, nil
184}
185
186// parseRequestBody parses JSON request body and direction
187func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
188 var jsonData any
189 if err := c.BodyParser(&jsonData); err != nil {
190 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
191 }
192
193 direction, err := mapper.ParseDirection(dir)
194 if err != nil {
195 return nil, mapper.BtoA, err
196 }
197
198 return jsonData, direction, nil
199}
200
Akron49ceeb42025-05-23 17:46:01 +0200201func main() {
202 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200203 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200204
Akrone1cff7c2025-06-04 18:43:32 +0200205 // Validate command line arguments
206 if cfg.Config == "" && len(cfg.Mappings) == 0 {
207 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
208 }
209
Akron14678dc2025-06-05 13:01:38 +0200210 // Expand glob patterns in mapping files
211 expandedMappings, err := expandGlobs(cfg.Mappings)
212 if err != nil {
213 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
214 }
215
Akrone1cff7c2025-06-04 18:43:32 +0200216 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200217 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200218 if err != nil {
219 log.Fatal().Err(err).Msg("Failed to load configuration")
220 }
221
Akrona8a66ce2025-06-05 10:50:17 +0200222 finalPort := yamlConfig.Port
223 finalLogLevel := yamlConfig.LogLevel
224
225 // Use command line values if provided (they override config file)
226 if cfg.Port != nil {
227 finalPort = *cfg.Port
228 }
229 if cfg.LogLevel != nil {
230 finalLogLevel = *cfg.LogLevel
231 }
232
233 // Set up logging with the final log level
234 setupLogger(finalLogLevel)
235
Akron49ceeb42025-05-23 17:46:01 +0200236 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200237 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200238 if err != nil {
239 log.Fatal().Err(err).Msg("Failed to create mapper")
240 }
241
242 // Create fiber app
243 app := fiber.New(fiber.Config{
244 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200245 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200246 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
247 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200248 })
249
Akron3caee162025-07-01 17:44:58 +0200250 // Add zerolog-integrated logger middleware
251 app.Use(setupFiberLogger())
252
Akron49ceeb42025-05-23 17:46:01 +0200253 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200254 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200255
256 // Start server
257 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200258 log.Info().Int("port", finalPort).Msg("Starting server")
Akrond8a76b32026-02-20 09:31:56 +0100259 fmt.Printf("Starting server port=%d\n", finalPort)
Akronae3ffde2025-06-05 14:04:06 +0200260
261 for _, list := range yamlConfig.Lists {
262 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
Akrond8a76b32026-02-20 09:31:56 +0100263 fmt.Printf("Loaded mapping desc=%s id=%s\n",
264 formatConsoleField(list.Description),
265 list.ID,
266 )
Akronae3ffde2025-06-05 14:04:06 +0200267 }
268
Akrona8a66ce2025-06-05 10:50:17 +0200269 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200270 log.Fatal().Err(err).Msg("Server error")
271 }
272 }()
273
274 // Wait for interrupt signal
275 sigChan := make(chan os.Signal, 1)
276 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
277 <-sigChan
278
279 // Graceful shutdown
280 log.Info().Msg("Shutting down server")
281 if err := app.Shutdown(); err != nil {
282 log.Error().Err(err).Msg("Error during shutdown")
283 }
284}
285
Akron06d21f02025-06-04 14:36:07 +0200286func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akrond8a76b32026-02-20 09:31:56 +0100287 configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
288 pluginTmpl := texttemplate.Must(texttemplate.ParseFS(staticFS, "static/plugin.html"))
289
Akron49ceeb42025-05-23 17:46:01 +0200290 // Health check endpoint
291 app.Get("/health", func(c *fiber.Ctx) error {
292 return c.SendString("OK")
293 })
294
Akrond8a76b32026-02-20 09:31:56 +0100295 // Static file serving from embedded FS
296 app.Get("/static/*", handleStaticFile())
297
Akron512aab62026-02-20 08:36:12 +0100298 // Composite cascade transformation endpoints
299 app.Post("/query", handleCompositeQueryTransform(m, yamlConfig.Lists))
300 app.Post("/response", handleCompositeResponseTransform(m, yamlConfig.Lists))
301
Akron49ceeb42025-05-23 17:46:01 +0200302 // Transformation endpoint
303 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200304
Akron4de47a92025-06-27 11:58:11 +0200305 // Response transformation endpoint
306 app.Post("/:map/response", handleResponseTransform(m))
307
Akron40aaa632025-06-03 17:57:52 +0200308 // Kalamar plugin endpoint
Akrond8a76b32026-02-20 09:31:56 +0100309 app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
310 app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
311}
312
313func handleStaticFile() fiber.Handler {
314 return func(c *fiber.Ctx) error {
315 name := c.Params("*")
316 data, err := fs.ReadFile(staticFS, "static/"+name)
317 if err != nil {
318 return c.Status(fiber.StatusNotFound).SendString("not found")
319 }
320 switch {
321 case strings.HasSuffix(name, ".js"):
322 c.Set("Content-Type", "text/javascript; charset=utf-8")
323 case strings.HasSuffix(name, ".css"):
324 c.Set("Content-Type", "text/css; charset=utf-8")
325 case strings.HasSuffix(name, ".html"):
326 c.Set("Content-Type", "text/html; charset=utf-8")
327 }
328 return c.Send(data)
329 }
330}
331
332func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
333 return BasePageData{
334 Title: config.Title,
335 Version: config.Version,
336 Hash: config.Buildhash,
337 Date: config.Buildtime,
338 Description: config.Description,
339 Server: yamlConfig.Server,
340 SDK: yamlConfig.SDK,
341 ServiceURL: yamlConfig.ServiceURL,
342 }
343}
344
345func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
346 data := ConfigPageData{
347 BasePageData: buildBasePageData(yamlConfig),
348 }
349
350 for _, list := range yamlConfig.Lists {
351 normalized := list
352 if normalized.Type == "" {
353 normalized.Type = "annotation"
354 }
355 if list.IsCorpus() {
356 data.CorpusMappings = append(data.CorpusMappings, normalized)
357 } else {
358 data.AnnotationMappings = append(data.AnnotationMappings, normalized)
359 }
360 }
361 return data
Akron49ceeb42025-05-23 17:46:01 +0200362}
363
Akron512aab62026-02-20 08:36:12 +0100364func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
365 return func(c *fiber.Ctx) error {
366 cfgRaw := c.Query("cfg", "")
367 if len(cfgRaw) > maxParamLength {
368 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
369 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
370 })
371 }
372
373 var jsonData any
374 if err := c.BodyParser(&jsonData); err != nil {
375 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
376 "error": "invalid JSON in request body",
377 })
378 }
379
380 entries, err := ParseCfgParam(cfgRaw, lists)
381 if err != nil {
382 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
383 "error": err.Error(),
384 })
385 }
386
387 if len(entries) == 0 {
388 return c.JSON(jsonData)
389 }
390
391 orderedIDs := make([]string, 0, len(entries))
392 opts := make([]mapper.MappingOptions, 0, len(entries))
393 for _, entry := range entries {
394 dir := mapper.AtoB
395 if entry.Direction == "btoa" {
396 dir = mapper.BtoA
397 }
398
399 orderedIDs = append(orderedIDs, entry.ID)
400 opts = append(opts, mapper.MappingOptions{
401 Direction: dir,
402 FoundryA: entry.FoundryA,
403 LayerA: entry.LayerA,
404 FoundryB: entry.FoundryB,
405 LayerB: entry.LayerB,
406 })
407 }
408
409 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
410 if err != nil {
411 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
412 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
413 "error": err.Error(),
414 })
415 }
416
417 return c.JSON(result)
418 }
419}
420
421func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
422 return func(c *fiber.Ctx) error {
423 cfgRaw := c.Query("cfg", "")
424 if len(cfgRaw) > maxParamLength {
425 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
426 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
427 })
428 }
429
430 var jsonData any
431 if err := c.BodyParser(&jsonData); err != nil {
432 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
433 "error": "invalid JSON in request body",
434 })
435 }
436
437 entries, err := ParseCfgParam(cfgRaw, lists)
438 if err != nil {
439 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
440 "error": err.Error(),
441 })
442 }
443
444 if len(entries) == 0 {
445 return c.JSON(jsonData)
446 }
447
448 orderedIDs := make([]string, 0, len(entries))
449 opts := make([]mapper.MappingOptions, 0, len(entries))
450 for _, entry := range entries {
451 dir := mapper.AtoB
452 if entry.Direction == "btoa" {
453 dir = mapper.BtoA
454 }
455
456 orderedIDs = append(orderedIDs, entry.ID)
457 opts = append(opts, mapper.MappingOptions{
458 Direction: dir,
459 FoundryA: entry.FoundryA,
460 LayerA: entry.LayerA,
461 FoundryB: entry.FoundryB,
462 LayerB: entry.LayerB,
463 })
464 }
465
466 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
467 if err != nil {
468 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
469 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
470 "error": err.Error(),
471 })
472 }
473
474 return c.JSON(result)
475 }
476}
477
Akron49ceeb42025-05-23 17:46:01 +0200478func handleTransform(m *mapper.Mapper) fiber.Handler {
479 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200480 // Extract and validate parameters
481 params, err := extractRequestParams(c)
482 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200483 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
484 "error": err.Error(),
485 })
486 }
487
Akron49ceeb42025-05-23 17:46:01 +0200488 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200489 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200490 if err != nil {
491 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
492 "error": err.Error(),
493 })
494 }
495
Akron49ceeb42025-05-23 17:46:01 +0200496 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200497 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200498 Direction: direction,
Akron49b525c2025-07-03 15:17:06 +0200499 FoundryA: params.FoundryA,
500 FoundryB: params.FoundryB,
501 LayerA: params.LayerA,
502 LayerB: params.LayerB,
Akron49ceeb42025-05-23 17:46:01 +0200503 }, jsonData)
504
505 if err != nil {
506 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200507 Str("mapID", params.MapID).
508 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200509 Msg("Failed to apply mappings")
510
511 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
512 "error": err.Error(),
513 })
514 }
515
516 return c.JSON(result)
517 }
518}
Akron74e1c072025-05-26 14:38:25 +0200519
Akron4de47a92025-06-27 11:58:11 +0200520func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
521 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200522 // Extract and validate parameters
523 params, err := extractRequestParams(c)
524 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200525 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
526 "error": err.Error(),
527 })
528 }
529
Akron4de47a92025-06-27 11:58:11 +0200530 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200531 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200532 if err != nil {
533 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
534 "error": err.Error(),
535 })
536 }
537
538 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200539 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron4de47a92025-06-27 11:58:11 +0200540 Direction: direction,
Akron49b525c2025-07-03 15:17:06 +0200541 FoundryA: params.FoundryA,
542 FoundryB: params.FoundryB,
543 LayerA: params.LayerA,
544 LayerB: params.LayerB,
Akron4de47a92025-06-27 11:58:11 +0200545 }, jsonData)
546
547 if err != nil {
548 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200549 Str("mapID", params.MapID).
550 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200551 Msg("Failed to apply response mappings")
552
553 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
554 "error": err.Error(),
555 })
556 }
557
558 return c.JSON(result)
559 }
560}
561
Akron74e1c072025-05-26 14:38:25 +0200562// validateInput checks if the input parameters are valid
563func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200564 // Define parameter checks
565 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200566 name string
567 value string
568 }{
569 {"mapID", mapID},
570 {"dir", dir},
571 {"foundryA", foundryA},
572 {"foundryB", foundryB},
573 {"layerA", layerA},
574 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200575 }
576
577 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200578 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200579 if len(param.value) > maxParamLength {
580 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
581 }
Akron74e1c072025-05-26 14:38:25 +0200582 if strings.ContainsAny(param.value, "<>{}[]\\") {
583 return fmt.Errorf("%s contains invalid characters", param.name)
584 }
585 }
586
Akron69d43bf2025-05-26 17:09:00 +0200587 if len(body) > maxInputLength {
588 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
589 }
590
Akron74e1c072025-05-26 14:38:25 +0200591 return nil
592}
Akron40aaa632025-06-03 17:57:52 +0200593
Akrond8a76b32026-02-20 09:31:56 +0100594func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *texttemplate.Template) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200595 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200596 mapID := c.Params("map")
597
Akrond8a76b32026-02-20 09:31:56 +0100598 // Config page (GET /)
599 if mapID == "" {
600 data := buildConfigPageData(yamlConfig)
601 var buf bytes.Buffer
602 if err := configTmpl.Execute(&buf, data); err != nil {
603 log.Error().Err(err).Msg("Failed to execute config template")
604 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
605 }
606 c.Set("Content-Type", "text/html")
607 return c.Send(buf.Bytes())
608 }
609
610 // Single-mapping page (GET /:map) — existing behavior
Akroncb51f812025-06-30 15:24:20 +0200611 // Get query parameters
612 dir := c.Query("dir", "atob")
613 foundryA := c.Query("foundryA", "")
614 foundryB := c.Query("foundryB", "")
615 layerA := c.Query("layerA", "")
616 layerB := c.Query("layerB", "")
617
Akron49b525c2025-07-03 15:17:06 +0200618 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200619 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
620 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
621 "error": err.Error(),
622 })
623 }
624
Akroncb51f812025-06-30 15:24:20 +0200625 if dir != "atob" && dir != "btoa" {
626 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
627 "error": "invalid direction, must be 'atob' or 'btoa'",
628 })
629 }
630
Akroncb51f812025-06-30 15:24:20 +0200631 queryParams := QueryParams{
632 Dir: dir,
633 FoundryA: foundryA,
634 FoundryB: foundryB,
635 LayerA: layerA,
636 LayerB: layerB,
637 }
638
Akrond8a76b32026-02-20 09:31:56 +0100639 queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
640 if err != nil {
641 log.Warn().Err(err).Msg("Failed to build query service URL")
642 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
643 }
644 reversed := queryParams
645 if queryParams.Dir == "btoa" {
646 reversed.Dir = "atob"
647 } else {
648 reversed.Dir = "btoa"
649 }
650 responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
651 if err != nil {
652 log.Warn().Err(err).Msg("Failed to build response service URL")
653 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
654 }
Akron40aaa632025-06-03 17:57:52 +0200655
Akrond8a76b32026-02-20 09:31:56 +0100656 data := SingleMappingPageData{
657 BasePageData: buildBasePageData(yamlConfig),
658 MapID: mapID,
659 Mappings: yamlConfig.Lists,
660 QueryURL: queryURL,
661 ResponseURL: responseURL,
662 }
663
664 var buf bytes.Buffer
665 if err := pluginTmpl.Execute(&buf, data); err != nil {
666 log.Error().Err(err).Msg("Failed to execute plugin template")
667 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
668 }
Akron40aaa632025-06-03 17:57:52 +0200669 c.Set("Content-Type", "text/html")
Akrond8a76b32026-02-20 09:31:56 +0100670 return c.Send(buf.Bytes())
Akron40aaa632025-06-03 17:57:52 +0200671 }
672}
673
Akrond8a76b32026-02-20 09:31:56 +0100674func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
675 service, err := url.Parse(serviceURL)
676 if err != nil {
677 return "", err
Akronc376dcc2025-06-04 17:00:18 +0200678 }
Akrond8a76b32026-02-20 09:31:56 +0100679 service.Path = path.Join(service.Path, mapID, endpoint)
680 service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
681 return service.String(), nil
682}
Akronc376dcc2025-06-04 17:00:18 +0200683
Akrond8a76b32026-02-20 09:31:56 +0100684func formatConsoleField(value string) string {
685 if strings.ContainsAny(value, " \t") {
686 return strconv.Quote(value)
Akron40aaa632025-06-03 17:57:52 +0200687 }
Akrond8a76b32026-02-20 09:31:56 +0100688 return value
Akron40aaa632025-06-03 17:57:52 +0200689}
Akron14678dc2025-06-05 13:01:38 +0200690
Akroncb51f812025-06-30 15:24:20 +0200691// buildQueryParams builds a query string from the provided parameters
692func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
693 params := url.Values{}
694 if dir != "" {
695 params.Add("dir", dir)
696 }
697 if foundryA != "" {
698 params.Add("foundryA", foundryA)
699 }
700 if foundryB != "" {
701 params.Add("foundryB", foundryB)
702 }
703 if layerA != "" {
704 params.Add("layerA", layerA)
705 }
706 if layerB != "" {
707 params.Add("layerB", layerB)
708 }
709 return params.Encode()
710}
711
Akron14678dc2025-06-05 13:01:38 +0200712// expandGlobs expands glob patterns in the slice of file paths
713// Returns the expanded list of files or an error if glob expansion fails
714func expandGlobs(patterns []string) ([]string, error) {
715 var expanded []string
716
717 for _, pattern := range patterns {
718 // Use filepath.Glob which works cross-platform
719 matches, err := filepath.Glob(pattern)
720 if err != nil {
721 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
722 }
723
724 // If no matches found, treat as literal filename (consistent with shell behavior)
725 if len(matches) == 0 {
726 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
727 expanded = append(expanded, pattern)
728 } else {
729 expanded = append(expanded, matches...)
730 }
731 }
732
733 return expanded, nil
734}