From d3b2944cd817ad9afda4209cf053608963c08a5f Mon Sep 17 00:00:00 2001 From: alex Date: Sun, 22 Jun 2025 11:32:11 +0300 Subject: [PATCH] first commit --- .gitignore | 2 + .luarc.json | 7 +++ Makefile | 48 ++++++++++++++++ cmd/main/node.go | 54 ++++++++++++++++++ com/_globals.lua | 10 ++++ com/echo.lua | 11 ++++ com/echo?v2.lua | 11 ++++ com/exec.lua | 16 ++++++ config/config.yaml | 8 +++ go.mod | 17 ++++++ go.sum | 20 +++++++ internal/config/config.go | 44 +++++++++++++++ internal/logs/logger.go | 25 +++++++++ internal/server/v1/go.mod | 14 +++++ internal/server/v1/go.sum | 13 +++++ internal/server/v1/handle_com.go | 92 +++++++++++++++++++++++++++++++ internal/server/v1/handle_list.go | 54 ++++++++++++++++++ internal/server/v1/server.go | 73 ++++++++++++++++++++++++ 18 files changed, 519 insertions(+) create mode 100644 .gitignore create mode 100644 .luarc.json create mode 100644 Makefile create mode 100644 cmd/main/node.go create mode 100644 com/_globals.lua create mode 100644 com/echo.lua create mode 100644 com/echo?v2.lua create mode 100644 com/exec.lua create mode 100644 config/config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/logs/logger.go create mode 100644 internal/server/v1/go.mod create mode 100644 internal/server/v1/go.sum create mode 100644 internal/server/v1/handle_com.go create mode 100644 internal/server/v1/handle_list.go create mode 100644 internal/server/v1/server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d3d88f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bak/ +bin/ diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..eebfd24 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,7 @@ +{ + "runtime.version": "Lua 5.1", + "workspace.library": [ + "./scripts/_globals.lua" + ], + "diagnostics.globals": ["Params", "Result"] +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a581918 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +APP_NAME := node +BIN_DIR := bin +GOPATH := $(shell go env GOPATH) +.PHONY: all build run test fmt vet lint check clean + +all: build + +lint-setup: + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +goimports-setup: + go install golang.org/x/tools/cmd/goimports@latest + +golicenses-setup: + go install github.com/google/go-licenses@latest + +setup: lint-setup goimports-setup golicenses-setup + @echo "Setting up the development environment..." + @mkdir -p $(BIN_DIR) + @echo "Setup complete. Run 'make build' to compile the application." + +build: + @go build -o $(BIN_DIR)/$(APP_NAME) ./cmd/$(APP_NAME) + +run: build + ./$(BIN_DIR)/$(APP_NAME) + +test: + @go test ./... | grep -v '^?' || true + +fmt: + @go fmt ./... + @$(GOPATH)/bin/goimports -w . + +vet: + @go vet ./... + +lint: + @$(GOPATH)/bin/golangci-lint run + +check: fmt vet lint test + +licenses: + @$(GOPATH)/bin/go-licenses save ./... --save_path=third_party/licenses --force + @echo "Licenses have been exported to third_party/licenses" + +clean: + @rm -rf bin \ No newline at end of file diff --git a/cmd/main/node.go b/cmd/main/node.go new file mode 100644 index 0000000..ebe2df0 --- /dev/null +++ b/cmd/main/node.go @@ -0,0 +1,54 @@ +package main + +import ( + "GoSally-mvp/internal/config" + "GoSally-mvp/internal/logs" + "crypto/rand" + "encoding/hex" + "log/slog" + "net/http" + "regexp" + + "github.com/go-chi/chi/v5" +) + +var allowedCmd = regexp.MustCompile(`^[a-zA-Z0-9]+$`) +var log *slog.Logger +var cfg *config.ConfigConf +var listAllowedCmd = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) // allowed symbols after first symbol + +func init() { + cfg = config.MustLoadConfig() + + log = logs.SetupLogger(cfg.Mode) + log = log.With("mode", cfg.Mode) + + log.Info("Initializing server", slog.String("address", cfg.HTTPServer.Address)) + log.Debug("Server running in debug mode") +} + +func main() { + r := chi.NewRouter() + r.Route("/v1/com", func(r chi.Router) { + r.Get("/", handleV1ComList) + r.Get("/{cmd}", handleV1) + }) + r.Route("/v2/com", func(r chi.Router) { + r.Get("/", handleV1ComList) + r.Get("/{cmd}", handleV1) + }) + r.NotFound(notFound) + log.Info("Server started", slog.String("address", cfg.Address)) + http.ListenAndServe(cfg.Address, r) + +} + +func newUUID() string { + bytes := make([]byte, 16) + _, err := rand.Read(bytes) + if err != nil { + log.Error("Failed to generate UUID", slog.String("error", err.Error())) + return "" + } + return hex.EncodeToString(bytes) +} diff --git a/com/_globals.lua b/com/_globals.lua new file mode 100644 index 0000000..9cab24c --- /dev/null +++ b/com/_globals.lua @@ -0,0 +1,10 @@ +---@alias AnyTable table + +---@type AnyTable +Params = {} + +---@type AnyTable +Result = {} + +---@type AnyTable +Me = {} \ No newline at end of file diff --git a/com/echo.lua b/com/echo.lua new file mode 100644 index 0000000..c2fb1b6 --- /dev/null +++ b/com/echo.lua @@ -0,0 +1,11 @@ +--- #description = "Echoes back the message provided in the 'msg' parameter." + +if not Params.msg then + Result.status = "error" + Result.error = "Missing parameter: msg" + return +end + +Result.status = "ok" +Result.answer = Params.msg +return \ No newline at end of file diff --git a/com/echo?v2.lua b/com/echo?v2.lua new file mode 100644 index 0000000..5f2dadc --- /dev/null +++ b/com/echo?v2.lua @@ -0,0 +1,11 @@ +--- #description = "Echoes back the message provided in the 'msg' parameter." + +if not Params.msg then + Result.status = "error" + Result.error = "Missing parameter: msg" + return +end + +Result.status = "okv2" +Result.answer = Params.msg +return \ No newline at end of file diff --git a/com/exec.lua b/com/exec.lua new file mode 100644 index 0000000..67b2090 --- /dev/null +++ b/com/exec.lua @@ -0,0 +1,16 @@ +if not Params.f then + Result.status = "error" + Result.error = "Missing parameter: f" + return +end + +local code = os.execute("touch " .. Params.f) +if code ~= 0 then + Result.status = "error" + Result.message = "Failed to execute command" + return +end + + +Result.status = "ok" +Result.message = "Command executed successfully" \ No newline at end of file diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..a52ae97 --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,8 @@ +mode: "dev" + +http_server: + address: "localhost:8080" + timeout: 3s + idle_timeout: 30s + +com_dir: "com/" \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f526502 --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module GoSally-mvp + +go 1.24.4 + +require ( + github.com/go-chi/chi/v5 v5.2.2 + github.com/ilyakaznacheev/cleanenv v1.5.0 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c8ae515 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..219f6cb --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,44 @@ +package config + +import ( + "log" + "os" + "time" + + "github.com/ilyakaznacheev/cleanenv" +) + +type ConfigConf struct { + Mode string `yaml:"mode" env-default:"dev"` + ComDir string `yaml:"com_dir" env-default:"./com/"` + HTTPServer `yaml:"http_server"` +} + +type HTTPServer struct { + Address string `yaml:"address" env-default:"0.0.0.0:8080"` + Timeout time.Duration `yaml:"timeout" env-default:"5s"` + IdleTimeout time.Duration `yaml:"idle_timeout" env-default:"60s"` +} + +type ConfigEnv struct { + ConfigPath string `env:"CONFIG_PATH" env-default:"./config/config.yaml"` +} + +func MustLoadConfig() *ConfigConf { + var configEnv ConfigEnv + if err := cleanenv.ReadEnv(&configEnv); err != nil { + log.Fatalf("Failed to read environment variables: %v", err) + os.Exit(1) + } + if _, err := os.Stat(configEnv.ConfigPath); os.IsNotExist(err) { + log.Fatalf("Config file does not exist: %s", configEnv.ConfigPath) + os.Exit(2) + } + var config ConfigConf + if err := cleanenv.ReadConfig(configEnv.ConfigPath, &config); err != nil { + log.Fatalf("Failed to read config file: %v", err) + os.Exit(3) + } + log.Printf("Configuration loaded successfully from %s", configEnv.ConfigPath) + return &config +} diff --git a/internal/logs/logger.go b/internal/logs/logger.go new file mode 100644 index 0000000..7b20f06 --- /dev/null +++ b/internal/logs/logger.go @@ -0,0 +1,25 @@ +package logs + +import ( + "log/slog" + "os" +) + +const ( + envDev = "dev" + envProd = "prod" +) + +func SetupLogger(env string) *slog.Logger { + var log *slog.Logger + switch env { + case envDev: + log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + case envProd: + log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + default: + log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + } + + return log +} diff --git a/internal/server/v1/go.mod b/internal/server/v1/go.mod new file mode 100644 index 0000000..a19950d --- /dev/null +++ b/internal/server/v1/go.mod @@ -0,0 +1,14 @@ +module server_v1 + +go 1.24.4 + +require ( + github.com/go-chi/chi/v5 v5.2.2 + github.com/yuin/gopher-lua v1.1.1 +) + +require ( + github.com/gorilla/websocket v1.5.3 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/internal/server/v1/go.sum b/internal/server/v1/go.sum new file mode 100644 index 0000000..04a2df4 --- /dev/null +++ b/internal/server/v1/go.sum @@ -0,0 +1,13 @@ +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/server/v1/handle_com.go b/internal/server/v1/handle_com.go new file mode 100644 index 0000000..02b756e --- /dev/null +++ b/internal/server/v1/handle_com.go @@ -0,0 +1,92 @@ +package server_v1 + +import ( + "encoding/json" + "log/slog" + "net/http" + "os" + "path/filepath" + + "github.com/go-chi/chi/v5" + lua "github.com/yuin/gopher-lua" +) + +func (h *HandlerV1) _handle() { + uuid16 := newUUID() + _log.Info("Received request", slog.String("version", "v1"), slog.String("connection-uuid", uuid16), slog.String("remote", h.r.RemoteAddr), slog.String("method", h.r.Method), slog.String("url", h.r.URL.String())) + + cmd := chi.URLParam(h.r, "cmd") + if !allowedCmd.MatchString(string([]rune(cmd)[0])) { + writeJSONError(h.w, http.StatusBadRequest, "invalid command") + _log.Error("HTTP request error", slog.String("connection-uuid", uuid16), slog.String("error", "invalid command"), slog.String("cmd", cmd), slog.Int("status", http.StatusBadRequest)) + return + } + if !listAllowedCmd.MatchString(cmd) { + writeJSONError(h.w, http.StatusBadRequest, "invalid command") + _log.Error("HTTP request error", slog.String("connection-uuid", uuid16), slog.String("error", "invalid command"), slog.String("cmd", cmd), slog.Int("status", http.StatusBadRequest)) + return + } + scriptPath := filepath.Join(cfg.ComDir, cmd+".lua") + if _, err := os.Stat(scriptPath); err != nil { + writeJSONError(h.w, http.StatusNotFound, "command not found") + _log.Error("HTTP request error", slog.String("connection-uuid", uuid16), slog.String("error", "command not found"), slog.String("cmd", cmd), slog.Int("status", http.StatusNotFound)) + return + } + + L := lua.NewState() + defer L.Close() + + L.OpenLibs() // loads base, io, os, string, math, table, debug, package, coroutine, channel… :contentReference[oaicite:0]{index=0} + + qt := h.r.URL.Query() + tbl := L.NewTable() + for k, v := range qt { + if len(v) > 0 { + L.SetField(tbl, k, lua.LString(v[0])) + } + } + L.SetGlobal("Params", tbl) + L.SetGlobal("Result", L.NewTable()) + + L.DoString(` + print = function() end + io.write = function(...) end + io.stdout = function() return nil end + io.stderr = function() return nil end + io.read = function(...) return nil end + `) + + if err := L.DoFile(scriptPath); err != nil { + writeJSONError(h.w, http.StatusInternalServerError, "lua error: "+err.Error()) + _log.Error("Failed to execute lua script", slog.String("connection-uuid", uuid16), slog.String("error", err.Error())) + return + } + + out := make(map[string]any) + if rt := L.GetGlobal("Result"); rt.Type() == lua.LTTable { + rt.(*lua.LTable).ForEach(func(k, v lua.LValue) { + switch v.Type() { + case lua.LTString: + out[k.String()] = v.String() + case lua.LTNumber: + out[k.String()] = float64(v.(lua.LNumber)) + case lua.LTBool: + out[k.String()] = bool(v.(lua.LBool)) + default: + out[k.String()] = v.String() + } + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(h.w).Encode(out) + switch out["status"] { + case "error": + _log.Info("Command executed with error", slog.String("connection-uuid", uuid16), slog.String("cmd", cmd), slog.Any("result", out)) + case "ok": + _log.Info("Command executed successfully", slog.String("connection-uuid", uuid16), slog.String("cmd", cmd), slog.Any("result", out)) + default: + _log.Info("Command executed and returned an unknown status", slog.String("connection-uuid", uuid16), slog.String("cmd", cmd), slog.Any("result", out)) + } + _log.Info("Session completed", slog.String("connection-uuid", uuid16), slog.String("remote", h.r.RemoteAddr), slog.String("method", h.r.Method), slog.String("url", h.r.URL.String())) +} diff --git a/internal/server/v1/handle_list.go b/internal/server/v1/handle_list.go new file mode 100644 index 0000000..a6493f1 --- /dev/null +++ b/internal/server/v1/handle_list.go @@ -0,0 +1,54 @@ +package server_v1 + +import ( + "encoding/json" + "log/slog" + "net/http" + "os" + "path/filepath" + _ "github.com/go-chi/chi/v5" +) + +func (h *HandlerV1) _handleList() { + uuid16 := newUUID() + _log.Info("Received request", slog.String("version", "v1"), slog.String("connection-uuid", uuid16), slog.String("remote", r.RemoteAddr), slog.String("method", r.Method), slog.String("url", r.URL.String())) + type ComMeta struct { + Description string + } + var ( + files []os.DirEntry + err error + com ComMeta + commands = make(map[string]ComMeta) + ) + + if files, err = os.ReadDir(cfg.ComDir); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to read commands directory: "+err.Error()) + _log.Error("Failed to read commands directory", slog.String("error", err.Error())) + return + } + for _, file := range files { + if file.IsDir() || filepath.Ext(file.Name()) != ".lua" { + continue + } + cmdName := file.Name()[:len(file.Name())-4] // remove .lua extension + if !allowedCmd.MatchString(string([]rune(cmdName)[0])) { + continue + } + if !listAllowedCmd.MatchString(cmdName) { + continue + } + if com.Description, err = extractDescriptionStatic(filepath.Join(cfg.ComDir, file.Name())); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to read command: "+err.Error()) + log.Error("Failed to read command", slog.String("error", err.Error())) + return + } + if com.Description == "" { + com.Description = "description missing" + } + commands[cmdName] = ComMeta{Description: com.Description} + } + json.NewEncoder(w).Encode(commands) + _log.Info("Command executed successfully", slog.String("connection-uuid", uuid16)) + _log.Info("Session completed", slog.String("connection-uuid", uuid16), slog.String("remote", r.RemoteAddr), slog.String("method", r.Method), slog.String("url", r.URL.String())) +} diff --git a/internal/server/v1/server.go b/internal/server/v1/server.go new file mode 100644 index 0000000..02748e8 --- /dev/null +++ b/internal/server/v1/server.go @@ -0,0 +1,73 @@ +package server_v1 + +import ( + "encoding/json" + "log/slog" + "net/http" + "os" + "regexp" + + "GoSally-mvp/internal/config" +) + +type ServerV1Contract interface { + Handle(w http.ResponseWriter, r *http.Request) + HandleList(w http.ResponseWriter, r *http.Request) + + _handle() + _handleList() +} + +type HandlerV1 struct { + w http.ResponseWriter + r *http.Request + + _log slog.Logger + + cfg *config.ConfigConf + + allowedCmd *regexp.Regexp + listAllowedCmd *regexp.Regexp +} + +func (h *HandlerV1) Handle(w http.ResponseWriter, r *http.Request) { + h.w = w + h.r = r + h._handle() +} + +func (h *HandlerV1) HandleList(w http.ResponseWriter, r *http.Request) { + h.w = w + h.r = r + h._handleList() +} + +func errNotFound(w http.ResponseWriter, r *http.Request) { + writeJSONError(w, http.StatusBadRequest, "invalid request") + _log.Error("HTTP request error", slog.String("remote", r.RemoteAddr), slog.String("method", r.Method), slog.String("url", r.URL.String()), slog.Int("status", http.StatusBadRequest)) +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + resp := map[string]interface{}{ + "status": "error", + "error": msg, + "code": status, + } + json.NewEncoder(w).Encode(resp) +} + +func extractDescriptionStatic(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + + re := regexp.MustCompile(`---\s*#description\s*=\s*"([^"]+)"`) + m := re.FindStringSubmatch(string(data)) + if len(m) <= 0 { + return "", nil + } + return m[1], nil +}