blob: e456537f8181bfde1a9b7ee6baa6b1df6683d620 [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
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
80}
81
Akrond8a76b32026-02-20 09:31:56 +010082// ConfigPageData holds all data passed to the configuration page template.
83type ConfigPageData struct {
84 BasePageData
85 AnnotationMappings []config.MappingList
86 CorpusMappings []config.MappingList
87}
88
Akrona00d4752025-05-26 17:34:36 +020089func parseConfig() *appConfig {
90 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020091
92 desc := config.Description
93 desc += " [" + config.Version + "]"
94
Akron1fc750e2025-05-26 16:54:18 +020095 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020096 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020097 kong.UsageOnError(),
98 )
99 if ctx.Error != nil {
100 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +0200101 os.Exit(1)
102 }
Akron49ceeb42025-05-23 17:46:01 +0200103 return cfg
104}
105
106func setupLogger(level string) {
107 // Parse log level
108 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
109 if err != nil {
110 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
111 lvl = zerolog.InfoLevel
112 }
113
114 // Configure zerolog
115 zerolog.SetGlobalLevel(lvl)
116 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
117}
118
Akron3caee162025-07-01 17:44:58 +0200119// setupFiberLogger configures fiber's logger middleware to integrate with zerolog
120func setupFiberLogger() fiber.Handler {
121 // Check if HTTP request logging should be enabled based on current log level
122 currentLevel := zerolog.GlobalLevel()
123
124 // Only enable HTTP request logging if log level is debug or info
125 if currentLevel > zerolog.InfoLevel {
126 return func(c *fiber.Ctx) error {
127 return c.Next()
128 }
129 }
130
131 return func(c *fiber.Ctx) error {
132 // Record start time
133 start := time.Now()
134
135 // Process request
136 err := c.Next()
137
138 // Calculate latency
139 latency := time.Since(start)
140 status := c.Response().StatusCode()
141
142 // Determine log level based on status code
143 logEvent := log.Info()
144 if status >= 400 && status < 500 {
145 logEvent = log.Warn()
146 } else if status >= 500 {
147 logEvent = log.Error()
148 }
149
150 // Log the request
151 logEvent.
152 Int("status", status).
153 Dur("latency", latency).
154 Str("method", c.Method()).
155 Str("path", c.Path()).
156 Str("ip", c.IP()).
157 Str("user_agent", c.Get("User-Agent")).
158 Msg("HTTP request")
159
160 return err
161 }
162}
163
Akron49b525c2025-07-03 15:17:06 +0200164// extractRequestParams extracts and validates common request parameters
165func extractRequestParams(c *fiber.Ctx) (*requestParams, error) {
166 params := &requestParams{
167 MapID: c.Params("map"),
168 Dir: c.Query("dir", "atob"),
169 FoundryA: c.Query("foundryA", ""),
170 FoundryB: c.Query("foundryB", ""),
171 LayerA: c.Query("layerA", ""),
172 LayerB: c.Query("layerB", ""),
173 }
174
175 // Validate input parameters
176 if err := validateInput(params.MapID, params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB, c.Body()); err != nil {
177 return nil, err
178 }
179
180 // Validate direction
181 if params.Dir != "atob" && params.Dir != "btoa" {
182 return nil, fmt.Errorf("invalid direction, must be 'atob' or 'btoa'")
183 }
184
185 return params, nil
186}
187
188// parseRequestBody parses JSON request body and direction
189func parseRequestBody(c *fiber.Ctx, dir string) (any, mapper.Direction, error) {
190 var jsonData any
191 if err := c.BodyParser(&jsonData); err != nil {
192 return nil, mapper.BtoA, fmt.Errorf("invalid JSON in request body")
193 }
194
195 direction, err := mapper.ParseDirection(dir)
196 if err != nil {
197 return nil, mapper.BtoA, err
198 }
199
200 return jsonData, direction, nil
201}
202
Akron49ceeb42025-05-23 17:46:01 +0200203func main() {
204 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +0200205 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +0200206
Akrone1cff7c2025-06-04 18:43:32 +0200207 // Validate command line arguments
208 if cfg.Config == "" && len(cfg.Mappings) == 0 {
209 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
210 }
211
Akron14678dc2025-06-05 13:01:38 +0200212 // Expand glob patterns in mapping files
213 expandedMappings, err := expandGlobs(cfg.Mappings)
214 if err != nil {
215 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
216 }
217
Akrone1cff7c2025-06-04 18:43:32 +0200218 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200219 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200220 if err != nil {
221 log.Fatal().Err(err).Msg("Failed to load configuration")
222 }
223
Akrona8a66ce2025-06-05 10:50:17 +0200224 finalPort := yamlConfig.Port
225 finalLogLevel := yamlConfig.LogLevel
226
227 // Use command line values if provided (they override config file)
228 if cfg.Port != nil {
229 finalPort = *cfg.Port
230 }
231 if cfg.LogLevel != nil {
232 finalLogLevel = *cfg.LogLevel
233 }
234
235 // Set up logging with the final log level
236 setupLogger(finalLogLevel)
237
Akron49ceeb42025-05-23 17:46:01 +0200238 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200239 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200240 if err != nil {
241 log.Fatal().Err(err).Msg("Failed to create mapper")
242 }
243
244 // Create fiber app
245 app := fiber.New(fiber.Config{
246 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200247 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200248 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
249 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200250 })
251
Akron3caee162025-07-01 17:44:58 +0200252 // Add zerolog-integrated logger middleware
253 app.Use(setupFiberLogger())
254
Akron49ceeb42025-05-23 17:46:01 +0200255 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200256 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200257
258 // Start server
259 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200260 log.Info().Int("port", finalPort).Msg("Starting server")
Akrond8a76b32026-02-20 09:31:56 +0100261 fmt.Printf("Starting server port=%d\n", finalPort)
Akronae3ffde2025-06-05 14:04:06 +0200262
263 for _, list := range yamlConfig.Lists {
264 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
Akrond8a76b32026-02-20 09:31:56 +0100265 fmt.Printf("Loaded mapping desc=%s id=%s\n",
266 formatConsoleField(list.Description),
267 list.ID,
268 )
Akronae3ffde2025-06-05 14:04:06 +0200269 }
270
Akrona8a66ce2025-06-05 10:50:17 +0200271 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200272 log.Fatal().Err(err).Msg("Server error")
273 }
274 }()
275
276 // Wait for interrupt signal
277 sigChan := make(chan os.Signal, 1)
278 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
279 <-sigChan
280
281 // Graceful shutdown
282 log.Info().Msg("Shutting down server")
283 if err := app.Shutdown(); err != nil {
284 log.Error().Err(err).Msg("Error during shutdown")
285 }
286}
287
Akron06d21f02025-06-04 14:36:07 +0200288func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akrond8a76b32026-02-20 09:31:56 +0100289 configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
290 pluginTmpl := texttemplate.Must(texttemplate.ParseFS(staticFS, "static/plugin.html"))
291
Akron49ceeb42025-05-23 17:46:01 +0200292 // Health check endpoint
293 app.Get("/health", func(c *fiber.Ctx) error {
294 return c.SendString("OK")
295 })
296
Akrond8a76b32026-02-20 09:31:56 +0100297 // Static file serving from embedded FS
298 app.Get("/static/*", handleStaticFile())
299
Akron512aab62026-02-20 08:36:12 +0100300 // Composite cascade transformation endpoints
301 app.Post("/query", handleCompositeQueryTransform(m, yamlConfig.Lists))
302 app.Post("/response", handleCompositeResponseTransform(m, yamlConfig.Lists))
303
Akron49ceeb42025-05-23 17:46:01 +0200304 // Transformation endpoint
305 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200306
Akron4de47a92025-06-27 11:58:11 +0200307 // Response transformation endpoint
308 app.Post("/:map/response", handleResponseTransform(m))
309
Akron40aaa632025-06-03 17:57:52 +0200310 // Kalamar plugin endpoint
Akrond8a76b32026-02-20 09:31:56 +0100311 app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
312 app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
313}
314
315func handleStaticFile() fiber.Handler {
316 return func(c *fiber.Ctx) error {
317 name := c.Params("*")
318 data, err := fs.ReadFile(staticFS, "static/"+name)
319 if err != nil {
320 return c.Status(fiber.StatusNotFound).SendString("not found")
321 }
322 switch {
323 case strings.HasSuffix(name, ".js"):
324 c.Set("Content-Type", "text/javascript; charset=utf-8")
325 case strings.HasSuffix(name, ".css"):
326 c.Set("Content-Type", "text/css; charset=utf-8")
327 case strings.HasSuffix(name, ".html"):
328 c.Set("Content-Type", "text/html; charset=utf-8")
329 }
330 return c.Send(data)
331 }
332}
333
334func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
335 return BasePageData{
336 Title: config.Title,
337 Version: config.Version,
338 Hash: config.Buildhash,
339 Date: config.Buildtime,
340 Description: config.Description,
341 Server: yamlConfig.Server,
342 SDK: yamlConfig.SDK,
Akron43fb1022026-02-20 11:38:49 +0100343 Stylesheet: yamlConfig.Stylesheet,
Akrond8a76b32026-02-20 09:31:56 +0100344 ServiceURL: yamlConfig.ServiceURL,
Akron43fb1022026-02-20 11:38:49 +0100345 CookieName: yamlConfig.CookieName,
Akrond8a76b32026-02-20 09:31:56 +0100346 }
347}
348
349func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
350 data := ConfigPageData{
351 BasePageData: buildBasePageData(yamlConfig),
352 }
353
354 for _, list := range yamlConfig.Lists {
355 normalized := list
356 if normalized.Type == "" {
357 normalized.Type = "annotation"
358 }
359 if list.IsCorpus() {
360 data.CorpusMappings = append(data.CorpusMappings, normalized)
361 } else {
362 data.AnnotationMappings = append(data.AnnotationMappings, normalized)
363 }
364 }
365 return data
Akron49ceeb42025-05-23 17:46:01 +0200366}
367
Akron512aab62026-02-20 08:36:12 +0100368func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
369 return func(c *fiber.Ctx) error {
370 cfgRaw := c.Query("cfg", "")
371 if len(cfgRaw) > maxParamLength {
372 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
373 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
374 })
375 }
376
377 var jsonData any
378 if err := c.BodyParser(&jsonData); err != nil {
379 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
380 "error": "invalid JSON in request body",
381 })
382 }
383
384 entries, err := ParseCfgParam(cfgRaw, lists)
385 if err != nil {
386 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
387 "error": err.Error(),
388 })
389 }
390
391 if len(entries) == 0 {
392 return c.JSON(jsonData)
393 }
394
395 orderedIDs := make([]string, 0, len(entries))
396 opts := make([]mapper.MappingOptions, 0, len(entries))
397 for _, entry := range entries {
398 dir := mapper.AtoB
399 if entry.Direction == "btoa" {
400 dir = mapper.BtoA
401 }
402
403 orderedIDs = append(orderedIDs, entry.ID)
404 opts = append(opts, mapper.MappingOptions{
405 Direction: dir,
406 FoundryA: entry.FoundryA,
407 LayerA: entry.LayerA,
408 FoundryB: entry.FoundryB,
409 LayerB: entry.LayerB,
410 })
411 }
412
413 result, err := m.CascadeQueryMappings(orderedIDs, opts, jsonData)
414 if err != nil {
415 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite query mappings")
416 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
417 "error": err.Error(),
418 })
419 }
420
421 return c.JSON(result)
422 }
423}
424
425func handleCompositeResponseTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
426 return func(c *fiber.Ctx) error {
427 cfgRaw := c.Query("cfg", "")
428 if len(cfgRaw) > maxParamLength {
429 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
430 "error": fmt.Sprintf("cfg too long (max %d bytes)", maxParamLength),
431 })
432 }
433
434 var jsonData any
435 if err := c.BodyParser(&jsonData); err != nil {
436 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
437 "error": "invalid JSON in request body",
438 })
439 }
440
441 entries, err := ParseCfgParam(cfgRaw, lists)
442 if err != nil {
443 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
444 "error": err.Error(),
445 })
446 }
447
448 if len(entries) == 0 {
449 return c.JSON(jsonData)
450 }
451
452 orderedIDs := make([]string, 0, len(entries))
453 opts := make([]mapper.MappingOptions, 0, len(entries))
454 for _, entry := range entries {
455 dir := mapper.AtoB
456 if entry.Direction == "btoa" {
457 dir = mapper.BtoA
458 }
459
460 orderedIDs = append(orderedIDs, entry.ID)
461 opts = append(opts, mapper.MappingOptions{
462 Direction: dir,
463 FoundryA: entry.FoundryA,
464 LayerA: entry.LayerA,
465 FoundryB: entry.FoundryB,
466 LayerB: entry.LayerB,
467 })
468 }
469
470 result, err := m.CascadeResponseMappings(orderedIDs, opts, jsonData)
471 if err != nil {
472 log.Error().Err(err).Str("cfg", cfgRaw).Msg("Failed to apply composite response mappings")
473 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
474 "error": err.Error(),
475 })
476 }
477
478 return c.JSON(result)
479 }
480}
481
Akron49ceeb42025-05-23 17:46:01 +0200482func handleTransform(m *mapper.Mapper) fiber.Handler {
483 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200484 // Extract and validate parameters
485 params, err := extractRequestParams(c)
486 if err != nil {
Akron74e1c072025-05-26 14:38:25 +0200487 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
488 "error": err.Error(),
489 })
490 }
491
Akron49ceeb42025-05-23 17:46:01 +0200492 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200493 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akrona1a183f2025-05-26 17:47:33 +0200494 if err != nil {
495 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
496 "error": err.Error(),
497 })
498 }
499
Akron49ceeb42025-05-23 17:46:01 +0200500 // Apply mappings
Akron49b525c2025-07-03 15:17:06 +0200501 result, err := m.ApplyQueryMappings(params.MapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200502 Direction: direction,
Akron49b525c2025-07-03 15:17:06 +0200503 FoundryA: params.FoundryA,
504 FoundryB: params.FoundryB,
505 LayerA: params.LayerA,
506 LayerB: params.LayerB,
Akron49ceeb42025-05-23 17:46:01 +0200507 }, jsonData)
508
509 if err != nil {
510 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200511 Str("mapID", params.MapID).
512 Str("direction", params.Dir).
Akron49ceeb42025-05-23 17:46:01 +0200513 Msg("Failed to apply mappings")
514
515 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
516 "error": err.Error(),
517 })
518 }
519
520 return c.JSON(result)
521 }
522}
Akron74e1c072025-05-26 14:38:25 +0200523
Akron4de47a92025-06-27 11:58:11 +0200524func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
525 return func(c *fiber.Ctx) error {
Akron49b525c2025-07-03 15:17:06 +0200526 // Extract and validate parameters
527 params, err := extractRequestParams(c)
528 if err != nil {
Akron4de47a92025-06-27 11:58:11 +0200529 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
530 "error": err.Error(),
531 })
532 }
533
Akron4de47a92025-06-27 11:58:11 +0200534 // Parse request body
Akron49b525c2025-07-03 15:17:06 +0200535 jsonData, direction, err := parseRequestBody(c, params.Dir)
Akron4de47a92025-06-27 11:58:11 +0200536 if err != nil {
537 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
538 "error": err.Error(),
539 })
540 }
541
542 // Apply response mappings
Akron49b525c2025-07-03 15:17:06 +0200543 result, err := m.ApplyResponseMappings(params.MapID, mapper.MappingOptions{
Akron4de47a92025-06-27 11:58:11 +0200544 Direction: direction,
Akron49b525c2025-07-03 15:17:06 +0200545 FoundryA: params.FoundryA,
546 FoundryB: params.FoundryB,
547 LayerA: params.LayerA,
548 LayerB: params.LayerB,
Akron4de47a92025-06-27 11:58:11 +0200549 }, jsonData)
550
551 if err != nil {
552 log.Error().Err(err).
Akron49b525c2025-07-03 15:17:06 +0200553 Str("mapID", params.MapID).
554 Str("direction", params.Dir).
Akron4de47a92025-06-27 11:58:11 +0200555 Msg("Failed to apply response mappings")
556
557 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
558 "error": err.Error(),
559 })
560 }
561
562 return c.JSON(result)
563 }
564}
565
Akron74e1c072025-05-26 14:38:25 +0200566// validateInput checks if the input parameters are valid
567func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200568 // Define parameter checks
569 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200570 name string
571 value string
572 }{
573 {"mapID", mapID},
574 {"dir", dir},
575 {"foundryA", foundryA},
576 {"foundryB", foundryB},
577 {"layerA", layerA},
578 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200579 }
580
581 for _, param := range params {
Akron49b525c2025-07-03 15:17:06 +0200582 // Check input lengths and invalid characters in one combined condition
Akron69d43bf2025-05-26 17:09:00 +0200583 if len(param.value) > maxParamLength {
584 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
585 }
Akron74e1c072025-05-26 14:38:25 +0200586 if strings.ContainsAny(param.value, "<>{}[]\\") {
587 return fmt.Errorf("%s contains invalid characters", param.name)
588 }
589 }
590
Akron69d43bf2025-05-26 17:09:00 +0200591 if len(body) > maxInputLength {
592 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
593 }
594
Akron74e1c072025-05-26 14:38:25 +0200595 return nil
596}
Akron40aaa632025-06-03 17:57:52 +0200597
Akrond8a76b32026-02-20 09:31:56 +0100598func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *texttemplate.Template) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200599 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200600 mapID := c.Params("map")
601
Akrond8a76b32026-02-20 09:31:56 +0100602 // Config page (GET /)
603 if mapID == "" {
604 data := buildConfigPageData(yamlConfig)
605 var buf bytes.Buffer
606 if err := configTmpl.Execute(&buf, data); err != nil {
607 log.Error().Err(err).Msg("Failed to execute config template")
608 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
609 }
610 c.Set("Content-Type", "text/html")
611 return c.Send(buf.Bytes())
612 }
613
614 // Single-mapping page (GET /:map) — existing behavior
Akroncb51f812025-06-30 15:24:20 +0200615 // Get query parameters
616 dir := c.Query("dir", "atob")
617 foundryA := c.Query("foundryA", "")
618 foundryB := c.Query("foundryB", "")
619 layerA := c.Query("layerA", "")
620 layerB := c.Query("layerB", "")
621
Akron49b525c2025-07-03 15:17:06 +0200622 // Validate input parameters and direction in one step
Akroncb51f812025-06-30 15:24:20 +0200623 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
624 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
625 "error": err.Error(),
626 })
627 }
628
Akroncb51f812025-06-30 15:24:20 +0200629 if dir != "atob" && dir != "btoa" {
630 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
631 "error": "invalid direction, must be 'atob' or 'btoa'",
632 })
633 }
634
Akroncb51f812025-06-30 15:24:20 +0200635 queryParams := QueryParams{
636 Dir: dir,
637 FoundryA: foundryA,
638 FoundryB: foundryB,
639 LayerA: layerA,
640 LayerB: layerB,
641 }
642
Akrond8a76b32026-02-20 09:31:56 +0100643 queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
644 if err != nil {
645 log.Warn().Err(err).Msg("Failed to build query service URL")
646 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
647 }
648 reversed := queryParams
649 if queryParams.Dir == "btoa" {
650 reversed.Dir = "atob"
651 } else {
652 reversed.Dir = "btoa"
653 }
654 responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
655 if err != nil {
656 log.Warn().Err(err).Msg("Failed to build response service URL")
657 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
658 }
Akron40aaa632025-06-03 17:57:52 +0200659
Akrond8a76b32026-02-20 09:31:56 +0100660 data := SingleMappingPageData{
661 BasePageData: buildBasePageData(yamlConfig),
662 MapID: mapID,
663 Mappings: yamlConfig.Lists,
664 QueryURL: queryURL,
665 ResponseURL: responseURL,
666 }
667
668 var buf bytes.Buffer
669 if err := pluginTmpl.Execute(&buf, data); err != nil {
670 log.Error().Err(err).Msg("Failed to execute plugin template")
671 return c.Status(fiber.StatusInternalServerError).SendString("internal error")
672 }
Akron40aaa632025-06-03 17:57:52 +0200673 c.Set("Content-Type", "text/html")
Akrond8a76b32026-02-20 09:31:56 +0100674 return c.Send(buf.Bytes())
Akron40aaa632025-06-03 17:57:52 +0200675 }
676}
677
Akrond8a76b32026-02-20 09:31:56 +0100678func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
679 service, err := url.Parse(serviceURL)
680 if err != nil {
681 return "", err
Akronc376dcc2025-06-04 17:00:18 +0200682 }
Akrond8a76b32026-02-20 09:31:56 +0100683 service.Path = path.Join(service.Path, mapID, endpoint)
684 service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
685 return service.String(), nil
686}
Akronc376dcc2025-06-04 17:00:18 +0200687
Akrond8a76b32026-02-20 09:31:56 +0100688func formatConsoleField(value string) string {
689 if strings.ContainsAny(value, " \t") {
690 return strconv.Quote(value)
Akron40aaa632025-06-03 17:57:52 +0200691 }
Akrond8a76b32026-02-20 09:31:56 +0100692 return value
Akron40aaa632025-06-03 17:57:52 +0200693}
Akron14678dc2025-06-05 13:01:38 +0200694
Akroncb51f812025-06-30 15:24:20 +0200695// buildQueryParams builds a query string from the provided parameters
696func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
697 params := url.Values{}
698 if dir != "" {
699 params.Add("dir", dir)
700 }
701 if foundryA != "" {
702 params.Add("foundryA", foundryA)
703 }
704 if foundryB != "" {
705 params.Add("foundryB", foundryB)
706 }
707 if layerA != "" {
708 params.Add("layerA", layerA)
709 }
710 if layerB != "" {
711 params.Add("layerB", layerB)
712 }
713 return params.Encode()
714}
715
Akron14678dc2025-06-05 13:01:38 +0200716// expandGlobs expands glob patterns in the slice of file paths
717// Returns the expanded list of files or an error if glob expansion fails
718func expandGlobs(patterns []string) ([]string, error) {
719 var expanded []string
720
721 for _, pattern := range patterns {
722 // Use filepath.Glob which works cross-platform
723 matches, err := filepath.Glob(pattern)
724 if err != nil {
725 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
726 }
727
728 // If no matches found, treat as literal filename (consistent with shell behavior)
729 if len(matches) == 0 {
730 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
731 expanded = append(expanded, pattern)
732 } else {
733 expanded = append(expanded, matches...)
734 }
735 }
736
737 return expanded, nil
738}