init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,219 @@
package api
import (
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
)
func AuditMiddleware(auditSvc *service.AuditLogService) fiber.Handler {
return func(c *fiber.Ctx) error {
if auditSvc == nil {
return c.Next()
}
start := time.Now()
err := c.Next()
status := c.Response().StatusCode()
if status == 0 {
status = fiber.StatusOK
}
requestID := c.GetRespHeader(fiber.HeaderXRequestID)
if requestID == "" {
requestID = localString(c.Locals("requestid"))
}
meta := map[string]any{
"latency_ms": time.Since(start).Milliseconds(),
}
if err != nil {
meta["error"] = err.Error()
}
module, action := resolveAuditModuleAction(c)
if !shouldPersistAuditByMethod(c.Method()) {
return err
}
auditSvc.Log(c.UserContext(), service.AuditLogInput{
RequestID: requestID,
ActorUserID: localUserID(c.Locals("user_id")),
Layer: "api",
Module: module,
Action: action,
Method: c.Method(),
Path: c.Path(),
StatusCode: status,
Success: status >= 200 && status < 400 && err == nil,
IP: c.IP(),
UserAgent: c.Get(fiber.HeaderUserAgent),
Message: "HTTP " + strconv.Itoa(status),
Metadata: meta,
})
return err
}
}
func shouldPersistAuditByMethod(method string) bool {
switch strings.ToUpper(strings.TrimSpace(method)) {
case fiber.MethodPost, fiber.MethodPut, fiber.MethodPatch, fiber.MethodDelete:
return true
default:
return false
}
}
func resolveAuditModuleAction(c *fiber.Ctx) (string, string) {
method := c.Method()
path := c.Path()
normalized := strings.ToLower(strings.TrimSpace(path))
parts := strings.Split(strings.Trim(normalized, "/"), "/")
if len(parts) < 3 || parts[0] != "api" || parts[1] != "v1" {
return sharedconst.AuditModuleUnknown, actionFromMethod(method)
}
moduleSegment := strings.TrimSpace(parts[2])
module := moduleFromPathSegment(moduleSegment)
if moduleSegment == "bases" {
module = resolveBaseModuleVariant(c, module)
}
suffix := ""
if len(parts) > 3 {
suffix = strings.TrimSpace(parts[len(parts)-1])
}
switch suffix {
case "get-all", "dt":
return module, sharedconst.AuditActionList
case "get":
return module, sharedconst.AuditActionGet
case "create":
return module, sharedconst.AuditActionCreate
case "update":
return module, sharedconst.AuditActionUpdate
case "delete":
return module, sharedconst.AuditActionDelete
case "export":
return module, sharedconst.AuditActionExport
}
return module, actionFromMethod(method)
}
func resolveBaseModuleVariant(c *fiber.Ctx, fallback string) string {
raw := strings.TrimSpace(c.Query("category_type"))
if raw == "" {
raw = strings.TrimSpace(c.Query("base_category"))
}
if raw == "" {
raw = strings.TrimSpace(c.Query("category"))
}
switch strings.ToLower(raw) {
case "regular":
return sharedconst.AuditModuleBaseRegular
case "hems":
return sharedconst.AuditModuleBaseHEMS
default:
return fallback
}
}
func actionFromMethod(method string) string {
switch strings.ToUpper(strings.TrimSpace(method)) {
case fiber.MethodGet:
return sharedconst.AuditActionGet
case fiber.MethodPost:
return sharedconst.AuditActionCreate
case fiber.MethodPut, fiber.MethodPatch:
return sharedconst.AuditActionUpdate
case fiber.MethodDelete:
return sharedconst.AuditActionDelete
default:
return sharedconst.AuditActionCustom
}
}
func moduleFromPathSegment(segment string) string {
switch strings.ToLower(strings.TrimSpace(segment)) {
case "air-rescuer-checklist":
return sharedconst.AuditModuleAirRescuerChecklist
case "audit-logs":
return sharedconst.AuditModuleAuditLog
case "auth":
return sharedconst.AuditModuleAuth
case "bases":
return sharedconst.AuditModuleBase
case "branding", "public":
return sharedconst.AuditModuleBranding
case "contacts":
return sharedconst.AuditModuleContact
case "duty-roster":
return sharedconst.AuditModuleDutyRoster
case "dul":
return sharedconst.AuditModuleDUL
case "facilities":
return sharedconst.AuditModuleFacility
case "federal-state":
return sharedconst.AuditModuleFederalState
case "file-manager":
return sharedconst.AuditModuleFileManager
case "flight-data":
return sharedconst.AuditModuleFlightData
case "flights":
return sharedconst.AuditModuleFlight
case "forces-present":
return sharedconst.AuditModuleForcesPresent
case "health-insurance-companies":
return sharedconst.AuditModuleHealthInsuranceCompany
case "helicopter-files":
return sharedconst.AuditModuleHelicopterFile
case "helicopters":
return sharedconst.AuditModuleHelicopter
case "hems-operation", "hems-operation-category", "hems-operational-data":
return sharedconst.AuditModuleUnknown
case "hospital":
return sharedconst.AuditModuleHospital
case "icao":
return sharedconst.AuditModuleICAO
case "insurance-patient-data":
return sharedconst.AuditModuleInsurancePatientData
case "land":
return sharedconst.AuditModuleLand
case "master-settings":
return sharedconst.AuditModuleMasterSetting
case "medicine":
return sharedconst.AuditModuleMedicine
case "mission":
return sharedconst.AuditModuleMission
case "opc":
return sharedconst.AuditModuleOPC
case "patient-data":
return sharedconst.AuditModulePatientData
case "reserve-acs":
return sharedconst.AuditModuleReserveAC
case "roles":
return sharedconst.AuditModuleRole
case "users":
return sharedconst.AuditModuleUser
case "vocation":
return sharedconst.AuditModuleVocation
default:
return sharedconst.AuditModuleUnknown
}
}
func localString(v any) string {
s, _ := v.(string)
return s
}
func localUserID(v any) []byte {
id, _ := v.([]byte)
if len(id) != 16 {
return nil
}
return id
}

View File

@@ -0,0 +1,208 @@
package api
import (
"context"
"net/http"
"sync"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/domain/audit"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
)
type mockAuditMiddlewareRepo struct {
mu sync.Mutex
entries []*audit.AuditLog
}
func (m *mockAuditMiddlewareRepo) Create(_ context.Context, entry *audit.AuditLog) error {
m.mu.Lock()
defer m.mu.Unlock()
cp := *entry
m.entries = append(m.entries, &cp)
return nil
}
func TestAuditMiddleware_SkipsGETRequests(t *testing.T) {
repo := &mockAuditMiddlewareRepo{}
auditSvc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 16, Workers: 1})
app := fiber.New()
app.Use(AuditMiddleware(auditSvc))
app.Get("/api/v1/ping", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
req, _ := http.NewRequest(http.MethodGet, "/api/v1/ping", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
repo.mu.Lock()
n := len(repo.entries)
repo.mu.Unlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.entries) != 0 {
t.Fatalf("expected no audit log entry for GET, got=%d", len(repo.entries))
}
}
func TestAuditMiddleware_LogsMutationRequests(t *testing.T) {
repo := &mockAuditMiddlewareRepo{}
auditSvc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 16, Workers: 1})
app := fiber.New()
app.Use(AuditMiddleware(auditSvc))
app.Post("/api/v1/helicopters/create", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
req, _ := http.NewRequest(http.MethodPost, "/api/v1/helicopters/create", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
repo.mu.Lock()
n := len(repo.entries)
repo.mu.Unlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.entries) == 0 {
t.Fatalf("expected audit log entry for mutation request")
}
got := repo.entries[0]
if got.Layer != "api" || got.Module != sharedconst.AuditModuleHelicopter || got.Action != sharedconst.AuditActionCreate {
t.Fatalf("unexpected audit entry: layer=%s module=%s action=%s", got.Layer, got.Module, got.Action)
}
if got.StatusCode != http.StatusCreated {
t.Fatalf("expected status 201, got %d", got.StatusCode)
}
}
func TestModuleFromPathSegment(t *testing.T) {
cases := []struct {
segment string
want string
}{
{segment: "helicopters", want: sharedconst.AuditModuleHelicopter},
{segment: "bases", want: sharedconst.AuditModuleBase},
{segment: "health-insurance-companies", want: sharedconst.AuditModuleHealthInsuranceCompany},
{segment: "hems-roster", want: sharedconst.AuditModuleUnknown},
{segment: "dul", want: sharedconst.AuditModuleDUL},
{segment: "reserve-acs", want: sharedconst.AuditModuleReserveAC},
{segment: "not-registered", want: sharedconst.AuditModuleUnknown},
}
for _, tc := range cases {
t.Run(tc.segment, func(t *testing.T) {
got := moduleFromPathSegment(tc.segment)
if got != tc.want {
t.Fatalf("moduleFromPathSegment(%q) = %q, want %q", tc.segment, got, tc.want)
}
})
}
}
func TestResolveAuditModuleAction(t *testing.T) {
cases := []struct {
name string
method string
target string
wantModule string
wantAction string
}{
{
name: "datatable list",
method: http.MethodGet,
target: "/api/v1/helicopters/get-all/dt",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionList,
},
{
name: "explicit create",
method: http.MethodPost,
target: "/api/v1/helicopters/create",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionCreate,
},
{
name: "fallback method update",
method: http.MethodPatch,
target: "/api/v1/helicopters/019dbeb6-4c37-7495-b0d0-e02ed134f87d",
wantModule: sharedconst.AuditModuleHelicopter,
wantAction: sharedconst.AuditActionUpdate,
},
{
name: "base regular variant from query",
method: http.MethodGet,
target: "/api/v1/bases/get-all?category_type=regular",
wantModule: sharedconst.AuditModuleBaseRegular,
wantAction: sharedconst.AuditActionList,
},
{
name: "base hems variant from query alias",
method: http.MethodGet,
target: "/api/v1/bases/get-all?base_category=hems",
wantModule: sharedconst.AuditModuleBaseHEMS,
wantAction: sharedconst.AuditActionList,
},
{
name: "unknown module",
method: http.MethodGet,
target: "/api/v1/ping",
wantModule: sharedconst.AuditModuleUnknown,
wantAction: sharedconst.AuditActionGet,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
app := fiber.New()
app.All("/*", func(c *fiber.Ctx) error {
gotModule, gotAction := resolveAuditModuleAction(c)
if gotModule != tc.wantModule || gotAction != tc.wantAction {
t.Fatalf("resolveAuditModuleAction(%q, %q) = (%q, %q), want (%q, %q)",
tc.method, tc.target, gotModule, gotAction, tc.wantModule, tc.wantAction)
}
return c.SendStatus(http.StatusNoContent)
})
req, _ := http.NewRequest(tc.method, tc.target, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204, got %d", resp.StatusCode)
}
})
}
}

View File

@@ -0,0 +1,131 @@
package api
import (
"archive/zip"
"bytes"
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
)
// buildTinyXLSX returns a minimal but structurally valid .xlsx (ZIP) payload.
func buildTinyXLSX(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
entries := map[string]string{
"[Content_Types].xml": `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/></Types>`,
"_rels/.rels": `<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
"xl/workbook.xml": `<?xml version="1.0"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>`,
}
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := io.WriteString(w, content); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
return buf.Bytes()
}
func assertValidZip(t *testing.T, b []byte) {
t.Helper()
if _, err := zip.NewReader(bytes.NewReader(b), int64(len(b))); err != nil {
t.Fatalf("payload is not a valid zip: %v", err)
}
}
func TestIsWOPIRequest(t *testing.T) {
app := fiber.New()
cases := []struct {
path string
want bool
}{
{"/wopi/files/123/contents", true},
{"/wopi/files/123", true},
{"/api/file-manager/files", false},
{"/wopimirror/x", false},
{"/", false},
}
for _, tc := range cases {
app.Get(tc.path, func(c *fiber.Ctx) error {
if got := isWOPIRequest(c); got != tc.want {
t.Errorf("isWOPIRequest(%q) = %v, want %v", tc.path, got, tc.want)
}
return c.SendStatus(fiber.StatusOK)
})
resp, err := app.Test(httptest.NewRequest("GET", tc.path, nil), -1)
if err != nil {
t.Fatalf("app.Test(%q): %v", tc.path, err)
}
_ = resp.Body.Close()
}
}
// Regression for the "template .xlsx always corrupt via WOPI" bug: the global
// gzip compress middleware must NOT compress WOPI file-content responses.
// Collabora saves the response body verbatim as the document, so a gzip stream
// there is a guaranteed-corrupt file.
func TestCompressMiddleware_SkipsWOPIContents(t *testing.T) {
src := buildTinyXLSX(t)
assertValidZip(t, src)
app := fiber.New()
// Mirror the production compress wiring (UseDefaultMiddlewares).
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
Next: func(c *fiber.Ctx) bool {
return isEventStreamRequest(c) || isWOPIRequest(c)
},
}))
streamXLSX := func(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, "application/octet-stream")
c.Status(fiber.StatusOK)
c.Context().Response.SetBodyStream(io.NopCloser(bytes.NewReader(src)), len(src))
return nil
}
app.Get("/wopi/files/:id/contents", streamXLSX)
// Negative control: a non-WOPI route with a compressible body must still gzip.
app.Get("/api/echo", func(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, "application/octet-stream")
return c.Send(bytes.Repeat([]byte("A"), 4096))
})
// WOPI content download with gzip offered must come back raw + valid.
req := httptest.NewRequest("GET", "/wopi/files/abc/contents", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := app.Test(req, -1)
if err != nil {
t.Fatalf("app.Test wopi: %v", err)
}
if enc := resp.Header.Get("Content-Encoding"); enc != "" {
t.Fatalf("WOPI content unexpectedly encoded: Content-Encoding=%q", enc)
}
got, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
assertValidZip(t, got)
if !bytes.Equal(got, src) {
t.Fatalf("WOPI content body mutated: got %d bytes, want %d", len(got), len(src))
}
// Sanity: prove compression is actually active for non-excluded routes.
req2 := httptest.NewRequest("GET", "/api/echo", nil)
req2.Header.Set("Accept-Encoding", "gzip")
resp2, err := app.Test(req2, -1)
if err != nil {
t.Fatalf("app.Test echo: %v", err)
}
if enc := resp2.Header.Get("Content-Encoding"); enc != "gzip" {
t.Fatalf("expected gzip on non-WOPI route, got Content-Encoding=%q (compress not active - test invalid)", enc)
}
_ = resp2.Body.Close()
}

View File

@@ -0,0 +1,471 @@
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/helmet"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/gofiber/fiber/v2/middleware/timeout"
"github.com/gofiber/fiber/v2/utils"
"github.com/prometheus/client_golang/prometheus"
"wucher/internal/config"
"wucher/internal/shared/pkg/apperrorsx"
)
var (
httpMetricsRegisterOnce = sync.Once{}
httpRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests.",
}, []string{"method", "path", "status"})
httpRequestDurationSeconds = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds.",
Buckets: prometheus.DefBuckets,
}, []string{"method", "path", "status"})
)
// UseDefaultMiddlewares applies baseline best-practice middleware.
func UseDefaultMiddlewares(app *fiber.App, cfg config.HTTPConfig) {
registerHTTPMetrics()
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
StackTraceHandler: func(c *fiber.Ctx, err any) {
slog.Error("panic recovered", "method", c.Method(), "path", c.Path(), "error", err, "stack", string(debug.Stack()))
},
}))
app.Use(requestid.New(requestid.Config{
Header: fiber.HeaderXRequestID,
ContextKey: "requestid",
Generator: utils.UUIDv4,
}))
app.Use(func(c *fiber.Ctx) error {
// Use a request-scoped context that keeps request values but is not canceled
// as soon as fasthttp starts draining on server shutdown.
c.SetUserContext(context.WithoutCancel(c.Context()))
return c.Next()
})
app.Use(httpAccessLogMiddleware())
app.Use(compress.New(compress.Config{
Level: compress.LevelBestSpeed,
Next: func(c *fiber.Ctx) bool {
return isEventStreamRequest(c) || isWOPIRequest(c)
},
}))
app.Use(requestTimeoutMiddleware(cfg))
app.Use(httpMetricsMiddleware())
app.Use(helmet.New(helmet.Config{
XFrameOptions: "DENY",
ReferrerPolicy: "no-referrer",
PermissionPolicy: "geolocation=(), microphone=(), camera=(), payment=(), usb=()",
HSTSMaxAge: cfg.HSTSMaxAge,
HSTSPreloadEnabled: cfg.HSTSMaxAge > 0,
}))
app.Use(disallowUnsafeMethods())
corsAllowCredentials := cfg.CORSAllowCredentials && !containsWildcardOrigin(cfg.CORSAllowOrigins)
app.Use(cors.New(cors.Config{
AllowOrigins: cfg.CORSAllowOrigins,
AllowMethods: cfg.CORSAllowMethods,
AllowHeaders: cfg.CORSAllowHeaders,
ExposeHeaders: cfg.CORSExposeHeaders,
AllowCredentials: corsAllowCredentials,
MaxAge: cfg.CORSMaxAge,
}))
app.Use(limiter.New(limiter.Config{
Max: cfg.RateLimitMax,
Expiration: cfg.RateLimitWindow,
Next: func(c *fiber.Ctx) bool {
path := c.Path()
return c.Method() == fiber.MethodOptions ||
path == "/health" ||
strings.HasPrefix(path, "/swagger")
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(cfg.RateLimitWindow.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
}))
// Per-endpoint limiter (exact method+path match), configured from env/config.
// Applied in addition to the global limiter above.
endpointKeys := make([]string, 0, len(cfg.RateLimitByEndpoint))
for key := range cfg.RateLimitByEndpoint {
endpointKeys = append(endpointKeys, key)
}
sort.Strings(endpointKeys)
for _, endpointKey := range endpointKeys {
rule := cfg.RateLimitByEndpoint[endpointKey]
currentEndpointKey := endpointKey
currentRule := rule
app.Use(limiter.New(limiter.Config{
Max: currentRule.Max,
Expiration: currentRule.Window,
Next: func(c *fiber.Ctx) bool {
return methodPathKey(c.Method(), c.Path()) != currentEndpointKey
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(currentRule.Window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": fmt.Sprintf("rate limit exceeded for %s", currentEndpointKey),
}},
})
},
}))
}
}
func httpAccessLogMiddleware() fiber.Handler {
requestLogger := slog.Default()
return func(c *fiber.Ctx) error {
started := time.Now()
err := c.Next()
if c.Path() == "/health" {
return err
}
statusCode := c.Response().StatusCode()
level := slog.LevelInfo
if statusCode >= fiber.StatusInternalServerError || err != nil {
level = slog.LevelError
} else if statusCode >= fiber.StatusBadRequest {
level = slog.LevelWarn
}
requestID := strings.TrimSpace(fmt.Sprint(c.Locals("requestid")))
if requestID == "" || requestID == "<nil>" {
requestID = strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
}
args := []any{
slog.String("request_id", requestID),
slog.String("method", c.Method()),
slog.String("path", c.Path()),
slog.Int("status", statusCode),
slog.Duration("latency", time.Since(started)),
slog.String("ip", c.IP()),
}
if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail))); detail != "" && detail != "<nil>" {
args = append(args, slog.String("internal_detail", detail))
}
if detail := strings.TrimSpace(fmt.Sprint(c.Locals(apperrorsx.ContextKeyHTTPErrorDetail))); detail != "" && detail != "<nil>" {
args = append(args, slog.String("detail", detail))
}
if err != nil {
args = append(args, slog.Any("error", err))
args = append(args, slog.Any("error_chain", buildErrorChain(err)))
}
if statusCode >= fiber.StatusInternalServerError {
if body := strings.TrimSpace(string(c.Response().Body())); body != "" {
args = append(args, slog.String("response_body", truncateForLog(body, 1800)))
}
}
requestLogger.Log(c.UserContext(), level, "http request", args...)
return err
}
}
func buildErrorChain(err error) []string {
if err == nil {
return nil
}
chain := make([]string, 0, 8)
seen := map[string]struct{}{}
for current := err; current != nil; current = errors.Unwrap(current) {
msg := current.Error()
if msg == "" {
msg = fmt.Sprintf("%T", current)
}
key := fmt.Sprintf("%T:%s", current, msg)
if _, ok := seen[key]; ok {
break
}
seen[key] = struct{}{}
chain = append(chain, fmt.Sprintf("%T: %s", current, msg))
}
return chain
}
func truncateForLog(v string, max int) string {
if max <= 0 || len(v) <= max {
return v
}
if max <= 3 {
return v[:max]
}
return v[:max-3] + "..."
}
func httpMetricsMiddleware() fiber.Handler {
return func(c *fiber.Ctx) error {
started := time.Now()
method := normalizeHTTPMethod(c.Method())
err := c.Next()
path := metricsPathLabel(c)
if shouldSkipHTTPMetrics(path) {
return err
}
status := strconv.Itoa(c.Response().StatusCode())
httpRequestsTotal.WithLabelValues(method, path, status).Inc()
httpRequestDurationSeconds.WithLabelValues(method, path, status).Observe(time.Since(started).Seconds())
return err
}
}
func registerHTTPMetrics() {
httpMetricsRegisterOnce.Do(func() {
registerHTTPCollector(httpRequestsTotal)
registerHTTPCollector(httpRequestDurationSeconds)
})
}
func registerHTTPCollector(collector prometheus.Collector) {
if err := prometheus.Register(collector); err != nil {
var alreadyRegistered prometheus.AlreadyRegisteredError
if errors.As(err, &alreadyRegistered) {
return
}
panic(err)
}
}
func shouldSkipHTTPMetrics(path string) bool {
return path == "/metrics" || path == "/health"
}
func metricsPathLabel(c *fiber.Ctx) string {
route := c.Route()
if route != nil {
if routePath := strings.TrimSpace(utils.CopyString(route.Path)); routePath != "" {
return routePath
}
}
return "unmatched"
}
func normalizeHTTPMethod(method string) string {
method = strings.ToUpper(strings.TrimSpace(utils.CopyString(method)))
if method == "" {
return "UNKNOWN"
}
switch method {
case fiber.MethodGet,
fiber.MethodHead,
fiber.MethodPost,
fiber.MethodPut,
fiber.MethodPatch,
fiber.MethodDelete,
fiber.MethodOptions,
fiber.MethodTrace,
fiber.MethodConnect:
return method
default:
return "OTHER"
}
}
func requestTimeoutMiddleware(cfg config.HTTPConfig) fiber.Handler {
defaultTimeout := cfg.RequestTimeout
overrides := cfg.RequestTimeoutByPath
if defaultTimeout <= 0 && len(overrides) == 0 {
return func(c *fiber.Ctx) error { return c.Next() }
}
return func(c *fiber.Ctx) error {
if isEventStreamRequest(c) {
return c.Next()
}
requestTimeout := defaultTimeout
if override, ok := timeoutForRequest(overrides, c.Method(), c.Path(), c.Route()); ok {
requestTimeout = override
}
if requestTimeout <= 0 {
return c.Next()
}
return timeout.NewWithContext(func(c *fiber.Ctx) error {
return c.Next()
}, requestTimeout)(c)
}
}
func isEventStreamRequest(c *fiber.Ctx) bool {
if c == nil {
return false
}
path := strings.TrimSpace(c.Path())
if strings.HasSuffix(path, "/file-manager/events") {
return true
}
accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
return strings.Contains(accept, "text/event-stream")
}
// isWOPIRequest reports whether the request targets the Collabora/WOPI
// endpoints. WOPI file-content responses carry raw office bytes (an .xlsx is a
// ZIP container that is already compressed); gzipping them makes Collabora
// store the compressed stream verbatim as the document, producing a file that
// is consistently corrupt. Office/JSON WOPI payloads gain nothing from gzip, so
// the whole group is excluded from the compress middleware.
func isWOPIRequest(c *fiber.Ctx) bool {
if c == nil {
return false
}
return strings.HasPrefix(strings.TrimSpace(c.Path()), "/wopi/")
}
func timeoutForRequest(overrides map[string]time.Duration, method, path string, route *fiber.Route) (time.Duration, bool) {
if len(overrides) == 0 {
return 0, false
}
normalizedMethod := strings.ToUpper(strings.TrimSpace(method))
trimmedPath := strings.TrimSpace(path)
if route != nil {
registeredPath := strings.TrimSpace(route.Path)
if registeredPath != "" && registeredPath != "/" {
key := timeoutOverrideKey(normalizedMethod, registeredPath)
if v, ok := overrides[key]; ok {
return v, true
}
}
}
if v, ok := overrides[timeoutOverrideKey(normalizedMethod, trimmedPath)]; ok {
return v, true
}
for key, duration := range overrides {
parts := strings.SplitN(key, " ", 2)
if len(parts) != 2 {
continue
}
if strings.ToUpper(strings.TrimSpace(parts[0])) != normalizedMethod {
continue
}
if routePatternMatches(strings.TrimSpace(parts[1]), trimmedPath) {
return duration, true
}
}
return 0, false
}
func methodPathKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
func timeoutOverrideKey(method, path string) string {
return strings.ToUpper(strings.TrimSpace(method)) + " " + strings.TrimSpace(path)
}
func routePatternMatches(pattern, path string) bool {
if pattern == "" {
return false
}
if pattern == path {
return true
}
pSeg := splitPathSegments(pattern)
rSeg := splitPathSegments(path)
if len(pSeg) == 0 && len(rSeg) == 0 {
return true
}
i := 0
for ; i < len(pSeg); i++ {
segment := pSeg[i]
if segment == "*" || strings.HasPrefix(segment, "*") {
return true
}
if i >= len(rSeg) {
return false
}
if strings.HasPrefix(segment, ":") || strings.HasPrefix(segment, "+") {
continue
}
if segment != rSeg[i] {
return false
}
}
return i == len(rSeg)
}
func splitPathSegments(path string) []string {
trimmed := strings.TrimSpace(path)
trimmed = strings.TrimPrefix(trimmed, "/")
trimmed = strings.TrimSuffix(trimmed, "/")
if trimmed == "" {
return nil
}
return strings.Split(trimmed, "/")
}
func disallowUnsafeMethods() fiber.Handler {
return func(c *fiber.Ctx) error {
if c.Method() == fiber.MethodTrace || c.Method() == "TRACK" {
return c.SendStatus(fiber.StatusMethodNotAllowed)
}
return c.Next()
}
}
func containsWildcardOrigin(origins string) bool {
for _, origin := range strings.Split(origins, ",") {
if strings.TrimSpace(origin) == "*" {
return true
}
}
return false
}

View File

@@ -0,0 +1,586 @@
package api
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"wucher/internal/config"
"wucher/internal/shared/pkg/apperrorsx"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
)
func testHTTPConfig() config.HTTPConfig {
return config.HTTPConfig{
CORSAllowOrigins: "http://localhost:3000",
CORSAllowMethods: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
CORSAllowHeaders: "Origin,Content-Type,Accept,Authorization",
CORSExposeHeaders: "X-Request-ID",
CORSAllowCredentials: true,
CORSMaxAge: 300,
RateLimitMax: 100,
RateLimitWindow: time.Minute,
RequestTimeout: 0,
RequestTimeoutByPath: map[string]time.Duration{},
HSTSMaxAge: 0,
}
}
func TestUseDefaultMiddlewares_CORSHeaders(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("Origin", "http://localhost:3000")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" {
t.Fatalf("expected allow-origin http://localhost:3000, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
t.Fatalf("expected allow-credentials true, got %q", got)
}
if got := resp.Header.Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("expected X-Frame-Options DENY, got %q", got)
}
if got := resp.Header.Get("X-Request-Id"); got == "" {
t.Fatalf("expected request id header to be set")
}
}
func TestUseDefaultMiddlewares_BlockTrace(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.All("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodTrace, "/ping", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("expected 405, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RateLimiter(t *testing.T) {
cfg := testHTTPConfig()
cfg.RateLimitMax = 1
cfg.RateLimitWindow = time.Minute
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/limited", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
firstReq, _ := http.NewRequest(http.MethodGet, "/limited", nil)
firstResp, err := app.Test(firstReq)
if err != nil {
t.Fatalf("first request app.Test: %v", err)
}
if firstResp.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", firstResp.StatusCode)
}
secondReq, _ := http.NewRequest(http.MethodGet, "/limited", nil)
secondResp, err := app.Test(secondReq)
if err != nil {
t.Fatalf("second request app.Test: %v", err)
}
if secondResp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", secondResp.StatusCode)
}
if got := secondResp.Header.Get("Retry-After"); got == "" {
t.Fatalf("expected Retry-After header on 429 response")
}
}
func TestUseDefaultMiddlewares_EndpointRateLimiter(t *testing.T) {
cfg := testHTTPConfig()
cfg.RateLimitMax = 100
cfg.RateLimitByEndpoint = map[string]config.RateLimitRule{
"POST /auth/login": {
Max: 1,
Window: time.Minute,
},
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Post("/auth/login", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
app.Post("/auth/refresh", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req1, _ := http.NewRequest(http.MethodPost, "/auth/login", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first login request app.Test: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first login request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/auth/login", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second login request app.Test: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second login request 429, got %d", resp2.StatusCode)
}
otherReq, _ := http.NewRequest(http.MethodPost, "/auth/refresh", nil)
otherResp, err := app.Test(otherReq)
if err != nil {
t.Fatalf("other endpoint request app.Test: %v", err)
}
if otherResp.StatusCode != http.StatusOK {
t.Fatalf("expected other endpoint request 200, got %d", otherResp.StatusCode)
}
}
func TestUseDefaultMiddlewares_WildcardOriginDisablesCredentials(t *testing.T) {
cfg := testHTTPConfig()
cfg.CORSAllowOrigins = "*"
cfg.CORSAllowCredentials = true
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ping", func(c *fiber.Ctx) error { return c.SendStatus(http.StatusOK) })
req, _ := http.NewRequest(http.MethodGet, "/ping", nil)
req.Header.Set("Origin", "http://localhost:3000")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Fatalf("expected allow-origin *, got %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Fatalf("expected allow-credentials to be omitted, got %q", got)
}
}
func TestUseDefaultMiddlewares_RequestContextDetachedFromShutdown(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/ctx", func(c *fiber.Ctx) error {
if c.UserContext() == nil {
return c.SendStatus(http.StatusInternalServerError)
}
if c.UserContext().Done() != nil {
return c.SendStatus(http.StatusInternalServerError)
}
return c.SendStatus(http.StatusOK)
})
req, _ := http.NewRequest(http.MethodGet, "/ctx", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_CompressesLargeJSONWhenGzipAccepted(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
largePayload := map[string]string{
"data": strings.Repeat("x", 8*1024),
}
app.Get("/large-json", func(c *fiber.Ctx) error {
return c.JSON(largePayload)
})
req, _ := http.NewRequest(http.MethodGet, "/large-json", nil)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("expected Content-Encoding gzip, got %q", got)
}
gz, err := gzip.NewReader(resp.Body)
if err != nil {
t.Fatalf("gzip reader: %v", err)
}
defer gz.Close()
var payload map[string]string
if err := json.NewDecoder(gz).Decode(&payload); err != nil {
t.Fatalf("decode json: %v", err)
}
if payload["data"] != largePayload["data"] {
t.Fatalf("unexpected payload body")
}
}
func TestUseDefaultMiddlewares_RequestTimeoutExceeded(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/slow", func(c *fiber.Ctx) error {
select {
case <-time.After(50 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/slow", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusRequestTimeout {
t.Fatalf("expected 408, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutNormalRequestUnaffected(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 200 * time.Millisecond
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/fast", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req, _ := http.NewRequest(http.MethodGet, "/fast", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutPerEndpointOverride(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
cfg.RequestTimeoutByPath = map[string]time.Duration{
"GET /slow": 120 * time.Millisecond,
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/slow", func(c *fiber.Ctx) error {
select {
case <-time.After(40 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/slow", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with override timeout, got %d", resp.StatusCode)
}
}
func TestUseDefaultMiddlewares_RequestTimeoutPerEndpointOverrideDynamicRoute(t *testing.T) {
cfg := testHTTPConfig()
cfg.RequestTimeout = 10 * time.Millisecond
cfg.RequestTimeoutByPath = map[string]time.Duration{
"GET /users/:id": 120 * time.Millisecond,
}
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/users/:id", func(c *fiber.Ctx) error {
select {
case <-time.After(40 * time.Millisecond):
return c.SendStatus(http.StatusOK)
case <-c.UserContext().Done():
return c.SendStatus(http.StatusRequestTimeout)
}
})
req, _ := http.NewRequest(http.MethodGet, "/users/123", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 with dynamic route override timeout, got %d", resp.StatusCode)
}
}
func TestHTTPAccessLogMiddlewareIncludesErrorDetail(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/bad", func(c *fiber.Ctx) error {
return response.WriteErrors(c, http.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Title: "Validation error",
Detail: "name is required",
}})
})
req, _ := http.NewRequest(http.MethodGet, "/bad", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if strings.Contains(string(body), "name is required") {
t.Fatalf("expected sanitized response detail, got %s", string(body))
}
if !strings.Contains(string(body), `"detail":"validation failed"`) {
t.Fatalf("expected generic response detail, got %s", string(body))
}
if !strings.Contains(logBuf.String(), "name is required") {
t.Fatalf("expected access log to include original detail, got %s", logBuf.String())
}
}
func TestHTTPAccessLogMiddlewareIncludesResponseBodyOnServerError(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/boom", func(c *fiber.Ctx) error {
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "500",
"title": "Update failed",
"detail": "db deadlock on takeover update",
}},
})
})
req, _ := http.NewRequest(http.MethodGet, "/boom", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
if !strings.Contains(logBuf.String(), "db deadlock on takeover update") {
t.Fatalf("expected access log to include response body detail, got %s", logBuf.String())
}
if !strings.Contains(logBuf.String(), "response_body") {
t.Fatalf("expected access log to include response_body field, got %s", logBuf.String())
}
}
func TestHTTPAccessLogMiddlewareIncludesInternalErrorDetail(t *testing.T) {
var logBuf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuf, &slog.HandlerOptions{})))
t.Cleanup(func() {
slog.SetDefault(prev)
})
app := fiber.New()
app.Use(httpAccessLogMiddleware())
app.Get("/boom", func(c *fiber.Ctx) error {
c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail, "action=UpdateTakeover | error=reserve update failed | chain=*errors.errorString: reserve update failed")
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "500",
"title": "Update failed",
"detail": "internal server error",
}},
})
})
req, _ := http.NewRequest(http.MethodGet, "/boom", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", resp.StatusCode)
}
if !strings.Contains(logBuf.String(), "internal_detail") {
t.Fatalf("expected access log to include internal_detail field, got %s", logBuf.String())
}
if !strings.Contains(logBuf.String(), "reserve update failed") {
t.Fatalf("expected access log to include internal error detail, got %s", logBuf.String())
}
}
func TestUseDefaultMiddlewares_HTTPMetricsUsesRoutePatternLabels(t *testing.T) {
cfg := testHTTPConfig()
app := fiber.New()
UseDefaultMiddlewares(app, cfg)
app.Get("/users/:id", func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusCreated)
})
app.Get("/metrics", func(c *fiber.Ctx) error {
metricsHandler(c.Context())
return nil
})
beforeCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/users/:id", "201")
beforeHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/users/:id", "201")
req, _ := http.NewRequest(http.MethodGet, "/users/123", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
afterCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/users/:id", "201")
afterHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/users/:id", "201")
if afterCounter <= beforeCounter {
t.Fatalf("expected counter to increase, before=%v after=%v", beforeCounter, afterCounter)
}
if afterHistogram <= beforeHistogram {
t.Fatalf("expected histogram count to increase, before=%v after=%v", beforeHistogram, afterHistogram)
}
metricsReq, _ := http.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp, err := app.Test(metricsReq)
if err != nil {
t.Fatalf("metrics app.Test: %v", err)
}
if metricsResp.StatusCode != http.StatusOK {
t.Fatalf("expected /metrics status 200, got %d", metricsResp.StatusCode)
}
body, err := io.ReadAll(metricsResp.Body)
if err != nil {
t.Fatalf("read /metrics body: %v", err)
}
metricsBody := string(body)
if !strings.Contains(metricsBody, "http_requests_total") {
t.Fatalf("expected http_requests_total in /metrics output")
}
if !strings.Contains(metricsBody, "http_request_duration_seconds") {
t.Fatalf("expected http_request_duration_seconds in /metrics output")
}
if !strings.Contains(metricsBody, "path=\"/users/:id\"") {
t.Fatalf("expected route-pattern path label in /metrics output")
}
beforeMetricsCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/metrics", "200")
beforeMetricsHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/metrics", "200")
metricsReq2, _ := http.NewRequest(http.MethodGet, "/metrics", nil)
metricsResp2, err := app.Test(metricsReq2)
if err != nil {
t.Fatalf("metrics second app.Test: %v", err)
}
if metricsResp2.StatusCode != http.StatusOK {
t.Fatalf("expected second /metrics status 200, got %d", metricsResp2.StatusCode)
}
afterMetricsCounter := metricCounterValue(t, httpRequestsTotal, "GET", "/metrics", "200")
afterMetricsHistogram := metricHistogramCount(t, httpRequestDurationSeconds, "GET", "/metrics", "200")
if afterMetricsCounter != beforeMetricsCounter {
t.Fatalf("expected /metrics to be excluded from counter, before=%v after=%v", beforeMetricsCounter, afterMetricsCounter)
}
if afterMetricsHistogram != beforeMetricsHistogram {
t.Fatalf("expected /metrics to be excluded from histogram, before=%v after=%v", beforeMetricsHistogram, afterMetricsHistogram)
}
}
func TestNormalizeHTTPMethod(t *testing.T) {
tests := []struct {
name string
input string
output string
}{
{name: "get", input: "GET", output: "GET"},
{name: "lowercase", input: "post", output: "POST"},
{name: "trimmed", input: " put ", output: "PUT"},
{name: "empty", input: "", output: "UNKNOWN"},
{name: "custom", input: "GETT", output: "OTHER"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeHTTPMethod(tt.input); got != tt.output {
t.Fatalf("expected %q, got %q", tt.output, got)
}
})
}
}
func metricCounterValue(t *testing.T, vec *prometheus.CounterVec, labels ...string) float64 {
t.Helper()
m := &dto.Metric{}
if err := vec.WithLabelValues(labels...).Write(m); err != nil {
t.Fatalf("read counter metric: %v", err)
}
return m.GetCounter().GetValue()
}
func metricHistogramCount(t *testing.T, vec *prometheus.HistogramVec, labels ...string) uint64 {
t.Helper()
observer, err := vec.GetMetricWithLabelValues(labels...)
if err != nil {
t.Fatalf("load histogram metric: %v", err)
}
metric, ok := observer.(prometheus.Metric)
if !ok {
t.Fatalf("histogram observer does not implement prometheus.Metric")
}
m := &dto.Metric{}
if err := metric.Write(m); err != nil {
t.Fatalf("read histogram metric: %v", err)
}
if m.GetHistogram() == nil {
t.Fatalf("histogram metric not available for labels %v", labels)
}
return m.GetHistogram().GetSampleCount()
}

View File

@@ -0,0 +1,9 @@
package api
type PinProtectedEndpoint struct {
Method string
Path string
Action string
}
var PinProtectedEndpoints = []PinProtectedEndpoint{}

View File

@@ -0,0 +1,64 @@
package api
import (
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestPinProtectedEndpoints_Definition(t *testing.T) {
seen := map[string]struct{}{}
for _, ep := range PinProtectedEndpoints {
if ep.Method == "" || ep.Path == "" || ep.Action == "" {
t.Fatalf("invalid pin-protected endpoint: %#v", ep)
}
key := ep.Method + " " + ep.Path
if _, ok := seen[key]; ok {
t.Fatalf("duplicate pin-protected endpoint %s", key)
}
seen[key] = struct{}{}
}
}
func TestPinProtectedEndpoints_RoutesHavePinMiddleware(t *testing.T) {
if len(PinProtectedEndpoints) == 0 {
return
}
app := fiber.New()
RegisterRoutes(app, buildRouteDependenciesForTest())
routes := app.GetRoutes(true)
for _, ep := range PinProtectedEndpoints {
route := findRoute(routes, ep.Method, ep.Path)
if route == nil {
t.Fatalf("pin-protected route %s %s is not registered", ep.Method, ep.Path)
}
minHandlers := 3 // pin + permission + handler
if ep.Path == "/api/v1/auth/invite" {
minHandlers = 4 // auth + pin + permission + handler (no group auth middleware)
}
if len(route.Handlers) < minHandlers {
t.Fatalf("route %s %s expected at least %d handlers, got %d", ep.Method, ep.Path, minHandlers, len(route.Handlers))
}
}
}
func findRoute(routes []fiber.Route, method, path string) *fiber.Route {
for i := range routes {
if routes[i].Method == method && routes[i].Path == path {
return &routes[i]
}
}
return nil
}
func TestPinProtectedEndpoints_ExpectedMethods(t *testing.T) {
for _, ep := range PinProtectedEndpoints {
switch ep.Method {
case http.MethodGet, http.MethodPost, http.MethodPatch, http.MethodDelete:
default:
t.Fatalf("unexpected method %q on pin-protected endpoint %s", ep.Method, ep.Path)
}
}
}

140
internal/app/api/routes.go Normal file
View File

@@ -0,0 +1,140 @@
package api
import (
"github.com/gofiber/fiber/v2"
"github.com/valyala/fasthttp/fasthttpadaptor"
samlauth "wucher/internal/auth"
"wucher/internal/service"
"wucher/internal/shared/pkg/metrics"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
var metricsHandler = fasthttpadaptor.NewFastHTTPHandler(metrics.PromHTTPHandler())
type RouteServices struct {
Auth *service.AuthService
Audit *service.AuditLogService
WOPI *service.WOPIService
}
type RouteHandlers struct {
User *handlers.UserHandler
Contact *handlers.ContactHandler
Health *handlers.HealthHandler
BuildInfo *handlers.BuildInfoHandler
Auth *handlers.AuthHandler
Role *handlers.RoleHandler
Helicopter *handlers.HelicopterHandler
HelicopterUsage *handlers.HelicopterUsageHandler
HelicopterFile *handlers.HelicopterFileHandler
Flight *handlers.FlightHandler
FlightData *handlers.FlightDataHandler
Mission *handlers.MissionHandler
FMReport *handlers.FMReportHandler
ReserveAc *handlers.ReserveAcHandler
Takeover *handlers.TakeoverHandler
OtherPerson *handlers.OtherPersonHandler
Facility *handlers.FacilityHandler
Base *handlers.BaseHandler
Medicine *handlers.MedicineHandler
Vocation *handlers.VocationHandler
Opc *handlers.OpcHandler
ForcesPresent *handlers.ForcesPresentHandler
Hospital *handlers.HospitalHandler
HealthInsuranceCompanies *handlers.HealthInsuranceCompaniesHandler
FederalState *handlers.FederalStateHandler
ICAO *handlers.ICAOHandler
Land *handlers.LandHandler
MasterSettings *handlers.MasterSettingsHandler
Branding *handlers.BrandingHandler
FileManagerFolder *handlers.FileManagerFolderHandler
FileManagerFile *handlers.FileManagerFileHandler
FileManagerAttachment *handlers.FileManagerAttachmentHandler
FileManagerEvents *handlers.FileManagerEventsHandler
HelicopterFileEvents *handlers.HelicopterFileEventsHandler
DutyRoster *handlers.DutyRosterHandler
AirRescuerChecklist *handlers.AirRescuerChecklistHandler
InsurancePatientData *handlers.InsurancePatientDataHandler
PatientData *handlers.PatientDataHandler
HEMSOperationalData *handlers.HEMSOperationalDataHandler
HEMSOperationCategory *handlers.HEMSOperationCategoryHandler
HEMSOperation *handlers.HEMSOperationHandler
Complaint *handlers.ComplaintHandler
EASARelease *handlers.EASAReleaseHandler
ActionSignoff *handlers.ActionSignoffHandler
MCF *handlers.MCFHandler
DUL *handlers.DULHandler
FleetStatus *handlers.FleetStatusHandler
SAMLAuth *samlauth.Handler
}
type RouteDependencies struct {
Handlers RouteHandlers
Services RouteServices
}
func RegisterRoutes(app *fiber.App, deps RouteDependencies) {
h := deps.Handlers
s := deps.Services
app.Get("/health", h.Health.Handle)
app.Get("/version", h.BuildInfo.Handle)
app.Get("/api/version", h.BuildInfo.Handle)
app.Get("/api/build-info", h.BuildInfo.Handle)
app.Get("/metrics", func(c *fiber.Ctx) error {
metricsHandler(c.Context())
return nil
})
api := app.Group("/api")
api.Use(AuditMiddleware(s.Audit))
v1 := api.Group("/v1")
registerAuthRoutes(v1, h.Auth, s.Auth, h.SAMLAuth)
registerUserRoutes(v1, h.User, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerContactRoutes(v1, h.Contact, middleware.AuthRequired(s.Auth), s.Auth)
registerRoleRoutes(v1, h.Role, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterRoutes(v1, h.Helicopter, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterUsageRoutes(v1, h.HelicopterUsage, middleware.AuthRequired(s.Auth), s.Auth)
registerHelicopterFileRoutes(v1, h.HelicopterFile, h.HelicopterFileEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFlightRoutes(v1, h.Flight, middleware.AuthRequired(s.Auth), s.Auth)
registerFlightDataRoutes(v1, h.FlightData, middleware.AuthRequired(s.Auth), s.Auth)
registerMissionRoutes(v1, h.Mission, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFMReportRoutes(v1, h.FMReport, middleware.AuthRequired(s.Auth), s.Auth)
registerReserveAcRoutes(v1, h.ReserveAc, h.Takeover, middleware.AuthRequired(s.Auth), s.Auth)
registerTakeoverRoutes(v1, h.Takeover, middleware.AuthRequired(s.Auth), s.Auth)
registerOtherPersonRoutes(v1, h.OtherPerson, middleware.AuthRequired(s.Auth), s.Auth)
registerLastFlightsInspectionRoutes(v1, h.ReserveAc, middleware.AuthRequired(s.Auth), s.Auth)
registerFacilityRoutes(v1, h.Facility, middleware.AuthRequired(s.Auth), s.Auth)
registerBaseRoutes(v1, h.Base, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerMedicineRoutes(v1, h.Medicine, middleware.AuthRequired(s.Auth), s.Auth)
registerVocationRoutes(v1, h.Vocation, middleware.AuthRequired(s.Auth), s.Auth)
registerOpcRoutes(v1, h.Opc, middleware.AuthRequired(s.Auth), s.Auth)
registerForcesPresentRoutes(v1, h.ForcesPresent, middleware.AuthRequired(s.Auth), s.Auth)
registerHospitalRoutes(v1, h.Hospital, middleware.AuthRequired(s.Auth), s.Auth)
registerHealthInsuranceCompaniesRoutes(v1, h.HealthInsuranceCompanies, middleware.AuthRequired(s.Auth), s.Auth)
registerFederalStateRoutes(v1, h.FederalState, middleware.AuthRequired(s.Auth), s.Auth)
registerICAORoutes(v1, h.ICAO, middleware.AuthRequired(s.Auth), s.Auth)
registerLandRoutes(v1, h.Land, middleware.AuthRequired(s.Auth), s.Auth)
registerMasterSettingsRoutes(v1, h.MasterSettings, middleware.AuthRequired(s.Auth), s.Auth)
registerBrandingRoutes(v1, h.Branding)
registerFileManagerRoutes(v1, h.FileManagerFolder, h.FileManagerFile, h.FileManagerAttachment, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerWOPIRoutes(app, h.Takeover, s.WOPI)
registerDutyRosterRoutes(v1, h.DutyRoster, middleware.AuthRequired(s.Auth), s.Auth)
registerAirRescuerChecklistRoutes(v1, h.AirRescuerChecklist, middleware.AuthRequired(s.Auth), s.Auth)
registerInsurancePatientDataRoutes(v1, h.InsurancePatientData, middleware.AuthRequired(s.Auth), s.Auth)
registerPatientDataRoutes(v1, h.PatientData, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationalDataRoutes(v1, h.HEMSOperationalData, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationCategoryRoutes(v1, h.HEMSOperationCategory, middleware.AuthRequired(s.Auth), s.Auth)
registerHEMSOperationRoutes(v1, h.HEMSOperation, middleware.AuthRequired(s.Auth), s.Auth)
registerComplaintRoutes(v1, h.Complaint, middleware.AuthRequired(s.Auth), s.Auth)
registerEASAReleaseRoutes(v1, h.EASARelease, middleware.AuthRequired(s.Auth), s.Auth)
registerActionSignoffRoutes(v1, h.ActionSignoff, middleware.AuthRequired(s.Auth), s.Auth)
registerMCFRoutes(v1, h.MCF, middleware.AuthRequired(s.Auth), s.Auth)
registerDULRoutes(v1, h.DUL, h.FileManagerEvents, middleware.AuthRequired(s.Auth), s.Auth)
registerFleetStatusRoutes(v1, h.FleetStatus, middleware.AuthRequired(s.Auth), s.Auth)
registerAuditRoutes(v1, handlers.NewAuditHandler(s.Audit), middleware.AuthRequired(s.Auth), s.Auth)
registerSwaggerRoutes(app)
}

View File

@@ -0,0 +1,15 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerActionSignoffRoutes(v1 fiber.Router, h *handlers.ActionSignoffHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/action-signoffs", authMiddleware)
g.Get("/get-by-flight/:flight_id", middleware.PermissionRequired(authSvc, "action_signoff.read"), h.GetByFlight)
g.Post("/sign/:helicopter_id", middleware.PermissionRequired(authSvc, "action_signoff.sign"), h.Sign)
}

View File

@@ -0,0 +1,23 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAirRescuerChecklistRoutes(v1 fiber.Router, checklistHandler *handlers.AirRescuerChecklistHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
checklists := v1.Group("/air-rescuer-checklist", authMiddleware)
checklists.Post("/create", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.create"), checklistHandler.Create)
checklists.Get("/get-all", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.List)
checklists.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.ListDatatable)
checklists.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.read"), checklistHandler.Get)
checklists.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.Update)
checklists.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.delete"), checklistHandler.Delete)
checklists.Post("/:uuid/items/create", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.CreateItem)
checklists.Patch("/:uuid/items/reorder", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.ReorderItems)
checklists.Patch("/items/update/:item_uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.update"), checklistHandler.UpdateItem)
checklists.Delete("/items/delete/:item_uuid", middleware.PermissionRequired(authSvc, "air_rescuer_checklist.delete"), checklistHandler.DeleteItem)
}

View File

@@ -0,0 +1,14 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAuditRoutes(v1 fiber.Router, auditHandler *handlers.AuditHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
audits := v1.Group("/audit-logs", authMiddleware)
audits.Get("/get-all", middleware.PermissionRequiredReusable(authSvc, "audit.read"), auditHandler.List)
}

View File

@@ -0,0 +1,142 @@
package api
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/limiter"
samlauth "wucher/internal/auth"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerAuthRoutes(v1 fiber.Router, authHandler *handlers.AuthHandler, authSvc *service.AuthService, samlHandler *samlauth.Handler) {
auth := v1.Group("/auth")
auth.Post("/register", authHandler.Register)
auth.Post("/invite", middleware.AuthRequired(authSvc), middleware.PermissionRequired(authSvc, "user.create"), authHandler.Invite)
auth.Post("/invite/resend-set-password", middleware.AuthRequired(authSvc), middleware.PermissionRequired(authSvc, "user.update"), forgotPasswordIPLimiter(authSvc), authHandler.ResendSetPasswordInvite)
auth.Post("/set-password", authHandler.SetPassword)
auth.Post("/forgot-password", forgotPasswordIPLimiter(authSvc), authHandler.ForgotPassword)
auth.Post("/reset-password", authHandler.ResetPassword)
auth.Post("/pin/setup", middleware.AuthRequired(authSvc), authHandler.SetupSecurityPIN)
auth.Post("/pin/verify", middleware.AuthRequired(authSvc), authHandler.VerifySecurityPIN)
auth.Post("/pin/change", middleware.AuthRequired(authSvc), authHandler.ChangeSecurityPIN)
auth.Post("/pin/request-reset", middleware.AuthRequired(authSvc), forgotPasswordIPLimiter(authSvc), authHandler.RequestSecurityPINReset)
auth.Post("/pin/forgot", forgotPasswordIPLimiter(authSvc), authHandler.ForgotSecurityPIN)
auth.Post("/pin/reset", authHandler.ResetSecurityPIN)
auth.Post("/login", loginIPLimiter(authSvc), authHandler.Login)
auth.Post("/exchange", authHandler.Exchange)
auth.Post("/webauthn/register/begin", middleware.AuthRequired(authSvc), authHandler.WebAuthnRegisterBegin)
auth.Post("/webauthn/register/finish", middleware.AuthRequired(authSvc), authHandler.WebAuthnRegisterFinish)
auth.Post("/webauthn/login/begin", authHandler.WebAuthnLoginBegin)
auth.Post("/webauthn/login/finish", authHandler.WebAuthnLoginFinish)
auth.Post("/webauthn/reset-setup", middleware.AuthRequired(authSvc), authHandler.WebAuthnResetSetup)
auth.Post("/refresh", authHandler.Refresh)
auth.Post("/logout", authHandler.Logout)
auth.Get("/sessions", middleware.AuthRequired(authSvc), authHandler.Sessions)
auth.Delete("/sessions/:session_id", middleware.AuthRequired(authSvc), authHandler.DeleteSession)
auth.Get("/me", middleware.AuthRequired(authSvc), authHandler.Me)
auth.Patch("/me/timezone", middleware.AuthRequired(authSvc), authHandler.UpdateTimezone)
auth.Get("/verify-email", authHandler.VerifyEmail)
if samlHandler != nil {
auth.Get("/microsoft/login", samlHandler.HandleLogin)
auth.Get("/microsoft/metadata", samlHandler.HandleMetadata)
} else {
auth.Get("/microsoft/login", authHandler.MicrosoftLogin)
}
auth.Get("/microsoft/login-check", authHandler.MicrosoftLoginCheck)
auth.Get("/microsoft/link", middleware.AuthRequired(authSvc), authHandler.MicrosoftLink)
auth.Get("/microsoft/reauth", middleware.AuthRequired(authSvc), authHandler.MicrosoftReauth)
if samlHandler != nil {
auth.Get("/microsoft/callback", samlHandler.HandleSAMLCallback)
auth.Post("/microsoft/callback", samlHandler.HandleSAMLCallback)
auth.Get("/microsoft/logout", func(c *fiber.Ctx) error {
if c.Query("SAMLRequest") != "" {
return samlHandler.HandleSAMLLogoutRequest(c)
}
if c.Query("SAMLResponse") != "" {
return samlHandler.HandleSAMLLogoutResponse(c)
}
return samlHandler.HandleBrowserLocalLogout(c)
})
auth.Post("/microsoft/logout", func(c *fiber.Ctx) error {
if c.FormValue("SAMLRequest") != "" {
return samlHandler.HandleSAMLLogoutRequest(c)
}
if c.FormValue("SAMLResponse") != "" {
return samlHandler.HandleSAMLLogoutResponse(c)
}
return samlHandler.HandleBrowserLocalLogout(c)
})
} else {
auth.Get("/microsoft/callback", authHandler.MicrosoftCallback)
auth.Post("/microsoft/logout", middleware.AuthRequired(authSvc), authHandler.MicrosoftLogout)
}
// OIDC front-channel logout: invoked by Microsoft Entra in a hidden iframe when the
// user signs out at the IdP. Must be public (the IdP has no app credentials) and
// available regardless of the active Microsoft auth mode.
auth.Get("/microsoft/front-channel-logout", authHandler.MicrosoftFrontChannelLogout)
auth.Post("/microsoft/front-channel-logout", authHandler.MicrosoftFrontChannelLogout)
auth.Post("/microsoft/refresh", middleware.AuthRequired(authSvc), authHandler.MicrosoftRefresh)
auth.Post("/sso/link", middleware.AuthRequired(authSvc), authHandler.SSOLink)
auth.Delete("/sso/unlink", middleware.AuthRequired(authSvc), authHandler.SSOUnlink)
auth.Post("/totp/setup", middleware.AuthRequired(authSvc), authHandler.TOTPSetup)
auth.Post("/totp/confirm", middleware.AuthRequired(authSvc), authHandler.TOTPConfirm)
auth.Post("/totp/verify", authHandler.TOTPVerify)
auth.Post("/email-otp/send", authHandler.SendEmailOTP)
auth.Post("/email-otp/verify", authHandler.VerifyEmailOTP)
auth.Post("/totp/disable", middleware.AuthRequired(authSvc), authHandler.TOTPDisable)
auth.Post("/totp/reset-setup", middleware.AuthRequired(authSvc), authHandler.TOTPResetSetup)
}
func forgotPasswordIPLimiter(authSvc *service.AuthService) fiber.Handler {
window := authSvc.ForgotPasswordIPRateWindow()
return limiter.New(limiter.Config{
Max: authSvc.ForgotPasswordIPRateMax(),
Expiration: window,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
})
}
func loginIPLimiter(authSvc *service.AuthService) fiber.Handler {
window := authSvc.LoginIPRateWindow()
return limiter.New(limiter.Config{
Max: authSvc.LoginIPRateMax(),
Expiration: window,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
LimitReached: func(c *fiber.Ctx) error {
retryAfter := int(window.Seconds())
if retryAfter < 1 {
retryAfter = 1
}
c.Set(fiber.HeaderRetryAfter, strconv.Itoa(retryAfter))
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"errors": []fiber.Map{{
"status": "429",
"title": "Too Many Requests",
"detail": "rate limit exceeded",
}},
})
},
})
}

View File

@@ -0,0 +1,154 @@
package api
import (
"net/http"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/config"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
)
func TestForgotPasswordIPLimiter(t *testing.T) {
cfg := config.AuthConfig{
ForgotPasswordIPRateMax: 1,
ForgotPasswordIPRateWindow: time.Minute,
}
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
app := fiber.New()
app.Post("/forgot", forgotPasswordIPLimiter(svc), func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req1, _ := http.NewRequest(http.MethodPost, "/forgot", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/forgot", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", resp2.StatusCode)
}
}
func TestLoginIPLimiter(t *testing.T) {
cfg := config.AuthConfig{
LoginIPRateMax: 1,
LoginIPRateWindow: time.Minute,
}
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: cfg})
app := fiber.New()
app.Post("/login", loginIPLimiter(svc), func(c *fiber.Ctx) error {
return c.SendStatus(http.StatusOK)
})
req1, _ := http.NewRequest(http.MethodPost, "/login", nil)
resp1, err := app.Test(req1)
if err != nil {
t.Fatalf("first request failed: %v", err)
}
if resp1.StatusCode != http.StatusOK {
t.Fatalf("expected first request 200, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest(http.MethodPost, "/login", nil)
resp2, err := app.Test(req2)
if err != nil {
t.Fatalf("second request failed: %v", err)
}
if resp2.StatusCode != http.StatusTooManyRequests {
t.Fatalf("expected second request 429, got %d", resp2.StatusCode)
}
}
func TestRegisterAuthRoutes(t *testing.T) {
app := fiber.New()
v1 := app.Group("/api/v1")
svc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
authHandler := handlers.NewAuthHandler(svc)
registerAuthRoutes(v1, authHandler, svc, nil)
routes := app.GetRoutes(true)
required := []struct {
method string
path string
}{
{method: http.MethodPost, path: "/api/v1/auth/register"},
{method: http.MethodPost, path: "/api/v1/auth/invite"},
{method: http.MethodPost, path: "/api/v1/auth/invite/resend-set-password"},
{method: http.MethodPost, path: "/api/v1/auth/forgot-password"},
{method: http.MethodPost, path: "/api/v1/auth/reset-password"},
{method: http.MethodPost, path: "/api/v1/auth/pin/setup"},
{method: http.MethodPost, path: "/api/v1/auth/pin/verify"},
{method: http.MethodPost, path: "/api/v1/auth/pin/change"},
{method: http.MethodPost, path: "/api/v1/auth/pin/request-reset"},
{method: http.MethodPost, path: "/api/v1/auth/pin/forgot"},
{method: http.MethodPost, path: "/api/v1/auth/pin/reset"},
{method: http.MethodPost, path: "/api/v1/auth/login"},
{method: http.MethodPost, path: "/api/v1/auth/exchange"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/register/begin"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/register/finish"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/login/begin"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/login/finish"},
{method: http.MethodPost, path: "/api/v1/auth/webauthn/reset-setup"},
{method: http.MethodPost, path: "/api/v1/auth/refresh"},
{method: http.MethodGet, path: "/api/v1/auth/sessions"},
{method: http.MethodDelete, path: "/api/v1/auth/sessions/:session_id"},
{method: http.MethodGet, path: "/api/v1/auth/me"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/login"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/login-check"},
{method: http.MethodGet, path: "/api/v1/auth/microsoft/link"},
{method: http.MethodPost, path: "/api/v1/auth/sso/link"},
{method: http.MethodDelete, path: "/api/v1/auth/sso/unlink"},
{method: http.MethodPost, path: "/api/v1/auth/totp/verify"},
{method: http.MethodPost, path: "/api/v1/auth/totp/reset-setup"},
}
for _, tc := range required {
if !hasRoute(routes, tc.method, tc.path) {
t.Fatalf("route %s %s not registered", tc.method, tc.path)
}
}
var forgotRoute *fiber.Route
for i := range routes {
if routes[i].Method == http.MethodPost && routes[i].Path == "/api/v1/auth/forgot-password" {
forgotRoute = &routes[i]
break
}
}
if forgotRoute == nil {
t.Fatalf("forgot password route not found")
}
if len(forgotRoute.Handlers) < 2 {
t.Fatalf("expected forgot password route to have limiter and handler, got %d handlers", len(forgotRoute.Handlers))
}
var loginRoute *fiber.Route
for i := range routes {
if routes[i].Method == http.MethodPost && routes[i].Path == "/api/v1/auth/login" {
loginRoute = &routes[i]
break
}
}
if loginRoute == nil {
t.Fatalf("login route not found")
}
if len(loginRoute.Handlers) < 2 {
t.Fatalf("expected login route to have limiter and handler, got %d handlers", len(loginRoute.Handlers))
}
}

View File

@@ -0,0 +1,35 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerBaseRoutes(
v1 fiber.Router,
baseHandler *handlers.BaseHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
bases := v1.Group("/bases", authMiddleware)
bases.Get("/events", middleware.PermissionRequired(authSvc, "base.read"), eventsHandler.Stream)
bases.Post("/create",
middleware.PermissionRequired(authSvc, "base.create"),
baseHandler.CreateBase,
)
bases.Get("/get-all", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.ListBases)
bases.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.ListBasesDatatable)
bases.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "base.read"), baseHandler.GetBase)
bases.Patch("/update/:uuid",
middleware.PermissionRequired(authSvc, "base.update"),
baseHandler.UpdateBase,
)
bases.Delete("/delete/:uuid",
middleware.PermissionRequired(authSvc, "base.delete"),
baseHandler.DeleteBase,
)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/transport/http/handlers"
)
func registerBrandingRoutes(v1 fiber.Router, brandingHandler *handlers.BrandingHandler) {
if brandingHandler == nil {
return
}
branding := v1.Group("/branding")
branding.Get("/logo", brandingHandler.GetLogo)
branding.Get("/login-cover", brandingHandler.GetLoginCover)
branding.Get("/login-content", brandingHandler.GetLoginContent)
publicBranding := v1.Group("/public/branding")
publicBranding.Get("/email-logo", brandingHandler.GetEmailLogo)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerComplaintRoutes(v1 fiber.Router, h *handlers.ComplaintHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
complaints := v1.Group("/complaints", authMiddleware)
complaints.Post("/create", middleware.PermissionRequired(authSvc, "complaint.create"), h.Create)
complaints.Get("/get-all", middleware.PermissionRequired(authSvc, "complaint.read"), h.List)
complaints.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListDatatable)
complaints.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListByHelicopter)
complaints.Get("/hold-items/get-all", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListHoldItems)
complaints.Get("/hold-items/get-all/dt", middleware.PermissionRequired(authSvc, "complaint.read"), h.ListHoldItemsDatatable)
complaints.Get("/get/:id", middleware.PermissionRequired(authSvc, "complaint.read"), h.GetByID)
complaints.Patch("/update/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.Update)
complaints.Post("/action-taken/create/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.ActionTaken)
complaints.Patch("/action-taken/update/:id", middleware.PermissionRequired(authSvc, "complaint.update"), h.UpdateActionTaken)
complaints.Post("/nsr/sign/:id", middleware.PermissionRequired(authSvc, "complaint.nsr"), h.NSRSign)
complaints.Post("/nsr/unsign/:id", middleware.PermissionRequired(authSvc, "complaint.nsr"), h.NSRUnsign)
complaints.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "complaint.delete"), h.Delete)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerContactRoutes(v1 fiber.Router, contactHandler *handlers.ContactHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
contacts := v1.Group("/contacts", authMiddleware)
contacts.Post("/create", middleware.PermissionRequired(authSvc, "user.create"), contactHandler.Create)
contacts.Get("/get-all", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.List)
contacts.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.ListDatatable)
contacts.Get("/:user_id", middleware.PermissionRequired(authSvc, "user.read"), contactHandler.Get)
contacts.Patch("/:user_id", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.Update)
contacts.Delete("/:user_id", middleware.PermissionRequired(authSvc, "user.delete"), contactHandler.Delete)
contacts.Patch("/:user_id/status", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateContactStatus)
contacts.Patch("/:user_id/role", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateContactRole)
contacts.Patch("/:user_id/pilot-category", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdatePilotCategory)
contacts.Patch("/:user_id/license", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateLicense)
contacts.Patch("/:user_id/chief-flags", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.UpdateChiefFlags)
contacts.Post("/bulk-update", middleware.PermissionRequired(authSvc, "user.update"), contactHandler.BulkUpdate)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerDULRoutes(
v1 fiber.Router,
dulHandler *handlers.DULHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
dul := v1.Group("/dul", authMiddleware)
dul.Get("/events", middleware.PermissionRequired(authSvc, "dul.read"), eventsHandler.Stream)
dul.Post("/create", middleware.PermissionRequired(authSvc, "dul.create"), dulHandler.Create)
dul.Get("/get-all", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.List)
dul.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.ListDatatable)
dul.Get("/get/:id", middleware.PermissionRequired(authSvc, "dul.read"), dulHandler.Get)
dul.Patch("/update/:id", middleware.PermissionRequired(authSvc, "dul.update"), dulHandler.Update)
dul.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "dul.delete"), dulHandler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerDutyRosterRoutes(v1 fiber.Router, rosterHandler *handlers.DutyRosterHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
dutyRosters := v1.Group("/duty-roster", authMiddleware)
dutyRosters.Post("/create", middleware.PermissionRequired(authSvc, "duty_roster.create"), rosterHandler.CreateAny)
dutyRosters.Get("/get-all", middleware.PermissionRequired(authSvc, "duty_roster.read"), rosterHandler.GetAllRosters)
dutyRosters.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "duty_roster.read"), rosterHandler.ListAnyDatatable)
dutyRosters.Patch("/update", middleware.PermissionRequired(authSvc, "duty_roster.update"), rosterHandler.UpdateAny)
dutyRosters.Delete("/delete", middleware.PermissionRequired(authSvc, "duty_roster.delete"), rosterHandler.DeleteAny)
dutyRosters.Delete("/crew", middleware.PermissionRequired(authSvc, "duty_roster.delete"), rosterHandler.DeleteAnyCrew)
}

View File

@@ -0,0 +1,21 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerEASAReleaseRoutes(v1 fiber.Router, h *handlers.EASAReleaseHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/easa-releases", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "easa_release.create"), h.Create)
g.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.ListByHelicopter)
g.Get("/get-by-flight/:flight_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByFlight)
g.Get("/get-by-complaint/:complaint_id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByComplaint)
g.Get("/get/:id", middleware.PermissionRequired(authSvc, "easa_release.read"), h.GetByID)
g.Patch("/update/:id", middleware.PermissionRequired(authSvc, "easa_release.update"), h.Update)
g.Post("/sign/:id", middleware.PermissionRequired(authSvc, "easa_release.sign"), h.Sign)
g.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "easa_release.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFacilityRoutes(v1 fiber.Router, facilityHandler *handlers.FacilityHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
facilities := v1.Group("/facilities", authMiddleware)
facilities.Post("/create", middleware.PermissionRequired(authSvc, "facility.create"), facilityHandler.Create)
facilities.Get("/get-all", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.List)
facilities.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.ListDatatable)
facilities.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "facility.read"), facilityHandler.Get)
facilities.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "facility.update"), facilityHandler.Update)
facilities.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "facility.delete"), facilityHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFederalStateRoutes(v1 fiber.Router, federalStateHandler *handlers.FederalStateHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
federalState := v1.Group("/federal-state", authMiddleware)
federalState.Post("/create", middleware.PermissionRequired(authSvc, "federal_state.create"), federalStateHandler.Create)
federalState.Get("/get-all", middleware.PermissionRequired(authSvc, "federal_state.read"), federalStateHandler.List)
federalState.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "federal_state.read"), federalStateHandler.ListDatatable)
federalState.Patch("/update/:id", middleware.PermissionRequired(authSvc, "federal_state.update"), federalStateHandler.Update)
federalState.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "federal_state.delete"), federalStateHandler.Delete)
}

View File

@@ -0,0 +1,62 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFileManagerRoutes(
v1 fiber.Router,
folderHandler *handlers.FileManagerFolderHandler,
fileHandler *handlers.FileManagerFileHandler,
attachmentHandler *handlers.FileManagerAttachmentHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
fm := v1.Group("/file-manager", authMiddleware)
fm.Get("/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.ListAll)
fm.Get("/events", middleware.PermissionRequired(authSvc, "file_manager.read"), eventsHandler.Stream)
fm.Get("/trash/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.ListTrashItems)
fm.Patch("/move", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.MoveNodes)
fm.Delete("/delete", middleware.PermissionRequired(authSvc, "file_manager.delete"), folderHandler.DeleteNodesBulk)
fm.Patch("/restore", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.RestoreNodesBulk)
fm.Delete("/purge-delete", middleware.PermissionRequired(authSvc, "file_manager.delete"), folderHandler.PurgeNodesBulk)
folders := fm.Group("/folders")
folders.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), folderHandler.CreateFolder)
folders.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), folderHandler.GetFolder)
folders.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "file_manager.update"), folderHandler.UpdateFolder)
files := fm.Group("/files")
files.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFile)
files.Post("/create-from-template", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFromTemplate)
files.Post("/create-aircraft-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateAircraftImage)
files.Post("/create-base-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateBaseImage)
files.Post("/create-dul-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateDULImage)
files.Post("/create-user-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateUserImage)
files.Post("/create-takeover-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateTakeoverFile)
files.Get("/:fileId/edit-session", middleware.PermissionRequired(authSvc, "file_manager.read"), fileHandler.EditSession)
files.Post("/create-mission-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateMissionFile)
files.Post("/upload", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadFile)
files.Post("/upload-aircraft-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadAircraftImage)
files.Post("/upload-base-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadBaseImage)
files.Post("/upload-dul-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadDULImage)
files.Post("/upload-user-image", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadUserImage)
files.Post("/upload-takeover-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadTakeoverFile)
files.Post("/upload-mission-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadMissionFile)
files.Post("/upload-fleet-status-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.UploadFleetStatusFile)
files.Post("/create-fleet-status-file", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CreateFleetStatusFile)
files.Delete("/cancel-upload", middleware.PermissionRequired(authSvc, "file_manager.create"), fileHandler.CancelUpload)
files.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), fileHandler.GetFile)
files.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "file_manager.update"), fileHandler.UpdateFile)
attachments := fm.Group("/attachments")
attachments.Post("/create", middleware.PermissionRequired(authSvc, "file_manager.create"), attachmentHandler.CreateAttachment)
attachments.Get("/get-all", middleware.PermissionRequired(authSvc, "file_manager.read"), attachmentHandler.ListAttachments)
attachments.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "file_manager.read"), attachmentHandler.GetAttachment)
attachments.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "file_manager.delete"), attachmentHandler.DeleteAttachment)
}

View File

@@ -0,0 +1,22 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFleetStatusRoutes(v1 fiber.Router, h *handlers.FleetStatusHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/fleet-status", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), h.Create)
g.Get("/get/:id", middleware.PermissionRequired(authSvc, "flight.read"), h.GetByID)
g.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "flight.read"), h.GetByHelicopterID)
g.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), h.List)
g.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), h.ListDatatable)
g.Get("/history/:helicopter_id", middleware.PermissionRequired(authSvc, "flight.read"), h.ListHistoryByHelicopter)
g.Post("/mark-serviced/:id", middleware.PermissionRequired(authSvc, "flight.update"), h.MarkServiced)
g.Patch("/update/:id", middleware.PermissionRequired(authSvc, "flight.update"), h.Update)
g.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "flight.delete"), h.Delete)
}

View File

@@ -0,0 +1,15 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFlightRoutes(v1 fiber.Router, flightHandler *handlers.FlightHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
flights := v1.Group("/flights", authMiddleware)
flights.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightHandler.List)
flights.Get("/get", middleware.PermissionRequired(authSvc, "flight.read"), flightHandler.GetByAuthUser)
}

View File

@@ -0,0 +1,24 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFlightDataRoutes(v1 fiber.Router, flightDataHandler *handlers.FlightDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
flightData := v1.Group("/flight-data", authMiddleware)
flightData.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), flightDataHandler.Create)
flightData.Get("/get/:id", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.Get)
flightData.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.Get)
flightData.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.List)
flightData.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.ListDatatable)
flightData.Patch("/update/:id", middleware.PermissionRequired(authSvc, "flight.update"), flightDataHandler.Update)
flightData.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "flight.delete"), flightDataHandler.Delete)
flightsData := v1.Group("/flights-data", authMiddleware)
flightsData.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.List)
flightsData.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), flightDataHandler.ListDatatable)
}

View File

@@ -0,0 +1,22 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
sharedconst "wucher/internal/shared/const"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerFMReportRoutes(v1 fiber.Router, fmReportHandler *handlers.FMReportHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
fmReports := v1.Group("/fm-reports", authMiddleware)
fmReports.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), fmReportHandler.List)
fmReports.Get("/get/:flight_id", middleware.PermissionRequired(authSvc, "flight.read"), fmReportHandler.GetByFlightID)
fmReports.Patch("/update/:flight_id", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.UpdateByFlightID)
fmReports.Post("/complete/:flight_id", middleware.PermissionRequired(authSvc, "flight.complete"), fmReportHandler.Complete)
fmReports.Post("/:id/attachments/upload-intent", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.RequestAttachmentUploadIntent)
fmReports.Post("/:id/attachments", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.UploadAttachment)
fmReports.Delete("/:id/attachments/:att_id", middleware.PermissionRequired(authSvc, "flight.update"), fmReportHandler.DeleteAttachment)
fmReports.Delete("/:id", middleware.PermissionRequired(authSvc, "flight.delete"), middleware.PinRequired(authSvc, sharedconst.PinActionFmReportDelete), fmReportHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerForcesPresentRoutes(v1 fiber.Router, forcesPresentHandler *handlers.ForcesPresentHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
forcesPresent := v1.Group("/forces-present", authMiddleware)
forcesPresent.Post("/create", middleware.PermissionRequired(authSvc, "forces_present.create"), forcesPresentHandler.Create)
forcesPresent.Get("/get-all", middleware.PermissionRequired(authSvc, "forces_present.read"), forcesPresentHandler.List)
forcesPresent.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "forces_present.read"), forcesPresentHandler.ListDatatable)
forcesPresent.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "forces_present.update"), forcesPresentHandler.Update)
forcesPresent.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "forces_present.delete"), forcesPresentHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHealthInsuranceCompaniesRoutes(v1 fiber.Router, healthInsuranceCompaniesHandler *handlers.HealthInsuranceCompaniesHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
hic := v1.Group("/health-insurance-companies", authMiddleware)
hic.Post("/create", middleware.PermissionRequired(authSvc, "health_insurance_companies.create"), healthInsuranceCompaniesHandler.Create)
hic.Get("/get-all", middleware.PermissionRequired(authSvc, "health_insurance_companies.read"), healthInsuranceCompaniesHandler.List)
hic.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "health_insurance_companies.read"), healthInsuranceCompaniesHandler.ListDatatable)
hic.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "health_insurance_companies.update"), healthInsuranceCompaniesHandler.Update)
hic.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "health_insurance_companies.delete"), healthInsuranceCompaniesHandler.Delete)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterRoutes(v1 fiber.Router, helicopterHandler *handlers.HelicopterHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
helicopters := v1.Group("/helicopters", authMiddleware)
helicopters.Post("/create", middleware.PermissionRequired(authSvc, "helicopter.create"), helicopterHandler.Create)
helicopters.Post("/:id/reports/next-number", middleware.PermissionRequired(authSvc, "helicopter.report.generate"), helicopterHandler.NextReportNumber)
helicopters.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.List)
helicopters.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.ListDatatable)
helicopters.Get("/get/:id", middleware.PermissionRequired(authSvc, "helicopter.read"), helicopterHandler.Get)
helicopters.Patch("/update/:id", middleware.PermissionRequired(authSvc, "helicopter.update"), helicopterHandler.Update)
helicopters.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "helicopter.delete"), helicopterHandler.Delete)
}

View File

@@ -0,0 +1,27 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterFileRoutes(
v1 fiber.Router,
handler *handlers.HelicopterFileHandler,
eventsHandler *handlers.HelicopterFileEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
files := v1.Group("/helicopter-files", authMiddleware)
files.Get("/events", middleware.PermissionRequired(authSvc, "helicopter.read"), eventsHandler.Stream)
files.Post("/upload", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Upload)
files.Post("/cancel-upload", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.CancelUpload)
files.Post("/create", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Create)
files.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter.read"), handler.List)
files.Get("/get/:helicopter_id", middleware.PermissionRequired(authSvc, "helicopter.read"), handler.Get)
files.Patch("/update", middleware.PermissionRequired(authSvc, "helicopter.update"), handler.Update)
files.Delete("/delete", middleware.PermissionRequired(authSvc, "helicopter.delete"), handler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHelicopterUsageRoutes(v1 fiber.Router, helicopterUsageHandler *handlers.HelicopterUsageHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
helicopterUsage := v1.Group("/helicopter-usage", authMiddleware)
helicopterUsage.Post("/create", middleware.PermissionRequired(authSvc, "helicopter_usage.create"), helicopterUsageHandler.Create)
helicopterUsage.Get("/get-all", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.List)
helicopterUsage.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.ListDatatable)
helicopterUsage.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.read"), helicopterUsageHandler.Get)
helicopterUsage.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.update"), helicopterUsageHandler.Update)
helicopterUsage.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "helicopter_usage.delete"), helicopterUsageHandler.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationRoutes(v1 fiber.Router, h *handlers.HEMSOperationHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operation", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "hems_operation.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "hems_operation.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationCategoryRoutes(v1 fiber.Router, h *handlers.HEMSOperationCategoryHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operation-category", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "operation_category.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "operation_category.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "operation_category.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "operation_category.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "operation_category.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "operation_category.delete"), h.Delete)
}

View File

@@ -0,0 +1,19 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHEMSOperationalDataRoutes(v1 fiber.Router, h *handlers.HEMSOperationalDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
r := v1.Group("/hems-operational-data", authMiddleware)
r.Post("/create", middleware.PermissionRequired(authSvc, "operation.create"), h.Create)
r.Get("/get-all", middleware.PermissionRequired(authSvc, "operation.read"), h.List)
r.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "operation.read"), h.ListDatatable)
r.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "operation.read"), h.Get)
r.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "operation.update"), h.Update)
r.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "operation.delete"), h.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerHospitalRoutes(v1 fiber.Router, hospitalHandler *handlers.HospitalHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
hospital := v1.Group("/hospital", authMiddleware)
hospital.Post("/create", middleware.PermissionRequired(authSvc, "no_icao_code.create"), hospitalHandler.Create)
hospital.Get("/get-all", middleware.PermissionRequired(authSvc, "no_icao_code.read"), hospitalHandler.List)
hospital.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "no_icao_code.read"), hospitalHandler.ListDatatable)
hospital.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "no_icao_code.update"), hospitalHandler.Update)
hospital.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "no_icao_code.delete"), hospitalHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerICAORoutes(v1 fiber.Router, icaoHandler *handlers.ICAOHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
icao := v1.Group("/icao", authMiddleware)
icao.Post("/create", middleware.PermissionRequired(authSvc, "icao.create"), icaoHandler.Create)
icao.Get("/get-all", middleware.PermissionRequired(authSvc, "icao.read"), icaoHandler.List)
icao.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "icao.read"), icaoHandler.ListDatatable)
icao.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "icao.update"), icaoHandler.Update)
icao.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "icao.delete"), icaoHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerLandRoutes(v1 fiber.Router, landHandler *handlers.LandHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
land := v1.Group("/land", authMiddleware)
land.Post("/create", middleware.PermissionRequired(authSvc, "country.create"), landHandler.Create)
land.Get("/get-all", middleware.PermissionRequired(authSvc, "country.read"), landHandler.List)
land.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "country.read"), landHandler.ListDatatable)
land.Patch("/update/:id", middleware.PermissionRequired(authSvc, "country.update"), landHandler.Update)
land.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "country.delete"), landHandler.Delete)
}

View File

@@ -0,0 +1,17 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerLastFlightsInspectionRoutes(v1 fiber.Router, reserveAcHandler *handlers.ReserveAcHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
lastFlightsInspection := v1.Group("/after-flight-inspection", authMiddleware)
lastFlightsInspection.Get("/get", middleware.PermissionRequired(authSvc, "after_flight.read"), reserveAcHandler.GetLastFlightsInspection)
lastFlightsInspection.Post("/create", middleware.PermissionRequired(authSvc, "after_flight.create"), reserveAcHandler.CreateLastFlightsInspection)
lastFlightsInspection.Patch("/update", middleware.PermissionRequired(authSvc, "after_flight.update"), reserveAcHandler.UpdateLastFlightsInspection)
lastFlightsInspection.Delete("/delete", middleware.PermissionRequired(authSvc, "after_flight.delete"), reserveAcHandler.DeleteLastFlightsInspection)
}

View File

@@ -0,0 +1,21 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerMasterSettingsRoutes(v1 fiber.Router, masterSettingsHandler *handlers.MasterSettingsHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
masterSettingsPublic := v1.Group("/master-settings")
masterSettingsPublic.Get("/get", masterSettingsHandler.Get)
masterSettings := v1.Group("/master-settings", authMiddleware)
masterSettings.Post("/create", middleware.PermissionRequired(authSvc, "master_settings.create"), masterSettingsHandler.Create)
masterSettings.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "master_settings.update"), masterSettingsHandler.Update)
masterSettings.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "master_settings.delete"), masterSettingsHandler.Delete)
masterSettings.Get("/sso/microsoft", middleware.PermissionRequired(authSvc, "master_settings.update"), masterSettingsHandler.GetMicrosoftEntraSSO)
masterSettings.Put("/sso/microsoft", middleware.PermissionRequired(authSvc, "master_settings.update"), masterSettingsHandler.UpsertMicrosoftEntraSSO)
}

View File

@@ -0,0 +1,17 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerMCFRoutes(v1 fiber.Router, h *handlers.MCFHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/mcf", authMiddleware)
g.Post("/set/:helicopter_id", middleware.PermissionRequired(authSvc, "mcf.create"), h.Set)
g.Get("/get-by-helicopter/:helicopter_id", middleware.PermissionRequired(authSvc, "mcf.read"), h.ListByHelicopter)
g.Get("/get/:id", middleware.PermissionRequired(authSvc, "mcf.read"), h.GetByID)
g.Post("/sign/:id", middleware.PermissionRequired(authSvc, "mcf.complete"), h.Sign)
}

View File

@@ -0,0 +1,32 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerMedicineRoutes(v1 fiber.Router, medicineHandler *handlers.MedicineHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
medicine := v1.Group("/medicine", authMiddleware)
medicine.Post("/create", middleware.PermissionRequired(authSvc, "medicine.create"), medicineHandler.Create)
medicine.Get("/get-all", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.List)
medicine.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListDatatable)
medicine.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "medicine.update"), medicineHandler.Update)
medicine.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "medicine.delete"), medicineHandler.Delete)
medicineGroup := v1.Group("/medicine-group", authMiddleware)
medicineGroup.Post("/create", middleware.PermissionRequired(authSvc, "medicine.create"), medicineHandler.CreateGroup)
medicineGroup.Get("/get-all", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListGroups)
medicineGroup.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListGroupsDatatable)
medicineGroup.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "medicine.update"), medicineHandler.UpdateGroup)
medicineGroup.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "medicine.delete"), medicineHandler.DeleteGroup)
motorReaction := v1.Group("/motor-reaction", authMiddleware)
motorReaction.Post("/create", middleware.PermissionRequired(authSvc, "medicine.create"), medicineHandler.CreateReaction)
motorReaction.Get("/get-all", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListReactions)
motorReaction.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "medicine.read"), medicineHandler.ListReactionsDatatable)
motorReaction.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "medicine.update"), medicineHandler.UpdateReaction)
motorReaction.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "medicine.delete"), medicineHandler.DeleteReaction)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerMissionRoutes(v1 fiber.Router, missionHandler *handlers.MissionHandler, eventsHandler *handlers.FileManagerEventsHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
mission := v1.Group("/mission", authMiddleware)
mission.Get("/events", middleware.PermissionRequired(authSvc, "flight.read"), eventsHandler.Stream)
mission.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), missionHandler.Create)
mission.Patch("/update/:mission_id", middleware.PermissionRequired(authSvc, "flight.update"), missionHandler.Update)
mission.Delete("/file/:mission_id/:attachment_id", middleware.PermissionRequired(authSvc, "flight.update"), missionHandler.RemoveFile)
mission.Delete("/delete/:flight_id", middleware.PermissionRequired(authSvc, "flight.delete"), missionHandler.Delete)
mission.Delete("/delete-by-id/:mission_id", middleware.PermissionRequired(authSvc, "flight.delete"), missionHandler.DeleteByID)
mission.Get("/get/:flight_id", middleware.PermissionRequired(authSvc, "flight.read"), missionHandler.GetByFlightID)
mission.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), missionHandler.List)
mission.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "flight.read"), missionHandler.ListDatatable)
missionType := v1.Group("/mission-type", authMiddleware)
missionType.Post("/create", middleware.PermissionRequired(authSvc, "flight.create"), missionHandler.CreateType)
missionType.Get("/get-all", middleware.PermissionRequired(authSvc, "flight.read"), missionHandler.ListTypes)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerOpcRoutes(v1 fiber.Router, opcHandler *handlers.OpcHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
opc := v1.Group("/opc", authMiddleware)
opc.Post("/create", middleware.PermissionRequired(authSvc, "opc.create"), opcHandler.Create)
opc.Get("/get-all", middleware.PermissionRequired(authSvc, "opc.read"), opcHandler.List)
opc.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "opc.read"), opcHandler.ListDatatable)
opc.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "opc.update"), opcHandler.Update)
opc.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "opc.delete"), opcHandler.Delete)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
// registerOtherPersonRoutes exposes the reusable, NON-STAFF "other person" master under
// /contact/other. It is kept separate from /contacts (staff) and /takeovers on purpose.
func registerOtherPersonRoutes(v1 fiber.Router, otherPersonHandler *handlers.OtherPersonHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
other := v1.Group("/contact/other", authMiddleware)
other.Get("/", middleware.PermissionRequired(authSvc, "reserve_ac.read"), otherPersonHandler.Search)
other.Get("/get/:id", middleware.PermissionRequired(authSvc, "reserve_ac.read"), otherPersonHandler.Get)
other.Post("/create", middleware.PermissionRequired(authSvc, "reserve_ac.create"), otherPersonHandler.Create)
other.Patch("/update/:id", middleware.PermissionRequired(authSvc, "reserve_ac.update"), otherPersonHandler.Update)
other.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "reserve_ac.delete"), otherPersonHandler.Delete)
}

View File

@@ -0,0 +1,29 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerInsurancePatientDataRoutes(v1 fiber.Router, h *handlers.InsurancePatientDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/insurance-patient-data", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "insurance_patient_data.create"), h.Create)
g.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "insurance_patient_data.read"), h.Get)
g.Get("/get-all", middleware.PermissionRequired(authSvc, "insurance_patient_data.read"), h.List)
g.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "insurance_patient_data.read"), h.ListDatatable)
g.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "insurance_patient_data.update"), h.Update)
g.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "insurance_patient_data.delete"), h.Delete)
}
func registerPatientDataRoutes(v1 fiber.Router, h *handlers.PatientDataHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
g := v1.Group("/patient-data", authMiddleware)
g.Post("/create", middleware.PermissionRequired(authSvc, "patient_data.create"), h.Create)
g.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "patient_data.read"), h.Get)
g.Get("/get-all", middleware.PermissionRequired(authSvc, "patient_data.read"), h.List)
g.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "patient_data.read"), h.ListDatatable)
g.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "patient_data.update"), h.Update)
g.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "patient_data.delete"), h.Delete)
}

View File

@@ -0,0 +1,20 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerReserveAcRoutes(v1 fiber.Router, reserveAcHandler *handlers.ReserveAcHandler, takeoverHandler *handlers.TakeoverHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
reserveAcs := v1.Group("/reserve-acs", authMiddleware)
reserveAcs.Post("/takeovers/create", middleware.PermissionRequired(authSvc, "reserve_ac.create"), takeoverHandler.CreateTakeover)
reserveAcs.Post("/create", middleware.PermissionRequired(authSvc, "reserve_ac.create"), reserveAcHandler.Create)
reserveAcs.Get("/get-all", middleware.PermissionRequired(authSvc, "reserve_ac.read"), reserveAcHandler.List)
reserveAcs.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "reserve_ac.read"), reserveAcHandler.ListDatatable)
reserveAcs.Get("/get/:id", middleware.PermissionRequired(authSvc, "reserve_ac.read"), reserveAcHandler.Get)
reserveAcs.Patch("/update", middleware.PermissionRequired(authSvc, "reserve_ac.update"), reserveAcHandler.Update)
reserveAcs.Delete("/delete", middleware.PermissionRequired(authSvc, "reserve_ac.delete"), reserveAcHandler.Delete)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerRoleRoutes(v1 fiber.Router, roleHandler *handlers.RoleHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
roles := v1.Group("/roles", authMiddleware)
roles.Post("/create", middleware.PermissionRequired(authSvc, "role.create"), roleHandler.Create)
roles.Get("/get-all", middleware.PermissionRequired(authSvc, "role.read"), roleHandler.List)
roles.Get("/permissions/get-all", middleware.PermissionRequired(authSvc, "role.permission.read"), roleHandler.ListPermissions)
roles.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "role.read"), roleHandler.ListDatatable)
roles.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "role.read"), roleHandler.GetByID)
roles.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "role.update"), roleHandler.Update)
roles.Patch("/permissions/:permission_uuid", middleware.PermissionRequired(authSvc, "role.permission.update"), roleHandler.UpdatePermission)
roles.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "role.delete"), roleHandler.Delete)
roles.Get("/:uuid/permissions", middleware.PermissionRequired(authSvc, "role.permission.read"), roleHandler.ListRolePermissions)
roles.Post("/:uuid/permissions", middleware.PermissionRequired(authSvc, "role.permission.assign"), roleHandler.AssignPermission)
roles.Delete("/:uuid/permissions/:permission_uuid", middleware.PermissionRequired(authSvc, "role.permission.remove"), roleHandler.RemovePermission)
roles.Post("/:uuid/permissions/assign-bulk", middleware.PermissionRequired(authSvc, "role.permission.assign"), roleHandler.AssignPermissionsBulk)
roles.Post("/:uuid/permissions/remove-bulk", middleware.PermissionRequired(authSvc, "role.permission.remove"), roleHandler.RemovePermissionsBulk)
}

View File

@@ -0,0 +1,21 @@
package api
import "github.com/gofiber/fiber/v2"
func registerSAMLAuthRoutes(v1 fiber.Router, samlHandler interface {
HandleLogin(*fiber.Ctx) error
HandleSAMLCallback(*fiber.Ctx) error
HandleSLO(*fiber.Ctx) error
HandleMetadata(*fiber.Ctx) error
HandleLogout(*fiber.Ctx) error
}) {
if samlHandler == nil {
return
}
auth := v1.Group("/auth")
auth.Get("/login", samlHandler.HandleLogin)
auth.Post("/saml/callback", samlHandler.HandleSAMLCallback)
auth.Post("/saml/logout", samlHandler.HandleSLO)
auth.Get("/saml/metadata", samlHandler.HandleMetadata)
auth.Post("/saml/app-logout", samlHandler.HandleLogout)
}

View File

@@ -0,0 +1,10 @@
package api
import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/swagger"
)
func registerSwaggerRoutes(app *fiber.App) {
app.Get("/swagger/*", swagger.HandlerDefault)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerTakeoverRoutes(v1 fiber.Router, takeoverHandler *handlers.TakeoverHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
takeovers := v1.Group("/takeovers", authMiddleware)
takeovers.Post("/create", middleware.PermissionRequired(authSvc, "reserve_ac.create"), takeoverHandler.CreateTakeover)
takeovers.Get("/get-all", middleware.PermissionRequired(authSvc, "reserve_ac.read"), takeoverHandler.ListTakeovers)
takeovers.Get("/get/:id", middleware.PermissionRequired(authSvc, "reserve_ac.read"), takeoverHandler.GetTakeover)
takeovers.Patch("/update/:id", middleware.PermissionRequired(authSvc, "reserve_ac.update"), takeoverHandler.UpdateTakeover)
takeovers.Delete("/delete/:id", middleware.PermissionRequired(authSvc, "reserve_ac.delete"), takeoverHandler.DeleteTakeover)
}

View File

@@ -0,0 +1,243 @@
package api
import (
"net/http"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestRegisterRoutes(t *testing.T) {
app := fiber.New()
RegisterRoutes(app, buildRouteDependenciesForTest())
routes := app.GetRoutes(true)
required := []struct {
method string
path string
}{
{method: http.MethodGet, path: "/health"},
{method: http.MethodGet, path: "/version"},
{method: http.MethodGet, path: "/api/version"},
{method: http.MethodGet, path: "/api/build-info"},
{method: http.MethodGet, path: "/metrics"},
{method: http.MethodPost, path: "/api/v1/auth/register"},
{method: http.MethodPost, path: "/api/v1/auth/forgot-password"},
{method: http.MethodPost, path: "/api/v1/users/create"},
{method: http.MethodGet, path: "/api/v1/users/events"},
{method: http.MethodPost, path: "/api/v1/contacts/create"},
{method: http.MethodGet, path: "/api/v1/contacts/get-all"},
{method: http.MethodGet, path: "/api/v1/contacts/get-all/dt"},
{method: http.MethodPost, path: "/api/v1/roles/create"},
{method: http.MethodPost, path: "/api/v1/helicopters/create"},
{method: http.MethodPost, path: "/api/v1/helicopter-usage/create"},
{method: http.MethodGet, path: "/api/v1/helicopter-usage/get-all"},
{method: http.MethodGet, path: "/api/v1/helicopter-usage/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/helicopter-usage/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/helicopter-usage/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/helicopter-usage/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/helicopters/:id/reports/next-number"},
{method: http.MethodPost, path: "/api/v1/helicopter-files/create"},
{method: http.MethodGet, path: "/api/v1/helicopter-files/events"},
{method: http.MethodPost, path: "/api/v1/helicopter-files/upload"},
{method: http.MethodPost, path: "/api/v1/helicopter-files/cancel-upload"},
{method: http.MethodGet, path: "/api/v1/helicopter-files/get-all"},
{method: http.MethodGet, path: "/api/v1/helicopter-files/get/:helicopter_id"},
{method: http.MethodPatch, path: "/api/v1/helicopter-files/update"},
{method: http.MethodDelete, path: "/api/v1/helicopter-files/delete"},
{method: http.MethodGet, path: "/api/v1/flights/get-all"},
{method: http.MethodGet, path: "/api/v1/flights/get"},
{method: http.MethodGet, path: "/api/v1/fm-reports/get-all"},
{method: http.MethodGet, path: "/api/v1/fm-reports/get/:flight_id"},
{method: http.MethodPatch, path: "/api/v1/fm-reports/update/:flight_id"},
{method: http.MethodPost, path: "/api/v1/fm-reports/complete/:flight_id"},
{method: http.MethodPost, path: "/api/v1/flight-data/create"},
{method: http.MethodGet, path: "/api/v1/flight-data/get/:uuid"},
{method: http.MethodGet, path: "/api/v1/flight-data/get-all"},
{method: http.MethodGet, path: "/api/v1/flight-data/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/flights-data/get-all"},
{method: http.MethodGet, path: "/api/v1/flights-data/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/mission-type/get-all"},
{method: http.MethodDelete, path: "/api/v1/mission/delete-by-id/:mission_id"},
{method: http.MethodPost, path: "/api/v1/reserve-acs/create"},
{method: http.MethodGet, path: "/api/v1/reserve-acs/get-all"},
{method: http.MethodGet, path: "/api/v1/reserve-acs/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/reserve-acs/get/:id"},
{method: http.MethodPatch, path: "/api/v1/reserve-acs/update"},
{method: http.MethodDelete, path: "/api/v1/reserve-acs/delete"},
{method: http.MethodGet, path: "/api/v1/after-flight-inspection/get"},
{method: http.MethodPost, path: "/api/v1/after-flight-inspection/create"},
{method: http.MethodPatch, path: "/api/v1/after-flight-inspection/update"},
{method: http.MethodDelete, path: "/api/v1/after-flight-inspection/delete"},
{method: http.MethodPost, path: "/api/v1/facilities/create"},
{method: http.MethodPost, path: "/api/v1/bases/create"},
{method: http.MethodGet, path: "/api/v1/bases/events"},
{method: http.MethodPost, path: "/api/v1/medicine/create"},
{method: http.MethodGet, path: "/api/v1/medicine/get-all"},
{method: http.MethodGet, path: "/api/v1/medicine/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/medicine/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/medicine/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/medicine-group/create"},
{method: http.MethodGet, path: "/api/v1/medicine-group/get-all"},
{method: http.MethodGet, path: "/api/v1/medicine-group/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/medicine-group/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/medicine-group/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/motor-reaction/create"},
{method: http.MethodGet, path: "/api/v1/motor-reaction/get-all"},
{method: http.MethodGet, path: "/api/v1/motor-reaction/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/motor-reaction/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/motor-reaction/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/vocation/create"},
{method: http.MethodGet, path: "/api/v1/vocation/get-all"},
{method: http.MethodGet, path: "/api/v1/vocation/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/vocation/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/vocation/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/opc/create"},
{method: http.MethodGet, path: "/api/v1/opc/get-all"},
{method: http.MethodGet, path: "/api/v1/opc/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/opc/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/opc/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/forces-present/create"},
{method: http.MethodGet, path: "/api/v1/forces-present/get-all"},
{method: http.MethodGet, path: "/api/v1/forces-present/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/forces-present/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/forces-present/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/hospital/create"},
{method: http.MethodGet, path: "/api/v1/hospital/get-all"},
{method: http.MethodGet, path: "/api/v1/hospital/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/hospital/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hospital/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/health-insurance-companies/create"},
{method: http.MethodGet, path: "/api/v1/health-insurance-companies/get-all"},
{method: http.MethodGet, path: "/api/v1/health-insurance-companies/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/health-insurance-companies/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/health-insurance-companies/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/federal-state/create"},
{method: http.MethodGet, path: "/api/v1/federal-state/get-all"},
{method: http.MethodGet, path: "/api/v1/federal-state/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/federal-state/update/:id"},
{method: http.MethodDelete, path: "/api/v1/federal-state/delete/:id"},
{method: http.MethodPost, path: "/api/v1/icao/create"},
{method: http.MethodGet, path: "/api/v1/icao/get-all"},
{method: http.MethodGet, path: "/api/v1/icao/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/icao/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/icao/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/land/create"},
{method: http.MethodGet, path: "/api/v1/land/get-all"},
{method: http.MethodGet, path: "/api/v1/land/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/land/update/:id"},
{method: http.MethodDelete, path: "/api/v1/land/delete/:id"},
{method: http.MethodPost, path: "/api/v1/master-settings/create"},
{method: http.MethodGet, path: "/api/v1/master-settings/get"},
{method: http.MethodPatch, path: "/api/v1/master-settings/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/master-settings/delete/:uuid"},
{method: http.MethodGet, path: "/api/v1/branding/logo"},
{method: http.MethodGet, path: "/api/v1/branding/login-cover"},
{method: http.MethodGet, path: "/api/v1/branding/login-content"},
{method: http.MethodGet, path: "/api/v1/file-manager/get-all"},
{method: http.MethodGet, path: "/api/v1/file-manager/events"},
{method: http.MethodGet, path: "/api/v1/file-manager/trash/get-all"},
{method: http.MethodPatch, path: "/api/v1/file-manager/move"},
{method: http.MethodDelete, path: "/api/v1/file-manager/delete"},
{method: http.MethodPatch, path: "/api/v1/file-manager/restore"},
{method: http.MethodDelete, path: "/api/v1/file-manager/purge-delete"},
{method: http.MethodPost, path: "/api/v1/file-manager/folders/create"},
{method: http.MethodGet, path: "/api/v1/file-manager/folders/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/file-manager/folders/update/:uuid"},
{method: http.MethodPost, path: "/api/v1/file-manager/files/create"},
{method: http.MethodPost, path: "/api/v1/file-manager/files/upload"},
{method: http.MethodDelete, path: "/api/v1/file-manager/files/cancel-upload"},
{method: http.MethodGet, path: "/api/v1/file-manager/files/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/file-manager/files/update/:uuid"},
{method: http.MethodPost, path: "/api/v1/file-manager/attachments/create"},
{method: http.MethodGet, path: "/api/v1/file-manager/attachments/get-all"},
{method: http.MethodGet, path: "/api/v1/file-manager/attachments/get/:uuid"},
{method: http.MethodDelete, path: "/api/v1/file-manager/attachments/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/duty-roster/create"},
{method: http.MethodGet, path: "/api/v1/duty-roster/get-all"},
{method: http.MethodGet, path: "/api/v1/duty-roster/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/duty-roster/update"},
{method: http.MethodDelete, path: "/api/v1/duty-roster/delete"},
{method: http.MethodDelete, path: "/api/v1/duty-roster/crew"},
{method: http.MethodPost, path: "/api/v1/air-rescuer-checklist/create"},
{method: http.MethodGet, path: "/api/v1/air-rescuer-checklist/get-all"},
{method: http.MethodGet, path: "/api/v1/air-rescuer-checklist/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/air-rescuer-checklist/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/air-rescuer-checklist/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/air-rescuer-checklist/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/air-rescuer-checklist/:uuid/items/create"},
{method: http.MethodPatch, path: "/api/v1/air-rescuer-checklist/:uuid/items/reorder"},
{method: http.MethodPatch, path: "/api/v1/air-rescuer-checklist/items/update/:item_uuid"},
{method: http.MethodDelete, path: "/api/v1/air-rescuer-checklist/items/delete/:item_uuid"},
{method: http.MethodPost, path: "/api/v1/insurance-patient-data/create"},
{method: http.MethodGet, path: "/api/v1/insurance-patient-data/get/:uuid"},
{method: http.MethodGet, path: "/api/v1/insurance-patient-data/get-all"},
{method: http.MethodGet, path: "/api/v1/insurance-patient-data/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/insurance-patient-data/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/insurance-patient-data/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/patient-data/create"},
{method: http.MethodGet, path: "/api/v1/patient-data/get/:uuid"},
{method: http.MethodGet, path: "/api/v1/patient-data/get-all"},
{method: http.MethodGet, path: "/api/v1/patient-data/get-all/dt"},
{method: http.MethodPatch, path: "/api/v1/patient-data/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/patient-data/delete/:uuid"},
{method: http.MethodGet, path: "/api/v1/dul/events"},
{method: http.MethodPost, path: "/api/v1/hems-operational-data/create"},
{method: http.MethodGet, path: "/api/v1/hems-operational-data/get-all"},
{method: http.MethodGet, path: "/api/v1/hems-operational-data/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/hems-operational-data/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/hems-operational-data/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hems-operational-data/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/hems-operation-category/create"},
{method: http.MethodGet, path: "/api/v1/hems-operation-category/get-all"},
{method: http.MethodGet, path: "/api/v1/hems-operation-category/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/hems-operation-category/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/hems-operation-category/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hems-operation-category/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/hems-operation/create"},
{method: http.MethodGet, path: "/api/v1/hems-operation/get-all"},
{method: http.MethodGet, path: "/api/v1/hems-operation/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/hems-operation/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/hems-operation/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hems-operation/delete/:uuid"},
{method: http.MethodGet, path: "/swagger/*"},
}
for _, tc := range required {
if !hasRoute(routes, tc.method, tc.path) {
t.Fatalf("route %s %s not registered", tc.method, tc.path)
}
}
legacy := []struct {
method string
path string
}{
{method: http.MethodPost, path: "/api/v1/crews/create"},
{method: http.MethodGet, path: "/api/v1/crews/get-all"},
{method: http.MethodGet, path: "/api/v1/crews/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/crews/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/crews/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/crews/delete/:uuid"},
{method: http.MethodPost, path: "/api/v1/hems-bases/create"},
{method: http.MethodGet, path: "/api/v1/hems-bases/get-all"},
{method: http.MethodGet, path: "/api/v1/hems-bases/get-all/dt"},
{method: http.MethodGet, path: "/api/v1/hems-bases/get/:uuid"},
{method: http.MethodPatch, path: "/api/v1/hems-bases/update/:uuid"},
{method: http.MethodDelete, path: "/api/v1/hems-bases/delete/:uuid"},
}
for _, tc := range legacy {
if hasRoute(routes, tc.method, tc.path) {
t.Fatalf("legacy route %s %s should not be registered", tc.method, tc.path)
}
}
}
func hasRoute(routes []fiber.Route, method, path string) bool {
for _, route := range routes {
if route.Method == method && route.Path == path {
return true
}
}
return false
}

View File

@@ -0,0 +1,58 @@
package api
import (
"wucher/internal/config"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
)
func buildRouteDependenciesForTest() RouteDependencies {
authSvc := service.NewAuthService(nil, nil, nil, nil, service.AuthServiceDependencies{SSO: nil, Protector: nil, Config: config.AuthConfig{}})
return RouteDependencies{
Handlers: RouteHandlers{
User: handlers.NewUserHandler(service.NewUserService(nil)),
Contact: handlers.NewContactHandler(service.NewContactService(nil, nil), nil),
Health: handlers.NewHealthHandler(),
BuildInfo: handlers.NewBuildInfoHandler(),
Auth: handlers.NewAuthHandler(authSvc),
Role: handlers.NewRoleHandler(service.NewRoleService(nil)),
Helicopter: handlers.NewHelicopterHandler(service.NewHelicopterService(nil)),
HelicopterUsage: handlers.NewHelicopterUsageHandler(service.NewHelicopterUsageService(nil)),
HelicopterFile: handlers.NewHelicopterFileHandler(service.NewHelicopterFileService(nil)),
Flight: handlers.NewFlightHandler(service.NewFlightService(nil)).WithMissionService(service.NewMissionService(nil)),
FMReport: handlers.NewFMReportHandler(service.NewFMReportService(nil)).WithTakeoverService(service.NewTakeoverService(nil)).WithHelicopterUsageService(service.NewHelicopterUsageService(nil)),
ReserveAc: handlers.NewReserveAcHandler(service.NewReserveAcService(nil)),
Facility: handlers.NewFacilityHandler(service.NewFacilityService(nil)),
Base: handlers.NewBaseHandler(service.NewBaseService(nil)),
Medicine: handlers.NewMedicineHandler(service.NewMedicineService(nil)),
Vocation: handlers.NewVocationHandler(service.NewVocationService(nil)),
Opc: handlers.NewOpcHandler(service.NewOpcService(nil)),
ForcesPresent: handlers.NewForcesPresentHandler(service.NewForcesPresentService(nil)),
Hospital: handlers.NewHospitalHandler(service.NewHospitalService(nil)),
HealthInsuranceCompanies: handlers.NewHealthInsuranceCompaniesHandler(service.NewHealthInsuranceCompaniesService(nil)),
FederalState: handlers.NewFederalStateHandler(service.NewFederalStateService(nil)),
ICAO: handlers.NewICAOHandler(service.NewICAOService(nil)),
Land: handlers.NewLandHandler(service.NewLandService(nil)),
MasterSettings: handlers.NewMasterSettingsHandler(service.NewMasterSettingsService(nil, service.MasterSettingsServiceDependencies{}), service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{}), nil),
Branding: handlers.NewBrandingHandler(service.NewMasterSettingsService(nil, service.MasterSettingsServiceDependencies{}), service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{}), nil),
FileManagerFolder: handlers.NewFileManagerFolderHandler(service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{}), nil),
FileManagerFile: handlers.NewFileManagerFileHandler(service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{}), nil).
WithAircraftService(service.NewHelicopterService(nil)).
WithBaseService(service.NewBaseService(nil)).
WithTakeoverService(service.NewTakeoverService(nil)),
FileManagerAttachment: handlers.NewFileManagerAttachmentHandler(service.NewFileManagerService(nil, nil, nil, service.FileManagerServiceDependencies{})),
FileManagerEvents: handlers.NewFileManagerEventsHandler(nil),
DutyRoster: handlers.NewDutyRosterHandler(service.NewDutyRosterService(nil)),
AirRescuerChecklist: handlers.NewAirRescuerChecklistHandler(service.NewAirRescuerChecklistService(nil)),
InsurancePatientData: handlers.NewInsurancePatientDataHandler(service.NewInsurancePatientDataService(nil)),
PatientData: handlers.NewPatientDataHandler(service.NewPatientDataService(nil)),
HEMSOperationalData: handlers.NewHEMSOperationalDataHandler(service.NewHEMSOperationalDataService(nil)),
HEMSOperationCategory: handlers.NewHEMSOperationCategoryHandler(service.NewHEMSOperationCategoryService(nil)),
HEMSOperation: handlers.NewHEMSOperationHandler(service.NewHEMSOperationService(nil)),
},
Services: RouteServices{
Auth: authSvc,
Audit: service.NewAuditLogService(nil, service.AuditLogServiceDependencies{QueueSize: 0, Workers: 0, IPEncryptionKey: ""}),
},
}
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerUserRoutes(
v1 fiber.Router,
userHandler *handlers.UserHandler,
eventsHandler *handlers.FileManagerEventsHandler,
authMiddleware fiber.Handler,
authSvc *service.AuthService,
) {
users := v1.Group("/users", authMiddleware)
users.Get("/events", middleware.PermissionRequired(authSvc, "user.read"), eventsHandler.Stream)
users.Post("/create", middleware.PermissionRequired(authSvc, "user.create"), userHandler.Create)
users.Get("/get-all", middleware.PermissionRequired(authSvc, "user.read"), userHandler.List)
users.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "user.read"), userHandler.ListDatatable)
users.Get("/get/:uuid", middleware.PermissionRequired(authSvc, "user.read"), userHandler.Get)
users.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "user.update"), userHandler.Update)
users.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "user.delete"), userHandler.Delete)
}

View File

@@ -0,0 +1,18 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerVocationRoutes(v1 fiber.Router, vocationHandler *handlers.VocationHandler, authMiddleware fiber.Handler, authSvc *service.AuthService) {
vocation := v1.Group("/vocation", authMiddleware)
vocation.Post("/create", middleware.PermissionRequired(authSvc, "vocation.create"), vocationHandler.Create)
vocation.Get("/get-all", middleware.PermissionRequired(authSvc, "vocation.read"), vocationHandler.List)
vocation.Get("/get-all/dt", middleware.PermissionRequired(authSvc, "vocation.read"), vocationHandler.ListDatatable)
vocation.Patch("/update/:uuid", middleware.PermissionRequired(authSvc, "vocation.update"), vocationHandler.Update)
vocation.Delete("/delete/:uuid", middleware.PermissionRequired(authSvc, "vocation.delete"), vocationHandler.Delete)
}

View File

@@ -0,0 +1,23 @@
package api
import (
"github.com/gofiber/fiber/v2"
"wucher/internal/service"
"wucher/internal/transport/http/handlers"
"wucher/internal/transport/http/middleware"
)
func registerWOPIRoutes(app fiber.Router, takeoverHandler *handlers.TakeoverHandler, wopiSvc *service.WOPIService) {
wopi := app.Group("/wopi", middleware.WOPIRequired(wopiSvc))
if wopiSvc != nil {
wopi.Use(middleware.WOPIRequestBodyLimit(wopiSvc.MaxBodyBytes()))
}
readOnly := wopi.Group("", middleware.WOPIPermissionRequired(service.WOPIPermissionRead))
writeOnly := wopi.Group("", middleware.WOPIPermissionRequired(service.WOPIPermissionWrite))
readOnly.Get("/files/:fileId", takeoverHandler.CheckWOPIFileInfo)
readOnly.Get("/files/:fileId/contents", takeoverHandler.GetWOPIContents)
writeOnly.Post("/files/:fileId/contents", takeoverHandler.PutWOPIContents)
writeOnly.Post("/files/:fileId", takeoverHandler.PostWOPIFile)
}

423
internal/app/api/seed.go Normal file
View File

@@ -0,0 +1,423 @@
package api
import (
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"wucher/internal/domain/auth"
"wucher/internal/domain/base"
)
// SeedRoles inserts default roles if they don't exist.
func SeedRoles(db *gorm.DB) error {
roles := []auth.Role{
{Code: "admin", Name: "admin", Description: "Full access to all resources."},
{Code: auth.RoleCodeStaff, Name: auth.RoleCodeStaff, Description: "Operational access with limited admin rights."},
{Code: "user", Name: "user", Description: "Regular end-user access."},
{Code: auth.RoleCodePilot, Name: "pilot", Description: "HEMS pilot contact role."},
{Code: auth.RoleCodeDoctor, Name: "doctor", Description: "HEMS doctor contact role."},
{Code: auth.RoleCodeAirRescuer, Name: "air_rescuer", Description: "HEMS air rescuer contact role."},
{Code: auth.RoleCodeTechnician, Name: "technician", Description: "HEMS technician contact role."},
{Code: auth.RoleCodeFlightAssistant, Name: "flight_assistant", Description: "HEMS flight assistant contact role."},
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "name"}},
DoUpdates: clause.AssignmentColumns([]string{"code", "description"}),
}).Create(&roles).Error
}
// DefaultPermissions returns the canonical catalog of permissions seeded into the system.
func DefaultPermissions() []auth.Permission {
return []auth.Permission{
{Name: "Create User", Key: "user.create", Description: "Create users"},
{Name: "Read User", Key: "user.read", Description: "Read users"},
{Name: "Update User", Key: "user.update", Description: "Update users"},
{Name: "Delete User", Key: "user.delete", Description: "Delete users"},
{Name: "Create Role", Key: "role.create", Description: "Create roles"},
{Name: "Read Role", Key: "role.read", Description: "Read roles"},
{Name: "Update Role", Key: "role.update", Description: "Update roles"},
{Name: "Delete Role", Key: "role.delete", Description: "Delete roles"},
{Name: "Read Permission", Key: "role.permission.read", Description: "Read permissions"},
{Name: "Assign Permission", Key: "role.permission.assign", Description: "Assign permissions to role"},
{Name: "Remove Permission", Key: "role.permission.remove", Description: "Remove permissions from role"},
{Name: "Update Permission", Key: "role.permission.update", Description: "Update permission configuration"},
{Name: "Read Filesystem", Key: "filesystem.read", Description: "Read filesystem structure"},
{Name: "Read File Manager", Key: "file_manager.read", Description: "Read file manager"},
{Name: "Create File Manager", Key: "file_manager.create", Description: "Create in file manager"},
{Name: "Update File Manager", Key: "file_manager.update", Description: "Update in file manager"},
{Name: "Delete File Manager", Key: "file_manager.delete", Description: "Delete in file manager"},
{Name: "Read Folders", Key: "file_manager.folder.read", Description: "Read folders"},
{Name: "Create Folders", Key: "file_manager.folder.create", Description: "Create folders"},
{Name: "Update Folders", Key: "file_manager.folder.update", Description: "Update folders"},
{Name: "Delete Folders", Key: "file_manager.folder.delete", Description: "Delete folders"},
{Name: "Read Files", Key: "file_manager.file.read", Description: "Read files"},
{Name: "Create Files", Key: "file_manager.file.create", Description: "Create files"},
{Name: "Update Files", Key: "file_manager.file.update", Description: "Update files"},
{Name: "Delete Files", Key: "file_manager.file.delete", Description: "Delete files"},
{Name: "Create Helicopter", Key: "helicopter.create", Description: "Create helicopter"},
{Name: "Generate Helicopter Report Number", Key: "helicopter.report.generate", Description: "Generate next helicopter report number"},
{Name: "Read Helicopter", Key: "helicopter.read", Description: "Read helicopter"},
{Name: "Update Helicopter", Key: "helicopter.update", Description: "Update helicopter"},
{Name: "Delete Helicopter", Key: "helicopter.delete", Description: "Delete helicopter"},
{Name: "Create Helicopter Usage", Key: "helicopter_usage.create", Description: "Create helicopter usage"},
{Name: "Read Helicopter Usage", Key: "helicopter_usage.read", Description: "Read helicopter usage"},
{Name: "Update Helicopter Usage", Key: "helicopter_usage.update", Description: "Update helicopter usage"},
{Name: "Delete Helicopter Usage", Key: "helicopter_usage.delete", Description: "Delete helicopter usage"},
{Name: "Create Flight Inspection", Key: "flight_inspection.create", Description: "Create flight inspection"},
{Name: "Read Flight Inspection", Key: "flight_inspection.read", Description: "Read flight inspection"},
{Name: "Update Flight Inspection", Key: "flight_inspection.update", Description: "Update flight inspection"},
{Name: "Create After Flight Inspection", Key: "after_flight.create", Description: "Create after flight inspection"},
{Name: "Read After Flight Inspection", Key: "after_flight.read", Description: "Read after flight inspection"},
{Name: "Update After Flight Inspection", Key: "after_flight.update", Description: "Update after flight inspection"},
{Name: "Delete After Flight Inspection", Key: "after_flight.delete", Description: "Delete after flight inspection"},
{Name: "Create Flight", Key: "flight.create", Description: "Create flight"},
{Name: "Read Flight", Key: "flight.read", Description: "Read flight"},
{Name: "Update Flight", Key: "flight.update", Description: "Update flight"},
{Name: "Complete Flight Report", Key: "flight.complete", Description: "Complete (finalize) flight report"},
{Name: "Delete Flight", Key: "flight.delete", Description: "Delete flight"},
{Name: "Create Reserve AC", Key: "reserve_ac.create", Description: "Create reserve AC"},
{Name: "Read Reserve AC", Key: "reserve_ac.read", Description: "Read reserve AC"},
{Name: "Update Reserve AC", Key: "reserve_ac.update", Description: "Update reserve AC"},
{Name: "Delete Reserve AC", Key: "reserve_ac.delete", Description: "Delete reserve AC"},
{Name: "Create Facility", Key: "facility.create", Description: "Create facility"},
{Name: "Read Facility", Key: "facility.read", Description: "Read facility"},
{Name: "Update Facility", Key: "facility.update", Description: "Update facility"},
{Name: "Delete Facility", Key: "facility.delete", Description: "Delete facility"},
{Name: "Create Base", Key: "base.create", Description: "Create base"},
{Name: "Read Base", Key: "base.read", Description: "Read base"},
{Name: "Update Base", Key: "base.update", Description: "Update base"},
{Name: "Delete Base", Key: "base.delete", Description: "Delete base"},
{Name: "Create HEMS Base", Key: "hems_base.create", Description: "Create HEMS base"},
{Name: "Read HEMS Base", Key: "hems_base.read", Description: "Read HEMS base"},
{Name: "Update HEMS Base", Key: "hems_base.update", Description: "Update HEMS base"},
{Name: "Delete HEMS Base", Key: "hems_base.delete", Description: "Delete HEMS base"},
{Name: "Create Medicine", Key: "medicine.create", Description: "Create medicine"},
{Name: "Read Medicine", Key: "medicine.read", Description: "Read medicine"},
{Name: "Update Medicine", Key: "medicine.update", Description: "Update medicine"},
{Name: "Delete Medicine", Key: "medicine.delete", Description: "Delete medicine"},
{Name: "Create Vocation", Key: "vocation.create", Description: "Create vocation"},
{Name: "Read Vocation", Key: "vocation.read", Description: "Read vocation"},
{Name: "Update Vocation", Key: "vocation.update", Description: "Update vocation"},
{Name: "Delete Vocation", Key: "vocation.delete", Description: "Delete vocation"},
{Name: "Create OPC", Key: "opc.create", Description: "Create opc"},
{Name: "Read OPC", Key: "opc.read", Description: "Read opc"},
{Name: "Update OPC", Key: "opc.update", Description: "Update opc"},
{Name: "Delete OPC", Key: "opc.delete", Description: "Delete opc"},
{Name: "Create Forces Present", Key: "forces_present.create", Description: "Create forces present"},
{Name: "Read Forces Present", Key: "forces_present.read", Description: "Read forces present"},
{Name: "Update Forces Present", Key: "forces_present.update", Description: "Update forces present"},
{Name: "Delete Forces Present", Key: "forces_present.delete", Description: "Delete forces present"},
{Name: "Create No ICAO Code", Key: "no_icao_code.create", Description: "Create no icao code"},
{Name: "Read No ICAO Code", Key: "no_icao_code.read", Description: "Read no icao code"},
{Name: "Update No ICAO Code", Key: "no_icao_code.update", Description: "Update no icao code"},
{Name: "Delete No ICAO Code", Key: "no_icao_code.delete", Description: "Delete no icao code"},
{Name: "Create Health Insurance Companies", Key: "health_insurance_companies.create", Description: "Create health insurance companies"},
{Name: "Read Health Insurance Companies", Key: "health_insurance_companies.read", Description: "Read health insurance companies"},
{Name: "Update Health Insurance Companies", Key: "health_insurance_companies.update", Description: "Update health insurance companies"},
{Name: "Delete Health Insurance Companies", Key: "health_insurance_companies.delete", Description: "Delete health insurance companies"},
{Name: "Create Federal State", Key: "federal_state.create", Description: "Create federal state"},
{Name: "Read Federal State", Key: "federal_state.read", Description: "Read federal state"},
{Name: "Update Federal State", Key: "federal_state.update", Description: "Update federal state"},
{Name: "Delete Federal State", Key: "federal_state.delete", Description: "Delete federal state"},
{Name: "Create ICAO", Key: "icao.create", Description: "Create ICAO"},
{Name: "Read ICAO", Key: "icao.read", Description: "Read ICAO"},
{Name: "Update ICAO", Key: "icao.update", Description: "Update ICAO"},
{Name: "Delete ICAO", Key: "icao.delete", Description: "Delete ICAO"},
{Name: "Create Country", Key: "country.create", Description: "Create country"},
{Name: "Read Country", Key: "country.read", Description: "Read country"},
{Name: "Update Country", Key: "country.update", Description: "Update country"},
{Name: "Delete Country", Key: "country.delete", Description: "Delete country"},
{Name: "Create Master Settings", Key: "master_settings.create", Description: "Create master settings"},
{Name: "Read Master Settings", Key: "master_settings.read", Description: "Read master settings"},
{Name: "Update Master Settings", Key: "master_settings.update", Description: "Update master settings"},
{Name: "Delete Master Settings", Key: "master_settings.delete", Description: "Delete master settings"},
{Name: "Create Duty Roster", Key: "duty_roster.create", Description: "Create duty roster"},
{Name: "Read Duty Roster", Key: "duty_roster.read", Description: "Read duty roster"},
{Name: "Update Duty Roster", Key: "duty_roster.update", Description: "Update duty roster"},
{Name: "Delete Duty Roster", Key: "duty_roster.delete", Description: "Delete duty roster"},
{Name: "Create Air Rescuer Checklist", Key: "air_rescuer_checklist.create", Description: "Create air rescuer checklist"},
{Name: "Read Air Rescuer Checklist", Key: "air_rescuer_checklist.read", Description: "Read air rescuer checklist"},
{Name: "Update Air Rescuer Checklist", Key: "air_rescuer_checklist.update", Description: "Update air rescuer checklist"},
{Name: "Delete Air Rescuer Checklist", Key: "air_rescuer_checklist.delete", Description: "Delete air rescuer checklist"},
{Name: "Create Flight Prep Check", Key: "flight_prep_check.create", Description: "Create flight prep check"},
{Name: "Read Flight Prep Check", Key: "flight_prep_check.read", Description: "Read flight prep check"},
{Name: "Update Flight Prep Check", Key: "flight_prep_check.update", Description: "Update flight prep check"},
{Name: "Read Audit Log", Key: "audit.read", Description: "Read audit logs"},
{Name: "Create Insurance Patient Data", Key: "insurance_patient_data.create", Description: "Create insurance patient data"},
{Name: "Read Insurance Patient Data", Key: "insurance_patient_data.read", Description: "Read insurance patient data"},
{Name: "Update Insurance Patient Data", Key: "insurance_patient_data.update", Description: "Update insurance patient data"},
{Name: "Delete Insurance Patient Data", Key: "insurance_patient_data.delete", Description: "Delete insurance patient data"},
{Name: "Create Patient Data", Key: "patient_data.create", Description: "Create patient data"},
{Name: "Read Patient Data", Key: "patient_data.read", Description: "Read patient data"},
{Name: "Update Patient Data", Key: "patient_data.update", Description: "Update patient data"},
{Name: "Delete Patient Data", Key: "patient_data.delete", Description: "Delete patient data"},
{Name: "Create Operation", Key: "operation.create", Description: "Create HEMS operational data"},
{Name: "Read Operation", Key: "operation.read", Description: "Read HEMS operational data"},
{Name: "Update Operation", Key: "operation.update", Description: "Update HEMS operational data"},
{Name: "Delete Operation", Key: "operation.delete", Description: "Delete HEMS operational data"},
{Name: "Create Operation Category", Key: "operation_category.create", Description: "Create HEMS operation category"},
{Name: "Read Operation Category", Key: "operation_category.read", Description: "Read HEMS operation category"},
{Name: "Update Operation Category", Key: "operation_category.update", Description: "Update HEMS operation category"},
{Name: "Delete Operation Category", Key: "operation_category.delete", Description: "Delete HEMS operation category"},
{Name: "Create HEMS Operation", Key: "hems_operation.create", Description: "Create HEMS operation"},
{Name: "Read HEMS Operation", Key: "hems_operation.read", Description: "Read HEMS operation"},
{Name: "Update HEMS Operation", Key: "hems_operation.update", Description: "Update HEMS operation"},
{Name: "Delete HEMS Operation", Key: "hems_operation.delete", Description: "Delete HEMS operation"},
{Name: "Create DUL", Key: "dul.create", Description: "Create DUL"},
{Name: "Read DUL", Key: "dul.read", Description: "Read DUL"},
{Name: "Update DUL", Key: "dul.update", Description: "Update DUL"},
{Name: "Delete DUL", Key: "dul.delete", Description: "Delete DUL"},
{Name: "Create Complaint", Key: "complaint.create", Description: "Create complaint"},
{Name: "Read Complaint", Key: "complaint.read", Description: "Read complaint"},
{Name: "Update Complaint", Key: "complaint.update", Description: "Update complaint"},
{Name: "Delete Complaint", Key: "complaint.delete", Description: "Delete complaint"},
{Name: "Decide Complaint NSR", Key: "complaint.nsr", Description: "Set or cancel complaint NSR (non-safety release)"},
{Name: "Create EASA Release", Key: "easa_release.create", Description: "Create EASA maintenance release"},
{Name: "Read EASA Release", Key: "easa_release.read", Description: "Read EASA maintenance release"},
{Name: "Update EASA Release", Key: "easa_release.update", Description: "Update EASA maintenance release"},
{Name: "Delete EASA Release", Key: "easa_release.delete", Description: "Delete EASA maintenance release"},
{Name: "Sign EASA Release", Key: "easa_release.sign", Description: "Sign EASA maintenance release (return to service)"},
{Name: "Read Action Sign-off", Key: "action_signoff.read", Description: "Read corrective-action sign-off"},
{Name: "Sign Action Sign-off", Key: "action_signoff.sign", Description: "Toggle (sign/unsign) corrective-action sign-off, per complaint or fleet-wide"},
{Name: "Create MCF", Key: "mcf.create", Description: "Create maintenance check flight"},
{Name: "Read MCF", Key: "mcf.read", Description: "Read maintenance check flight"},
{Name: "Complete MCF", Key: "mcf.complete", Description: "Complete (release) a helicopter's open maintenance check flight"},
}
}
// SeedPermissions inserts default permissions and keeps existing rows in sync.
func SeedPermissions(db *gorm.DB) error {
perms := DefaultPermissions()
for i := range perms {
perms[i].Module = auth.ModuleForKey(perms[i].Key)
perms[i].RequiresPIN = auth.DefaultPermissionRequiresPIN(perms[i].Key)
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "description", "module"}),
}).Create(&perms).Error
}
// SeedRolePermissions inserts default role-permission mappings and ensures admin has all permissions.
func SeedRolePermissions(db *gorm.DB) error {
type rolePerm struct {
RoleName string
PermKey string
}
mappings := []rolePerm{
// staff: read-only for users/roles/permissions
{RoleName: "staff", PermKey: "user.read"},
{RoleName: "staff", PermKey: "role.read"},
{RoleName: "staff", PermKey: "role.permission.read"},
{RoleName: "staff", PermKey: "helicopter.read"},
{RoleName: "staff", PermKey: "flight_inspection.read"},
{RoleName: "staff", PermKey: "flight.read"},
{RoleName: "staff", PermKey: "reserve_ac.read"},
{RoleName: "staff", PermKey: "facility.read"},
{RoleName: "staff", PermKey: "base.read"},
{RoleName: "staff", PermKey: "hems_base.read"},
{RoleName: "staff", PermKey: "medicine.read"},
{RoleName: "staff", PermKey: "vocation.read"},
{RoleName: "staff", PermKey: "opc.read"},
{RoleName: "staff", PermKey: "forces_present.read"},
{RoleName: "staff", PermKey: "no_icao_code.read"},
{RoleName: "staff", PermKey: "health_insurance_companies.read"},
{RoleName: "staff", PermKey: "federal_state.read"},
{RoleName: "staff", PermKey: "icao.read"},
{RoleName: "staff", PermKey: "country.read"},
{RoleName: "staff", PermKey: "master_settings.read"},
{RoleName: "staff", PermKey: "file_manager.read"},
{RoleName: "staff", PermKey: "duty_roster.read"},
{RoleName: "staff", PermKey: "air_rescuer_checklist.read"},
{RoleName: "staff", PermKey: "flight_prep_check.create"},
{RoleName: "staff", PermKey: "flight_prep_check.read"},
{RoleName: "staff", PermKey: "flight_prep_check.update"},
{RoleName: "staff", PermKey: "helicopter_usage.read"},
{RoleName: "staff", PermKey: "after_flight.read"},
{RoleName: "staff", PermKey: "audit.read"},
{RoleName: "staff", PermKey: "insurance_patient_data.read"},
{RoleName: "staff", PermKey: "patient_data.read"},
{RoleName: "staff", PermKey: "operation.read"},
{RoleName: "staff", PermKey: "operation_category.read"},
{RoleName: "staff", PermKey: "hems_operation.read"},
{RoleName: "pilot", PermKey: "flight.read"},
{RoleName: "pilot", PermKey: "duty_roster.read"},
}
if err := assignAllPermissionsToRole(db, "admin"); err != nil {
return err
}
for _, m := range mappings {
if err := assignPermissionToRole(db, m.RoleName, m.PermKey); err != nil {
return err
}
}
return nil
}
func assignAllPermissionsToRole(db *gorm.DB, roleName string) error {
var role auth.Role
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
return err
}
var perms []auth.Permission
if err := db.Find(&perms).Error; err != nil {
return err
}
for _, perm := range perms {
rp := auth.RolePermission{RoleID: role.ID, PermissionID: perm.ID}
if err := db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "role_id"}, {Name: "permission_id"}},
DoNothing: true,
}).Create(&rp).Error; err != nil {
return err
}
}
return nil
}
func assignPermissionToRole(db *gorm.DB, roleName, permKey string) error {
var role auth.Role
if err := db.Where("name = ?", roleName).First(&role).Error; err != nil {
return err
}
var perm auth.Permission
if err := db.Where("`key` = ?", permKey).First(&perm).Error; err != nil {
return err
}
rp := auth.RolePermission{RoleID: role.ID, PermissionID: perm.ID}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "role_id"}, {Name: "permission_id"}},
DoNothing: true,
}).Create(&rp).Error
}
// SeedBaseCategories inserts default base categories if they don't exist.
func SeedBaseCategories(db *gorm.DB) error {
categories := []base.BaseCategory{
{Key: base.CategoryKeyRegular, Name: "Regular Base"},
{Key: base.CategoryKeyHEMS, Name: "HEMS Base"},
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"name"}),
}).Create(&categories).Error
}
// SeedRosterUsers inserts sample users for duty roster assignment in development.
// This seed is idempotent by email and keeps role assignment synced via user_roles.
func SeedRosterUsers(db *gorm.DB) error {
type rosterSeed struct {
Email string
FirstName string
LastName string
MobilePhone string
RoleCode string
}
seeds := []rosterSeed{
{Email: "pilot.main@wucher.local", FirstName: "Pilot", LastName: "Main", MobilePhone: "+620001", RoleCode: auth.RoleCodePilot},
{Email: "pilot.additional@wucher.local", FirstName: "Pilot", LastName: "Additional", MobilePhone: "+620002", RoleCode: auth.RoleCodePilot},
{Email: "doctor.main@wucher.local", FirstName: "Doctor", LastName: "Main", MobilePhone: "+620003", RoleCode: auth.RoleCodeDoctor},
{Email: "rescuer.main@wucher.local", FirstName: "Rescuer", LastName: "Main", MobilePhone: "+620004", RoleCode: auth.RoleCodeAirRescuer},
{Email: "helper.main@wucher.local", FirstName: "Helper", LastName: "Main", MobilePhone: "+620005", RoleCode: auth.RoleCodeFlightAssistant},
}
return db.Transaction(func(tx *gorm.DB) error {
roleKeys := make([]string, 0, len(seeds))
seenRole := make(map[string]struct{}, len(seeds))
for i := range seeds {
k := strings.ToLower(strings.TrimSpace(seeds[i].RoleCode))
if k == "" {
continue
}
if _, ok := seenRole[k]; ok {
continue
}
seenRole[k] = struct{}{}
roleKeys = append(roleKeys, k)
}
var roles []auth.Role
if err := tx.Where("LOWER(code) IN ? OR LOWER(name) IN ?", roleKeys, roleKeys).Find(&roles).Error; err != nil {
return err
}
roleIDByCode := make(map[string][]byte, len(roles))
for i := range roles {
code := strings.ToLower(strings.TrimSpace(roles[i].Code))
if code == "" {
code = strings.ToLower(strings.TrimSpace(roles[i].Name))
}
if code == "" {
continue
}
roleIDByCode[code] = append([]byte(nil), roles[i].ID...)
}
now := time.Now().UTC()
for i := range seeds {
roleCode := strings.ToLower(strings.TrimSpace(seeds[i].RoleCode))
roleID := roleIDByCode[roleCode]
if len(roleID) != 16 {
continue
}
row := auth.User{
Email: strings.ToLower(strings.TrimSpace(seeds[i].Email)),
Username: nil,
FirstName: strings.TrimSpace(seeds[i].FirstName),
LastName: strings.TrimSpace(seeds[i].LastName),
MobilePhone: strings.TrimSpace(seeds[i].MobilePhone),
Timezone: "UTC",
RoleID: roleID,
IsActive: true,
EmailVerifiedAt: &now,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.Assignments(map[string]any{
"first_name": row.FirstName,
"last_name": row.LastName,
"mobile_phone": row.MobilePhone,
"timezone": row.Timezone,
"role_id": row.RoleID,
"is_active": row.IsActive,
"email_verified_at": row.EmailVerifiedAt,
"updated_at": now,
}),
}).Create(&row).Error; err != nil {
return err
}
var created auth.User
if err := tx.Where("email = ?", row.Email).Take(&created).Error; err != nil {
return err
}
link := auth.UserRole{
UserID: created.ID,
RoleID: roleID,
}
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "role_id"}},
DoNothing: true,
}).Create(&link).Error; err != nil {
return err
}
}
return nil
})
}

View File

@@ -0,0 +1,34 @@
package api
import (
"testing"
"wucher/internal/domain/auth"
)
// TestDefaultPermissionsHaveRegisteredModule guards that every seeded permission
// maps to a module declared in auth.ModuleRegistry. Adding a permission with a
// new prefix without registering its module will fail this test.
func TestDefaultPermissionsHaveRegisteredModule(t *testing.T) {
for _, p := range DefaultPermissions() {
module := auth.ModuleForKey(p.Key)
if !auth.IsRegisteredModule(module) {
t.Errorf("permission %q maps to module %q which is not in auth.ModuleRegistry", p.Key, module)
}
}
}
// TestDefaultPermissionsHaveUniqueKeys guards against accidental duplicate keys
// in the catalog (the upsert is keyed on "key").
func TestDefaultPermissionsHaveUniqueKeys(t *testing.T) {
seen := make(map[string]bool)
for _, p := range DefaultPermissions() {
if p.Key == "" {
t.Errorf("permission %q has an empty key", p.Name)
}
if seen[p.Key] {
t.Errorf("duplicate permission key %q in DefaultPermissions", p.Key)
}
seen[p.Key] = true
}
}

View File

@@ -0,0 +1,54 @@
package api
import (
"context"
"time"
"github.com/gofiber/fiber/v2"
)
type Server struct {
app *fiber.App
shutdownTimeout time.Duration
}
type ServerOption func(*Server)
const defaultShutdownTimeout = 30 * time.Second
func WithShutdownTimeout(timeout time.Duration) ServerOption {
return func(s *Server) {
if timeout > 0 {
s.shutdownTimeout = timeout
}
}
}
func NewServer(app *fiber.App, opts ...ServerOption) *Server {
srv := &Server{
app: app,
shutdownTimeout: defaultShutdownTimeout,
}
for _, opt := range opts {
if opt != nil {
opt(srv)
}
}
return srv
}
func (s *Server) Start(addr string) error {
return s.app.Listen(addr)
}
func (s *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
if s.shutdownTimeout > 0 {
shutdownCtx, cancel := context.WithTimeout(ctx, s.shutdownTimeout)
defer cancel()
ctx = shutdownCtx
}
return s.app.ShutdownWithContext(ctx)
}

View File

@@ -0,0 +1,160 @@
package api
import (
"context"
"errors"
"io"
"net"
"net/http"
"testing"
"time"
"github.com/gofiber/fiber/v2"
)
func TestServerShutdownDrainsInFlightRequestsAndRejectsNewRequests(t *testing.T) {
app := fiber.New(fiber.Config{
DisableStartupMessage: true,
})
UseDefaultMiddlewares(app, testHTTPConfig())
slowStarted := make(chan struct{})
app.Get("/slow", func(c *fiber.Ctx) error {
select {
case <-slowStarted:
default:
close(slowStarted)
}
select {
case <-time.After(300 * time.Millisecond):
case <-c.UserContext().Done():
return c.Status(http.StatusInternalServerError).SendString("user context canceled")
}
return c.SendString("slow done")
})
app.Get("/fast", func(c *fiber.Ctx) error {
return c.SendString("fast done")
})
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
serveErrCh := make(chan error, 1)
go func() {
serveErrCh <- app.Listener(ln)
}()
t.Cleanup(func() {
_ = app.Shutdown()
})
baseURL := "http://" + ln.Addr().String()
fastClient := newTestHTTPClient()
slowClient := newTestHTTPClient()
defer fastClient.CloseIdleConnections()
defer slowClient.CloseIdleConnections()
if err := waitForHTTP200(fastClient, baseURL+"/fast", time.Second); err != nil {
t.Fatalf("wait for server ready: %v", err)
}
slowRespCh := make(chan httpCallResult, 1)
go func() {
resp, err := slowClient.Get(baseURL + "/slow")
if err != nil {
slowRespCh <- httpCallResult{err: err}
return
}
defer resp.Body.Close()
body, readErr := io.ReadAll(resp.Body)
slowRespCh <- httpCallResult{
statusCode: resp.StatusCode,
body: string(body),
err: readErr,
}
}()
select {
case <-slowStarted:
case <-time.After(time.Second):
t.Fatal("slow request did not start")
}
srv := NewServer(app, WithShutdownTimeout(2*time.Second))
shutdownErrCh := make(chan error, 1)
go func() {
shutdownErrCh <- srv.Shutdown(context.Background())
}()
if err := waitForRequestRejection(fastClient, baseURL+"/fast", time.Second); err == nil {
t.Fatal("expected new requests to be rejected during shutdown")
}
slowResult := <-slowRespCh
if slowResult.err != nil {
t.Fatalf("slow request failed: %v", slowResult.err)
}
if slowResult.statusCode != http.StatusOK {
t.Fatalf("expected slow request 200, got %d with body %q", slowResult.statusCode, slowResult.body)
}
if slowResult.body != "slow done" {
t.Fatalf("expected slow request body %q, got %q", "slow done", slowResult.body)
}
if err := <-shutdownErrCh; err != nil {
t.Fatalf("shutdown failed: %v", err)
}
if err := <-serveErrCh; err != nil {
t.Fatalf("listener exited with error: %v", err)
}
}
type httpCallResult struct {
statusCode int
body string
err error
}
func newTestHTTPClient() *http.Client {
return &http.Client{
Timeout: time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
}
func waitForHTTP200(client *http.Client, url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
time.Sleep(10 * time.Millisecond)
}
return errors.New("server did not become ready")
}
func waitForRequestRejection(client *http.Client, url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err != nil {
return err
}
lastErr = errors.New("request still accepted")
resp.Body.Close()
time.Sleep(10 * time.Millisecond)
}
return lastErr
}

View File

@@ -0,0 +1,138 @@
package queueing
import (
"context"
"log/slog"
"gorm.io/gorm"
"wucher/internal/config"
"wucher/internal/queue"
mysqlrepo "wucher/internal/repository/mysql"
sqsrepo "wucher/internal/repository/sqs"
"wucher/internal/resilience"
)
func NewEmailProducer(
ctx context.Context,
queueCfg config.QueueConfig,
sqsCfg config.SQSConfig,
executor *resilience.Executor,
logger *slog.Logger,
) (queue.EmailJobProducer, error) {
producer, err := NewQueueProducer(ctx, sqsCfg, executor, "api")
if err != nil {
return nil, err
}
return queue.NewEmailEnqueuer(NewOutboxSerializer(queueCfg, sqsCfg), producer, logger), nil
}
func NewQueueProducer(ctx context.Context, sqsCfg config.SQSConfig, executor *resilience.Executor, component string) (queue.QueueProducer, error) {
client, err := sqsrepo.NewClient(ctx, sqsCfg)
if err != nil {
return nil, err
}
return sqsrepo.NewQueue(
sqsrepo.NewResilientAPI(client, executor),
sqsCfg,
sqsrepo.WithMetricsComponent(component),
)
}
func NewOutboxSerializer(queueCfg config.QueueConfig, sqsCfg config.SQSConfig) queue.MessageSerializer {
return queue.NewJSONSerializer(
queueCfg.MessageSchemaVersion,
queueCfg.EnableLegacyPayloadFallback,
sqsCfg.QueueType,
sqsCfg.MessageGroupID,
)
}
func NewEmailConsumer(
ctx context.Context,
queueCfg config.QueueConfig,
sqsCfg config.SQSConfig,
executor *resilience.Executor,
) (queue.QueueConsumer, queue.MessageSerializer, error) {
client, err := sqsrepo.NewClient(ctx, sqsCfg)
if err != nil {
return nil, nil, err
}
consumer, err := sqsrepo.NewEmailQueue(
sqsrepo.NewResilientAPI(client, executor),
sqsCfg,
sqsrepo.WithMetricsComponent("email_worker"),
)
if err != nil {
return nil, nil, err
}
serializer := queue.NewJSONSerializer(
queueCfg.MessageSchemaVersion,
queueCfg.EnableLegacyPayloadFallback,
sqsCfg.QueueType,
sqsCfg.MessageGroupID,
)
return consumer, serializer, nil
}
func NewFileProcessingProducer(
ctx context.Context,
queueCfg config.FileQueueConfig,
sqsCfg config.SQSConfig,
executor *resilience.Executor,
logger *slog.Logger,
) (queue.FileProcessingJobProducer, error) {
producer, err := NewQueueProducer(ctx, sqsCfg, executor, "api")
if err != nil {
return nil, err
}
return queue.NewFileProcessingEnqueuer(NewFileProcessingSerializer(queueCfg, sqsCfg), producer, logger), nil
}
func NewFileProcessingSerializer(queueCfg config.FileQueueConfig, sqsCfg config.SQSConfig) queue.FileProcessingMessageSerializer {
return queue.NewFileProcessingJSONSerializer(
queueCfg.MessageSchemaVersion,
sqsCfg.QueueType,
sqsCfg.MessageGroupID,
)
}
func NewFileProcessingConsumer(
ctx context.Context,
queueCfg config.FileQueueConfig,
sqsCfg config.SQSConfig,
executor *resilience.Executor,
) (queue.QueueConsumer, queue.FileProcessingMessageSerializer, error) {
client, err := sqsrepo.NewClient(ctx, sqsCfg)
if err != nil {
return nil, nil, err
}
consumer, err := sqsrepo.NewQueue(
sqsrepo.NewResilientAPI(client, executor),
sqsCfg,
sqsrepo.WithMetricsComponent("file_worker"),
)
if err != nil {
return nil, nil, err
}
serializer := queue.NewFileProcessingJSONSerializer(
queueCfg.MessageSchemaVersion,
sqsCfg.QueueType,
sqsCfg.MessageGroupID,
)
return consumer, serializer, nil
}
func NewIdempotencyStore(db *gorm.DB, queueCfg config.QueueConfig) queue.IdempotencyStore {
if db == nil {
return queue.NewMemoryIdempotencyStore()
}
return mysqlrepo.NewQueueIdempotencyStore(db, queueCfg.IdempotencyKeyPrefix)
}
func NewIdempotencyStoreWithPrefix(db *gorm.DB, keyPrefix string) queue.IdempotencyStore {
if db == nil {
return queue.NewMemoryIdempotencyStore()
}
return mysqlrepo.NewQueueIdempotencyStore(db, keyPrefix)
}

View File

@@ -0,0 +1,241 @@
package worker
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"wucher/internal/config"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/queue"
)
type FileProcessingOutboxDispatcher struct {
repo filemanager.FileProcessingOutboxRepository
producer queue.QueueProducer
logger *slog.Logger
retryPolicy queue.RetryPolicy
pollInterval time.Duration
dispatchTimeout time.Duration
lockTTL time.Duration
maxAttempts int
batchSize int
workerID string
metrics *outboxMetrics
}
func NewFileProcessingOutboxDispatcher(
cfg config.FileQueueConfig,
repo filemanager.FileProcessingOutboxRepository,
producer queue.QueueProducer,
logger *slog.Logger,
) *FileProcessingOutboxDispatcher {
if !cfg.OutboxEnabled || repo == nil || producer == nil {
return nil
}
if logger == nil {
logger = slog.Default()
}
workerID := fmt.Sprintf("%s-%d", hostname(), os.Getpid())
return &FileProcessingOutboxDispatcher{
repo: repo,
producer: producer,
logger: logger.With("component", "file-outbox-dispatcher", "backend", producer.Backend()),
retryPolicy: queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay),
pollInterval: cfg.OutboxPollInterval,
dispatchTimeout: cfg.OutboxDispatchTimeout,
lockTTL: cfg.OutboxLockTTL,
maxAttempts: cfg.OutboxMaxAttempts,
batchSize: cfg.OutboxBatchSize,
workerID: workerID,
metrics: &outboxMetrics{},
}
}
func (d *FileProcessingOutboxDispatcher) Metrics() OutboxMetricsSnapshot {
if d == nil {
return OutboxMetricsSnapshot{}
}
return d.metrics.Snapshot()
}
func (d *FileProcessingOutboxDispatcher) Run(ctx context.Context) error {
if d == nil {
return nil
}
if d.pollInterval <= 0 {
d.pollInterval = time.Second
}
for {
processed, err := d.dispatchOnce(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = err.Error()
})
d.logger.Error("file outbox dispatch cycle failed", slog.Any("error", err))
}
if ctx.Err() != nil {
return nil
}
if processed > 0 {
continue
}
select {
case <-ctx.Done():
return nil
case <-time.After(d.pollInterval):
}
}
}
func (d *FileProcessingOutboxDispatcher) dispatchOnce(ctx context.Context) (int, error) {
now := time.Now().UTC()
messages, err := d.repo.ClaimPendingFileProcessingOutboxMessages(ctx, d.batchSize, now, d.lockTTL, d.workerID)
if err != nil {
return 0, err
}
if len(messages) == 0 {
d.refreshPending(ctx, now)
return 0, nil
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Claimed += int64(len(messages))
})
for i := range messages {
d.dispatchMessage(ctx, &messages[i])
}
d.refreshPending(ctx, time.Now().UTC())
return len(messages), nil
}
func (d *FileProcessingOutboxDispatcher) dispatchMessage(ctx context.Context, message *filemanager.FileProcessingOutboxMessage) {
if message == nil {
return
}
outbound, err := message.OutboundMessage()
if err != nil {
d.failPermanently(ctx, message, err)
return
}
queueMessage := queue.OutboundMessage{
ID: outbound.ID,
Body: outbound.Body,
Attributes: outbound.Attributes,
MessageGroupID: outbound.MessageGroupID,
DeduplicationID: outbound.DeduplicationID,
}
dispatchCtx := ctx
cancel := func() {}
if d.dispatchTimeout > 0 {
dispatchCtx, cancel = context.WithTimeout(ctx, d.dispatchTimeout)
}
err = d.producer.Publish(dispatchCtx, queueMessage)
cancel()
if err == nil {
publishedAt := time.Now().UTC()
if markErr := d.repo.MarkFileProcessingOutboxMessagePublished(ctx, message.ID, publishedAt); markErr != nil {
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = markErr.Error()
})
d.logger.Error("file outbox publish marked failed",
slog.String("message_id", message.MessageID),
slog.Any("error", markErr),
)
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Published++
snapshot.LastSuccessAt = publishedAt
snapshot.LastError = ""
})
d.logger.Info("file outbox message published",
slog.String("message_id", message.MessageID),
slog.String("job_type", message.JobType),
slog.String("correlation_id", message.CorrelationID),
slog.Int("attempt", message.Attempts),
)
return
}
if message.Attempts >= d.maxAttempts {
d.failPermanently(ctx, message, err)
return
}
decision := d.retryPolicy.Decide(&queue.Delivery{ID: message.MessageID, ReceiveCount: message.Attempts}, nil, err)
nextAttemptAt := time.Now().UTC().Add(decision.Delay)
if retryErr := d.repo.MarkFileProcessingOutboxMessageRetry(ctx, message.ID, nextAttemptAt, err.Error()); retryErr != nil {
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = retryErr.Error()
})
d.logger.Error("file outbox retry scheduling failed",
slog.String("message_id", message.MessageID),
slog.Any("error", retryErr),
)
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Retried++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = err.Error()
})
d.logger.Warn("file outbox publish failed; scheduled retry",
slog.String("message_id", message.MessageID),
slog.String("job_type", message.JobType),
slog.Duration("retry_in", decision.Delay),
slog.Int("attempt", message.Attempts),
slog.Any("error", err),
)
}
func (d *FileProcessingOutboxDispatcher) failPermanently(ctx context.Context, message *filemanager.FileProcessingOutboxMessage, err error) {
if message == nil || err == nil {
return
}
failedAt := time.Now().UTC()
if markErr := d.repo.MarkFileProcessingOutboxMessageDead(ctx, message.ID, failedAt, err.Error()); markErr != nil {
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = failedAt
snapshot.LastError = markErr.Error()
})
d.logger.Error("file outbox dead-letter marking failed",
slog.String("message_id", message.MessageID),
slog.Any("error", markErr),
)
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Dead++
snapshot.LastFailureAt = failedAt
snapshot.LastError = err.Error()
})
d.logger.Error("file outbox message marked dead",
slog.String("message_id", message.MessageID),
slog.String("job_type", message.JobType),
slog.Int("attempt", message.Attempts),
slog.Any("error", err),
)
}
func (d *FileProcessingOutboxDispatcher) refreshPending(ctx context.Context, now time.Time) {
count, err := d.repo.CountPendingFileProcessingOutboxMessages(ctx, now)
if err != nil {
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.ApproximatePending = count
})
}

View File

@@ -0,0 +1,219 @@
package worker
import (
"context"
"errors"
"testing"
"time"
"wucher/internal/config"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/queue"
)
type stubFileOutboxRepo struct {
messages map[string]*filemanager.FileProcessingOutboxMessage
}
func newStubFileOutboxRepo(messages ...*filemanager.FileProcessingOutboxMessage) *stubFileOutboxRepo {
repo := &stubFileOutboxRepo{messages: map[string]*filemanager.FileProcessingOutboxMessage{}}
for _, message := range messages {
if message == nil {
continue
}
cp := *message
cp.ID = append([]byte(nil), message.ID...)
cp.Body = append([]byte(nil), message.Body...)
cp.Attributes = append([]byte(nil), message.Attributes...)
repo.messages[string(cp.ID)] = &cp
}
return repo
}
func (r *stubFileOutboxRepo) CreateFileProcessingOutboxMessage(_ context.Context, message *filemanager.FileProcessingOutboxMessage) error {
if message == nil {
return errors.New("nil message")
}
cp := *message
cp.ID = append([]byte(nil), message.ID...)
cp.Body = append([]byte(nil), message.Body...)
cp.Attributes = append([]byte(nil), message.Attributes...)
r.messages[string(cp.ID)] = &cp
return nil
}
func (r *stubFileOutboxRepo) ClaimPendingFileProcessingOutboxMessages(_ context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]filemanager.FileProcessingOutboxMessage, error) {
if limit <= 0 {
limit = 1
}
if lockTTL <= 0 {
lockTTL = time.Minute
}
out := make([]filemanager.FileProcessingOutboxMessage, 0, limit)
staleBefore := now.Add(-lockTTL)
for _, message := range r.messages {
if len(out) >= limit {
break
}
switch {
case message.Status == filemanager.FileProcessingOutboxStatusPending && !message.AvailableAt.After(now):
case message.Status == filemanager.FileProcessingOutboxStatusProcessing && message.LockedAt != nil && message.LockedAt.Before(staleBefore):
default:
continue
}
message.Status = filemanager.FileProcessingOutboxStatusProcessing
message.Attempts++
ts := now
message.LockedAt = &ts
message.LockedBy = workerID
cp := *message
out = append(out, cp)
}
return out, nil
}
func (r *stubFileOutboxRepo) MarkFileProcessingOutboxMessagePublished(_ context.Context, id []byte, publishedAt time.Time) error {
message := r.messages[string(id)]
if message == nil {
return errors.New("not found")
}
message.Status = filemanager.FileProcessingOutboxStatusPublished
message.PublishedAt = &publishedAt
message.LockedAt = nil
message.LockedBy = ""
message.LastError = ""
return nil
}
func (r *stubFileOutboxRepo) MarkFileProcessingOutboxMessageRetry(_ context.Context, id []byte, availableAt time.Time, lastErr string) error {
message := r.messages[string(id)]
if message == nil {
return errors.New("not found")
}
message.Status = filemanager.FileProcessingOutboxStatusPending
message.AvailableAt = availableAt
message.LockedAt = nil
message.LockedBy = ""
message.LastError = lastErr
return nil
}
func (r *stubFileOutboxRepo) MarkFileProcessingOutboxMessageDead(_ context.Context, id []byte, failedAt time.Time, lastErr string) error {
message := r.messages[string(id)]
if message == nil {
return errors.New("not found")
}
message.Status = filemanager.FileProcessingOutboxStatusDead
message.AvailableAt = failedAt
message.LockedAt = nil
message.LockedBy = ""
message.LastError = lastErr
return nil
}
func (r *stubFileOutboxRepo) CountPendingFileProcessingOutboxMessages(_ context.Context, now time.Time) (int64, error) {
var count int64
for _, message := range r.messages {
if (message.Status == filemanager.FileProcessingOutboxStatusPending && !message.AvailableAt.After(now)) || message.Status == filemanager.FileProcessingOutboxStatusProcessing {
count++
}
}
return count, nil
}
func newFileOutboxRecord(t *testing.T, job queue.FileProcessingJob) *filemanager.FileProcessingOutboxMessage {
t.Helper()
serializer := queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, "")
message, err := serializer.Serialize(context.Background(), job)
if err != nil {
t.Fatalf("serialize: %v", err)
}
record, err := filemanager.NewFileProcessingOutboxMessage(time.Now().UTC(), &filemanager.FileProcessingOutboundMessage{
ID: message.ID,
Body: append([]byte(nil), message.Body...),
Attributes: message.Attributes,
MessageGroupID: message.MessageGroupID,
DeduplicationID: message.DeduplicationID,
})
if err != nil {
t.Fatalf("new outbox message: %v", err)
}
return record
}
func TestFileProcessingOutboxDispatcherPublishSuccess(t *testing.T) {
record := newFileOutboxRecord(t, queue.FileProcessingJob{
FileUUID: "01961f5c-6471-7b81-9df5-2e2e1cf7e498",
Bucket: "bucket-a",
ObjectKey: "files/2026/04/05/object.pdf",
SizeBytes: 128,
})
repo := newStubFileOutboxRepo(record)
producer := &stubQueueProducer{}
dispatcher := NewFileProcessingOutboxDispatcher(config.FileQueueConfig{
OutboxEnabled: true,
OutboxBatchSize: 10,
OutboxDispatchTimeout: time.Second,
OutboxLockTTL: time.Minute,
OutboxMaxAttempts: 3,
WorkerBackoffMin: time.Second,
WorkerBackoffMax: time.Minute,
}, repo, producer, nil)
processed, err := dispatcher.dispatchOnce(context.Background())
if err != nil {
t.Fatalf("dispatch once: %v", err)
}
if processed != 1 {
t.Fatalf("expected one processed message, got %d", processed)
}
if len(producer.published) != 1 {
t.Fatalf("expected one published message, got %d", len(producer.published))
}
if repo.messages[string(record.ID)].Status != filemanager.FileProcessingOutboxStatusPublished {
t.Fatalf("expected message published, got %s", repo.messages[string(record.ID)].Status)
}
if got := dispatcher.Metrics().Published; got != 1 {
t.Fatalf("expected published metric=1, got %d", got)
}
}
func TestFileProcessingOutboxDispatcherRetryOnPublishFailure(t *testing.T) {
record := newFileOutboxRecord(t, queue.FileProcessingJob{
FileUUID: "01961f5c-6471-7b81-9df5-2e2e1cf7e499",
Bucket: "bucket-a",
ObjectKey: "files/2026/04/05/object.pdf",
SizeBytes: 256,
})
repo := newStubFileOutboxRepo(record)
producer := &stubQueueProducer{err: errors.New("queue unavailable")}
dispatcher := NewFileProcessingOutboxDispatcher(config.FileQueueConfig{
OutboxEnabled: true,
OutboxBatchSize: 10,
OutboxLockTTL: time.Minute,
OutboxMaxAttempts: 3,
WorkerBackoffMin: time.Second,
WorkerBackoffMax: time.Minute,
}, repo, producer, nil)
processed, err := dispatcher.dispatchOnce(context.Background())
if err != nil {
t.Fatalf("dispatch once: %v", err)
}
if processed != 1 {
t.Fatalf("expected one processed message, got %d", processed)
}
got := repo.messages[string(record.ID)]
if got.Status != filemanager.FileProcessingOutboxStatusPending {
t.Fatalf("expected message returned to pending, got %s", got.Status)
}
if got.Attempts != 1 {
t.Fatalf("expected attempts=1, got %d", got.Attempts)
}
if !got.AvailableAt.After(time.Now().Add(-time.Second)) {
t.Fatalf("expected retry to schedule next attempt in the future")
}
if metrics := dispatcher.Metrics(); metrics.Retried != 1 {
t.Fatalf("expected retried metric=1, got %d", metrics.Retried)
}
}

View File

@@ -0,0 +1,193 @@
package worker
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"wucher/internal/config"
"wucher/internal/shared/pkg/metrics"
"wucher/internal/queue"
)
type FileProcessingRunner struct {
worker *queue.FileProcessingWorker
consumer queue.QueueConsumer
dispatcher *FileProcessingOutboxDispatcher
logger *slog.Logger
monitorAddr string
depthPollInterval time.Duration
shutdownTimeout time.Duration
backend string
monitorServer *http.Server
}
func NewFileProcessingRunner(
cfg config.FileQueueConfig,
consumer queue.QueueConsumer,
serializer queue.FileProcessingMessageSerializer,
handler queue.FileProcessingJobHandler,
idempotency queue.IdempotencyStore,
dispatcher *FileProcessingOutboxDispatcher,
logger *slog.Logger,
) *FileProcessingRunner {
if logger == nil {
logger = slog.Default()
}
metrics := queue.NewMetrics()
health := queue.NewHealthReporter(cfg.WorkerHealthErrorThreshold)
worker := queue.NewFileProcessingWorker(queue.WorkerConfig{
Concurrency: cfg.WorkerConcurrency,
MaxBatchSize: cfg.WorkerMaxBatchSize,
ProcessTimeout: cfg.WorkerProcessTimeout,
ProcessingTTL: cfg.ProcessingTTL,
IdempotencyTTL: cfg.IdempotencyTTL,
PollErrorDelay: cfg.WorkerBackoffMin,
MetricsComponent: "file_worker",
}, consumer, serializer, handler, queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay), idempotency, metrics, health, logger)
backend := ""
if consumer != nil {
backend = consumer.Backend()
}
return &FileProcessingRunner{
worker: worker,
consumer: consumer,
dispatcher: dispatcher,
logger: logger,
monitorAddr: cfg.WorkerMonitorAddr,
depthPollInterval: cfg.WorkerDepthPollInterval,
shutdownTimeout: cfg.WorkerShutdownTimeout,
backend: backend,
}
}
func (r *FileProcessingRunner) Start(ctx context.Context) error {
if r.worker == nil {
return errors.New("worker is required")
}
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
if err := r.startMonitor(); err != nil {
return err
}
pollerDone := make(chan struct{})
go func() {
defer close(pollerDone)
r.runDepthPoller(runCtx)
}()
workerErrCh := make(chan error, 1)
go func() {
workerErrCh <- r.worker.Run(runCtx)
}()
var dispatcherErrCh chan error
if r.dispatcher != nil {
dispatcherErrCh = make(chan error, 1)
go func() {
dispatcherErrCh <- r.dispatcher.Run(runCtx)
}()
}
var err error
select {
case err = <-workerErrCh:
cancelRun()
if dispatcherErrCh != nil {
if dispatcherErr := <-dispatcherErrCh; err == nil {
err = dispatcherErr
}
}
case err = <-dispatcherErrCh:
cancelRun()
if workerErr := <-workerErrCh; err == nil {
err = workerErr
}
}
cancelRun()
shutdownCtx, cancel := context.WithTimeout(context.Background(), r.shutdownTimeout)
defer cancel()
if r.monitorServer != nil {
_ = r.monitorServer.Shutdown(shutdownCtx)
}
if r.consumer != nil {
_ = r.consumer.Close()
}
<-pollerDone
return err
}
func (r *FileProcessingRunner) startMonitor() error {
if r.monitorAddr == "" {
return nil
}
mux := http.NewServeMux()
mux.HandleFunc("/health", r.handleHealth)
mux.Handle("/metrics", metrics.PromHTTPHandler())
r.monitorServer = &http.Server{
Addr: r.monitorAddr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
r.logger.Info("file worker monitor listening",
slog.String("addr", r.monitorAddr),
slog.String("backend", r.backend),
)
if err := r.monitorServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
r.logger.Error("file worker monitor failed", slog.Any("error", err))
}
}()
return nil
}
func (r *FileProcessingRunner) runDepthPoller(ctx context.Context) {
if r.depthPollInterval <= 0 {
return
}
ticker := time.NewTicker(r.depthPollInterval)
defer ticker.Stop()
for {
refreshCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_ = r.worker.RefreshQueueDepth(refreshCtx)
cancel()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (r *FileProcessingRunner) handleHealth(w http.ResponseWriter, _ *http.Request) {
snapshot := struct {
Backend string `json:"backend"`
Health queue.HealthSnapshot `json:"health"`
Metrics queue.MetricsSnapshot `json:"metrics"`
Outbox *OutboxMetricsSnapshot `json:"outbox,omitempty"`
}{
Backend: r.backend,
Health: r.worker.Health().Snapshot(),
Metrics: r.worker.Metrics().Snapshot(),
}
if r.dispatcher != nil {
snapshot.Outbox = ptr(r.dispatcher.Metrics())
}
statusCode := http.StatusOK
if snapshot.Health.Status == "degraded" {
statusCode = http.StatusServiceUnavailable
}
writeFileWorkerJSON(w, statusCode, snapshot)
}
func writeFileWorkerJSON(w http.ResponseWriter, statusCode int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(payload)
}

View File

@@ -0,0 +1,342 @@
package worker
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"sync"
"time"
"wucher/internal/config"
"wucher/internal/domain/auth"
"wucher/internal/queue"
)
type OutboxMetricsSnapshot struct {
Claimed int64 `json:"claimed"`
Published int64 `json:"published"`
Retried int64 `json:"retried"`
Dead int64 `json:"dead"`
Failures int64 `json:"failures"`
ApproximatePending int64 `json:"approximate_pending"`
LastSuccessAt time.Time `json:"last_success_at"`
LastFailureAt time.Time `json:"last_failure_at"`
LastError string `json:"last_error,omitempty"`
}
type outboxMetrics struct {
mu sync.Mutex
snapshot OutboxMetricsSnapshot
}
func (m *outboxMetrics) Snapshot() OutboxMetricsSnapshot {
if m == nil {
return OutboxMetricsSnapshot{}
}
m.mu.Lock()
defer m.mu.Unlock()
return m.snapshot
}
func (m *outboxMetrics) update(fn func(snapshot *OutboxMetricsSnapshot)) {
if m == nil || fn == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
fn(&m.snapshot)
}
type OutboxDispatcher struct {
repo auth.EmailOutboxRepository
producer queue.QueueProducer
logger *slog.Logger
retryPolicy queue.RetryPolicy
pollInterval time.Duration
dispatchTimeout time.Duration
lockTTL time.Duration
maxAttempts int
batchSize int
workerID string
metrics *outboxMetrics
}
func NewOutboxDispatcher(
cfg config.QueueConfig,
repo auth.EmailOutboxRepository,
producer queue.QueueProducer,
logger *slog.Logger,
) *OutboxDispatcher {
if !cfg.OutboxEnabled || repo == nil || producer == nil {
return nil
}
if logger == nil {
logger = slog.Default()
}
workerID := fmt.Sprintf("%s-%d", hostname(), os.Getpid())
return &OutboxDispatcher{
repo: repo,
producer: producer,
logger: logger.With("component", "outbox-dispatcher", "backend", producer.Backend()),
retryPolicy: queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay),
pollInterval: cfg.OutboxPollInterval,
dispatchTimeout: cfg.OutboxDispatchTimeout,
lockTTL: cfg.OutboxLockTTL,
maxAttempts: cfg.OutboxMaxAttempts,
batchSize: cfg.OutboxBatchSize,
workerID: workerID,
metrics: &outboxMetrics{},
}
}
func (d *OutboxDispatcher) Metrics() OutboxMetricsSnapshot {
if d == nil {
return OutboxMetricsSnapshot{}
}
return d.metrics.Snapshot()
}
func (d *OutboxDispatcher) Run(ctx context.Context) error {
if d == nil {
return nil
}
if d.pollInterval <= 0 {
d.pollInterval = time.Second
}
for {
processed, err := d.dispatchOnce(ctx)
if err != nil {
if ctx.Err() != nil {
return nil
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = err.Error()
})
d.logger.Error("outbox dispatch cycle failed", slog.Any("error", err))
}
if ctx.Err() != nil {
return nil
}
if processed > 0 {
continue
}
select {
case <-ctx.Done():
return nil
case <-time.After(d.pollInterval):
}
}
}
func (d *OutboxDispatcher) dispatchOnce(ctx context.Context) (int, error) {
now := time.Now().UTC()
messages, err := d.repo.ClaimPendingEmailOutboxMessages(ctx, d.batchSize, now, d.lockTTL, d.workerID)
if err != nil {
return 0, err
}
if len(messages) == 0 {
d.refreshPending(ctx, now)
return 0, nil
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Claimed += int64(len(messages))
})
for i := range messages {
d.dispatchMessage(ctx, &messages[i])
}
d.refreshPending(ctx, time.Now().UTC())
return len(messages), nil
}
func (d *OutboxDispatcher) dispatchMessage(ctx context.Context, message *auth.EmailOutboxMessage) {
if message == nil {
return
}
outbound, err := message.OutboundMessage()
if err != nil {
d.failPermanently(ctx, message, err)
return
}
if shouldSkipOutboxEmailPublish(outbound) {
publishedAt := time.Now().UTC()
if markErr := d.repo.MarkEmailOutboxMessagePublished(ctx, message.ID, publishedAt); markErr != nil {
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = markErr.Error()
})
d.logger.Error("outbox skip marked failed",
slog.String("message_id", message.MessageID),
slog.Any("error", markErr),
)
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Published++
snapshot.LastSuccessAt = publishedAt
snapshot.LastError = ""
})
d.logger.Info("outbox message skipped",
slog.String("message_id", message.MessageID),
slog.String("job_type", message.JobType),
slog.String("correlation_id", message.CorrelationID),
)
return
}
dispatchCtx := ctx
cancel := func() {}
if d.dispatchTimeout > 0 {
dispatchCtx, cancel = context.WithTimeout(ctx, d.dispatchTimeout)
}
err = d.producer.Publish(dispatchCtx, *outbound)
cancel()
if err == nil {
publishedAt := time.Now().UTC()
if markErr := d.repo.MarkEmailOutboxMessagePublished(ctx, message.ID, publishedAt); markErr != nil {
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = markErr.Error()
})
d.logger.Error("outbox publish marked failed",
slog.String("message_id", message.MessageID),
slog.Any("error", markErr),
)
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Published++
snapshot.LastSuccessAt = publishedAt
snapshot.LastError = ""
})
d.logger.Info("outbox message published",
slog.String("message_id", message.MessageID),
slog.String("job_type", message.JobType),
slog.String("correlation_id", message.CorrelationID),
slog.Int("attempt", message.Attempts),
)
return
}
if message.Attempts >= d.maxAttempts {
d.failPermanently(ctx, message, err)
return
}
decision := d.retryPolicy.Decide(&queue.Delivery{ID: message.MessageID, ReceiveCount: message.Attempts}, nil, err)
nextAttemptAt := time.Now().UTC().Add(decision.Delay)
if retryErr := d.repo.MarkEmailOutboxMessageRetry(ctx, message.ID, nextAttemptAt, err.Error()); retryErr != nil {
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = retryErr.Error()
})
d.logger.Error("outbox retry scheduling failed",
slog.String("message_id", message.MessageID),
slog.Any("error", retryErr),
)
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Retried++
snapshot.LastFailureAt = time.Now().UTC()
snapshot.LastError = err.Error()
})
d.logger.Warn("outbox publish failed; scheduled retry",
slog.String("message_id", message.MessageID),
slog.String("job_type", message.JobType),
slog.Duration("retry_in", decision.Delay),
slog.Int("attempt", message.Attempts),
slog.Any("error", err),
)
}
func shouldSkipOutboxEmailPublish(outbound *queue.OutboundMessage) bool {
if outbound == nil || len(outbound.Body) == 0 {
return false
}
var envelope struct {
Payload struct {
MessageType string `json:"message_type"`
Metadata map[string]string `json:"metadata"`
Tags map[string]string `json:"tags"`
} `json:"payload"`
}
if err := json.Unmarshal(outbound.Body, &envelope); err == nil {
if strings.EqualFold(strings.TrimSpace(envelope.Payload.Metadata["skip_send"]), "true") {
return true
}
if strings.EqualFold(strings.TrimSpace(envelope.Payload.MessageType), "email_verify") &&
strings.EqualFold(strings.TrimSpace(envelope.Payload.Tags["source"]), "register") {
return true
}
return false
}
var legacyJob struct {
MessageType string `json:"message_type"`
Metadata map[string]string `json:"metadata"`
Tags map[string]string `json:"tags"`
}
if err := json.Unmarshal(outbound.Body, &legacyJob); err != nil {
return false
}
if strings.EqualFold(strings.TrimSpace(legacyJob.Metadata["skip_send"]), "true") {
return true
}
return strings.EqualFold(strings.TrimSpace(legacyJob.MessageType), "email_verify") &&
strings.EqualFold(strings.TrimSpace(legacyJob.Tags["source"]), "register")
}
func (d *OutboxDispatcher) failPermanently(ctx context.Context, message *auth.EmailOutboxMessage, err error) {
if message == nil || err == nil {
return
}
failedAt := time.Now().UTC()
if markErr := d.repo.MarkEmailOutboxMessageDead(ctx, message.ID, failedAt, err.Error()); markErr != nil {
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Failures++
snapshot.LastFailureAt = failedAt
snapshot.LastError = markErr.Error()
})
d.logger.Error("outbox dead-letter marking failed",
slog.String("message_id", message.MessageID),
slog.Any("error", markErr),
)
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.Dead++
snapshot.LastFailureAt = failedAt
snapshot.LastError = err.Error()
})
d.logger.Error("outbox message marked dead",
slog.String("message_id", message.MessageID),
slog.String("job_type", message.JobType),
slog.Int("attempt", message.Attempts),
slog.Any("error", err),
)
}
func (d *OutboxDispatcher) refreshPending(ctx context.Context, now time.Time) {
count, err := d.repo.CountPendingEmailOutboxMessages(ctx, now)
if err != nil {
return
}
d.metrics.update(func(snapshot *OutboxMetricsSnapshot) {
snapshot.ApproximatePending = count
})
}
func hostname() string {
name, err := os.Hostname()
if err != nil || strings.TrimSpace(name) == "" {
return "worker"
}
return strings.TrimSpace(name)
}

View File

@@ -0,0 +1,304 @@
package worker
import (
"context"
"errors"
"testing"
"time"
"wucher/internal/config"
"wucher/internal/domain/auth"
"wucher/internal/queue"
)
type stubOutboxRepo struct {
messages map[string]*auth.EmailOutboxMessage
}
func newStubOutboxRepo(messages ...*auth.EmailOutboxMessage) *stubOutboxRepo {
repo := &stubOutboxRepo{messages: map[string]*auth.EmailOutboxMessage{}}
for _, message := range messages {
if message == nil {
continue
}
cp := *message
cp.ID = append([]byte(nil), message.ID...)
cp.Body = append([]byte(nil), message.Body...)
cp.Attributes = append([]byte(nil), message.Attributes...)
repo.messages[string(cp.ID)] = &cp
}
return repo
}
func (r *stubOutboxRepo) CreateEmailOutboxMessage(_ context.Context, message *auth.EmailOutboxMessage) error {
if message == nil {
return errors.New("nil message")
}
cp := *message
cp.ID = append([]byte(nil), message.ID...)
cp.Body = append([]byte(nil), message.Body...)
cp.Attributes = append([]byte(nil), message.Attributes...)
r.messages[string(cp.ID)] = &cp
return nil
}
func (r *stubOutboxRepo) ClaimPendingEmailOutboxMessages(_ context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]auth.EmailOutboxMessage, error) {
if limit <= 0 {
limit = 1
}
if lockTTL <= 0 {
lockTTL = time.Minute
}
out := make([]auth.EmailOutboxMessage, 0, limit)
staleBefore := now.Add(-lockTTL)
for _, message := range r.messages {
if len(out) >= limit {
break
}
switch {
case message.Status == auth.EmailOutboxStatusPending && !message.AvailableAt.After(now):
case message.Status == auth.EmailOutboxStatusProcessing && message.LockedAt != nil && message.LockedAt.Before(staleBefore):
default:
continue
}
message.Status = auth.EmailOutboxStatusProcessing
message.Attempts++
ts := now
message.LockedAt = &ts
message.LockedBy = workerID
cp := *message
out = append(out, cp)
}
return out, nil
}
func (r *stubOutboxRepo) MarkEmailOutboxMessagePublished(_ context.Context, id []byte, publishedAt time.Time) error {
message := r.messages[string(id)]
if message == nil {
return errors.New("not found")
}
message.Status = auth.EmailOutboxStatusPublished
message.PublishedAt = &publishedAt
message.LockedAt = nil
message.LockedBy = ""
message.LastError = ""
return nil
}
func (r *stubOutboxRepo) MarkEmailOutboxMessageRetry(_ context.Context, id []byte, availableAt time.Time, lastErr string) error {
message := r.messages[string(id)]
if message == nil {
return errors.New("not found")
}
message.Status = auth.EmailOutboxStatusPending
message.AvailableAt = availableAt
message.LockedAt = nil
message.LockedBy = ""
message.LastError = lastErr
return nil
}
func (r *stubOutboxRepo) MarkEmailOutboxMessageDead(_ context.Context, id []byte, failedAt time.Time, lastErr string) error {
message := r.messages[string(id)]
if message == nil {
return errors.New("not found")
}
message.Status = auth.EmailOutboxStatusDead
message.AvailableAt = failedAt
message.LockedAt = nil
message.LockedBy = ""
message.LastError = lastErr
return nil
}
func (r *stubOutboxRepo) CountPendingEmailOutboxMessages(_ context.Context, now time.Time) (int64, error) {
var count int64
for _, message := range r.messages {
if (message.Status == auth.EmailOutboxStatusPending && !message.AvailableAt.After(now)) || message.Status == auth.EmailOutboxStatusProcessing {
count++
}
}
return count, nil
}
type stubQueueProducer struct {
published []queue.OutboundMessage
err error
}
func (p *stubQueueProducer) Backend() string {
return "stub"
}
func (p *stubQueueProducer) Publish(_ context.Context, message queue.OutboundMessage) error {
if p.err != nil {
return p.err
}
p.published = append(p.published, message)
return nil
}
func newOutboxRecord(t *testing.T, job queue.EmailJob) *auth.EmailOutboxMessage {
t.Helper()
serializer := queue.NewJSONSerializer("v1", true, queue.QueueTypeStandard, "")
message, err := serializer.Serialize(context.Background(), job)
if err != nil {
t.Fatalf("serialize: %v", err)
}
record, err := auth.NewEmailOutboxMessage(time.Now().UTC(), message)
if err != nil {
t.Fatalf("new outbox message: %v", err)
}
return record
}
func TestOutboxDispatcherPublishSuccess(t *testing.T) {
record := newOutboxRecord(t, queue.EmailJob{
Type: "email_verify",
To: "user@example.com",
Subject: "Verify",
Body: "hello",
})
repo := newStubOutboxRepo(record)
producer := &stubQueueProducer{}
dispatcher := NewOutboxDispatcher(config.QueueConfig{
OutboxEnabled: true,
OutboxBatchSize: 10,
OutboxDispatchTimeout: time.Second,
OutboxLockTTL: time.Minute,
OutboxMaxAttempts: 3,
WorkerBackoffMin: time.Second,
WorkerBackoffMax: time.Minute,
}, repo, producer, nil)
processed, err := dispatcher.dispatchOnce(context.Background())
if err != nil {
t.Fatalf("dispatch once: %v", err)
}
if processed != 1 {
t.Fatalf("expected one processed message, got %d", processed)
}
if len(producer.published) != 1 {
t.Fatalf("expected one published message, got %d", len(producer.published))
}
if repo.messages[string(record.ID)].Status != auth.EmailOutboxStatusPublished {
t.Fatalf("expected message published, got %s", repo.messages[string(record.ID)].Status)
}
if got := dispatcher.Metrics().Published; got != 1 {
t.Fatalf("expected published metric=1, got %d", got)
}
}
func TestOutboxDispatcherSkipRegisterVerifyEmail(t *testing.T) {
record := newOutboxRecord(t, queue.EmailJob{
MessageType: "email_verify",
To: "user@example.com",
Subject: "Verify",
TextBody: "hello",
Metadata: map[string]string{
"skip_send": "true",
},
Tags: map[string]string{
"source": "register",
},
})
repo := newStubOutboxRepo(record)
producer := &stubQueueProducer{}
dispatcher := NewOutboxDispatcher(config.QueueConfig{
OutboxEnabled: true,
OutboxBatchSize: 10,
OutboxDispatchTimeout: time.Second,
OutboxLockTTL: time.Minute,
OutboxMaxAttempts: 3,
WorkerBackoffMin: time.Second,
WorkerBackoffMax: time.Minute,
}, repo, producer, nil)
processed, err := dispatcher.dispatchOnce(context.Background())
if err != nil {
t.Fatalf("dispatch once: %v", err)
}
if processed != 1 {
t.Fatalf("expected one processed message, got %d", processed)
}
if len(producer.published) != 0 {
t.Fatalf("expected zero published message, got %d", len(producer.published))
}
if repo.messages[string(record.ID)].Status != auth.EmailOutboxStatusPublished {
t.Fatalf("expected message published status after skip, got %s", repo.messages[string(record.ID)].Status)
}
}
func TestOutboxDispatcherRetryOnPublishFailure(t *testing.T) {
record := newOutboxRecord(t, queue.EmailJob{
Type: "password_reset",
To: "user@example.com",
Subject: "Reset",
Body: "reset",
})
repo := newStubOutboxRepo(record)
producer := &stubQueueProducer{err: errors.New("queue unavailable")}
dispatcher := NewOutboxDispatcher(config.QueueConfig{
OutboxEnabled: true,
OutboxBatchSize: 10,
OutboxLockTTL: time.Minute,
OutboxMaxAttempts: 3,
WorkerBackoffMin: time.Second,
WorkerBackoffMax: time.Minute,
}, repo, producer, nil)
processed, err := dispatcher.dispatchOnce(context.Background())
if err != nil {
t.Fatalf("dispatch once: %v", err)
}
if processed != 1 {
t.Fatalf("expected one processed message, got %d", processed)
}
got := repo.messages[string(record.ID)]
if got.Status != auth.EmailOutboxStatusPending {
t.Fatalf("expected message returned to pending, got %s", got.Status)
}
if got.Attempts != 1 {
t.Fatalf("expected attempts=1, got %d", got.Attempts)
}
if !got.AvailableAt.After(time.Now().Add(-time.Second)) {
t.Fatalf("expected retry to schedule next attempt in the future")
}
if metrics := dispatcher.Metrics(); metrics.Retried != 1 {
t.Fatalf("expected retried metric=1, got %d", metrics.Retried)
}
}
func TestOutboxDispatcherMarksDeadAfterMaxAttempts(t *testing.T) {
record := newOutboxRecord(t, queue.EmailJob{
Type: "invite_set_password",
To: "user@example.com",
Subject: "Invite",
Body: "invite",
})
record.Attempts = 2
repo := newStubOutboxRepo(record)
producer := &stubQueueProducer{err: errors.New("queue unavailable")}
dispatcher := NewOutboxDispatcher(config.QueueConfig{
OutboxEnabled: true,
OutboxBatchSize: 10,
OutboxLockTTL: time.Minute,
OutboxMaxAttempts: 3,
WorkerBackoffMin: time.Second,
WorkerBackoffMax: time.Minute,
}, repo, producer, nil)
processed, err := dispatcher.dispatchOnce(context.Background())
if err != nil {
t.Fatalf("dispatch once: %v", err)
}
if processed != 1 {
t.Fatalf("expected one processed message, got %d", processed)
}
if got := repo.messages[string(record.ID)].Status; got != auth.EmailOutboxStatusDead {
t.Fatalf("expected dead status, got %s", got)
}
if got := dispatcher.Metrics().Dead; got != 1 {
t.Fatalf("expected dead metric=1, got %d", got)
}
}

View File

@@ -0,0 +1,196 @@
package worker
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"wucher/internal/config"
"wucher/internal/shared/pkg/metrics"
"wucher/internal/queue"
)
type Runner struct {
worker *queue.Worker
consumer queue.QueueConsumer
dispatcher *OutboxDispatcher
logger *slog.Logger
monitorAddr string
depthPollInterval time.Duration
shutdownTimeout time.Duration
backend string
monitorServer *http.Server
}
func NewRunner(
cfg config.QueueConfig,
consumer queue.QueueConsumer,
serializer queue.MessageSerializer,
handler queue.EmailJobHandler,
idempotency queue.IdempotencyStore,
dispatcher *OutboxDispatcher,
logger *slog.Logger,
) *Runner {
if logger == nil {
logger = slog.Default()
}
metrics := queue.NewMetrics()
health := queue.NewHealthReporter(cfg.WorkerHealthErrorThreshold)
worker := queue.NewWorker(queue.WorkerConfig{
Concurrency: cfg.WorkerConcurrency,
MaxBatchSize: cfg.WorkerMaxBatchSize,
ProcessTimeout: cfg.WorkerProcessTimeout,
ProcessingTTL: cfg.ProcessingTTL,
IdempotencyTTL: cfg.IdempotencyTTL,
PollErrorDelay: cfg.WorkerBackoffMin,
MetricsComponent: "email_worker",
}, consumer, serializer, handler, queue.NewExponentialBackoffPolicy(cfg.WorkerBackoffMin, cfg.WorkerBackoffMax, cfg.WorkerPermanentFailureDelay), idempotency, metrics, health, logger)
backend := ""
if consumer != nil {
backend = consumer.Backend()
}
return &Runner{
worker: worker,
consumer: consumer,
dispatcher: dispatcher,
logger: logger,
monitorAddr: cfg.WorkerMonitorAddr,
depthPollInterval: cfg.WorkerDepthPollInterval,
shutdownTimeout: cfg.WorkerShutdownTimeout,
backend: backend,
}
}
func (r *Runner) Start(ctx context.Context) error {
if r.worker == nil {
return errors.New("worker is required")
}
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
if err := r.startMonitor(); err != nil {
return err
}
pollerDone := make(chan struct{})
go func() {
defer close(pollerDone)
r.runDepthPoller(runCtx)
}()
workerErrCh := make(chan error, 1)
go func() {
workerErrCh <- r.worker.Run(runCtx)
}()
var dispatcherErrCh chan error
if r.dispatcher != nil {
dispatcherErrCh = make(chan error, 1)
go func() {
dispatcherErrCh <- r.dispatcher.Run(runCtx)
}()
}
var err error
select {
case err = <-workerErrCh:
cancelRun()
if dispatcherErrCh != nil {
if dispatcherErr := <-dispatcherErrCh; err == nil {
err = dispatcherErr
}
}
case err = <-dispatcherErrCh:
cancelRun()
if workerErr := <-workerErrCh; err == nil {
err = workerErr
}
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), r.shutdownTimeout)
defer cancel()
if r.monitorServer != nil {
_ = r.monitorServer.Shutdown(shutdownCtx)
}
if r.consumer != nil {
_ = r.consumer.Close()
}
<-pollerDone
return err
}
func (r *Runner) startMonitor() error {
if r.monitorAddr == "" {
return nil
}
mux := http.NewServeMux()
mux.HandleFunc("/health", r.handleHealth)
mux.Handle("/metrics", metrics.PromHTTPHandler())
r.monitorServer = &http.Server{
Addr: r.monitorAddr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
r.logger.Info("worker monitor listening",
slog.String("addr", r.monitorAddr),
slog.String("backend", r.backend),
)
if err := r.monitorServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
r.logger.Error("worker monitor failed", slog.Any("error", err))
}
}()
return nil
}
func (r *Runner) runDepthPoller(ctx context.Context) {
if r.depthPollInterval <= 0 {
return
}
ticker := time.NewTicker(r.depthPollInterval)
defer ticker.Stop()
for {
refreshCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
_ = r.worker.RefreshQueueDepth(refreshCtx)
cancel()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (r *Runner) handleHealth(w http.ResponseWriter, _ *http.Request) {
snapshot := struct {
Backend string `json:"backend"`
Health queue.HealthSnapshot `json:"health"`
Metrics queue.MetricsSnapshot `json:"metrics"`
Outbox *OutboxMetricsSnapshot `json:"outbox,omitempty"`
}{
Backend: r.backend,
Health: r.worker.Health().Snapshot(),
Metrics: r.worker.Metrics().Snapshot(),
}
if r.dispatcher != nil {
snapshot.Outbox = ptr(r.dispatcher.Metrics())
}
statusCode := http.StatusOK
if snapshot.Health.Status == "degraded" {
statusCode = http.StatusServiceUnavailable
}
writeJSON(w, statusCode, snapshot)
}
func writeJSON(w http.ResponseWriter, statusCode int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(payload)
}
func ptr[T any](v T) *T {
return &v
}

699
internal/auth/handler.go Normal file
View File

@@ -0,0 +1,699 @@
package auth
import (
"bytes"
"compress/flate"
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/beevik/etree"
"github.com/crewjam/saml"
"github.com/gofiber/fiber/v2"
dsig "github.com/russellhaering/goxmldsig"
"github.com/valyala/fasthttp/fasthttpadaptor"
"wucher/internal/config"
"wucher/internal/service"
)
type Handler struct {
cfg config.Config
auth *service.AuthService
}
func NewHandler(cfg config.Config, authSvc *service.AuthService) *Handler {
return &Handler{cfg: cfg, auth: authSvc}
}
func (h *Handler) HandleSAMLCallback(c *fiber.Ctx) error {
if SAMLMiddleware == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
}
req, err := toHTTPRequest(c)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request"})
}
if err := req.ParseForm(); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid form payload"})
}
assertion, err := SAMLMiddleware.ServiceProvider.ParseResponse(req, []string{""})
if err != nil {
resp := fiber.Map{"error": "invalid saml response"}
if strings.EqualFold(h.cfg.AppEnv, "development") || strings.EqualFold(h.cfg.AppEnv, "local") {
resp["detail"] = err.Error()
var invalidRespErr *saml.InvalidResponseError
if errors.As(err, &invalidRespErr) && invalidRespErr.PrivateErr != nil {
resp["private_detail"] = invalidRespErr.PrivateErr.Error()
}
if raw := c.FormValue("SAMLResponse"); raw != "" {
if statusCode, statusMessage := samlResponseStatus(raw); statusCode != "" || statusMessage != "" {
resp["status_code"] = statusCode
resp["status_message"] = statusMessage
}
}
}
return c.Status(fiber.StatusUnauthorized).JSON(resp)
}
nameID := ""
email := ""
givenName := ""
surname := ""
displayName := ""
if assertion.Subject != nil {
nameID = assertion.Subject.NameID.Value
}
for _, stmt := range assertion.AttributeStatements {
for _, attr := range stmt.Attributes {
if len(attr.Values) == 0 {
continue
}
val := attr.Values[0].Value
switch attr.Name {
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", "emailaddress":
email = val
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", "givenname":
givenName = val
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", "surname":
surname = val
case "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name", "name":
displayName = val
}
}
}
if displayName == "" {
displayName = givenName + " " + surname
}
now := time.Now().UTC()
session := UserSession{
UserID: nameID,
MicrosoftNameID: nameID,
Email: email,
Name: displayName,
LoggedInAt: now,
ExpiresAt: now.Add(time.Duration(h.cfg.SessionTTLHour) * time.Hour),
}
token, err := CreateSession(session)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "create session failed"})
}
if h.auth != nil {
claims := map[string]any{
"sub": nameID,
"email": email,
"name": displayName,
"oid": nameID,
"exp": time.Now().UTC().Add(5 * time.Minute).Unix(),
}
identity, err := h.auth.ExchangeMicrosoftToken(c.UserContext(), "saml_access", "saml_id", claims)
if err == nil && identity != nil {
user, userErr := h.auth.GetUserByID(c.UserContext(), identity.UserID)
if userErr == nil && user != nil {
tokens, tokenErr := h.auth.IssueTokensWithClientForProviderAndDeviceType(
c.UserContext(),
user,
c.Get(fiber.HeaderUserAgent),
c.IP(),
"microsoft",
readDeviceTypeHint(c),
)
if tokenErr == nil {
setAuthCookies(c, h.auth, tokens)
}
}
}
}
c.Cookie(&fiber.Cookie{
Name: "session_token",
Value: token,
HTTPOnly: true,
Secure: h.cfg.CookieSecure,
SameSite: "lax",
Path: "/",
Domain: h.cfg.CookieDomain,
Expires: session.ExpiresAt,
})
return c.Redirect(h.cfg.FrontendURL+"/dashboard", fiber.StatusFound)
}
func (h *Handler) HandleSLO(c *fiber.Ctx) error {
return h.HandleSAMLLogoutRequest(c)
}
func (h *Handler) HandleSAMLLogoutRequest(c *fiber.Ctx) error {
if SAMLMiddleware == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
}
log.Printf("saml logout request: has_saml_request=%t has_saml_response=%t method=%s", formOrQuery(c, "SAMLRequest") != "", formOrQuery(c, "SAMLResponse") != "", c.Method())
rawReq := formOrQuery(c, "SAMLRequest")
if rawReq == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing SAMLRequest"})
}
xmlBytes, err := decodeSAMLMessage(rawReq)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid SAMLRequest encoding"})
}
if c.Method() == fiber.MethodGet {
if err := validateRedirectSAMLSignature(c); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest signature"})
}
} else {
if err := validateLogoutRequestSignature(xmlBytes); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest signature"})
}
}
var logoutReq saml.LogoutRequest
if err := xml.Unmarshal(xmlBytes, &logoutReq); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid LogoutRequest XML"})
}
if err := validateSAMLMessageIssuer(logoutReq.Issuer.Value); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLRequest issuer"})
}
nameID := strings.TrimSpace(logoutReq.NameID.Value)
h.cleanupUserAuthSession(c, nameID)
log.Printf("saml logout request processed: name_id_present=%t", nameID != "")
relayState := formOrQuery(c, "RelayState")
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectLogoutResponse(logoutReq.ID, relayState)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to build LogoutResponse"})
}
return c.Redirect(redirectURL.String(), fiber.StatusFound)
}
func (h *Handler) HandleSAMLLogoutResponse(c *fiber.Ctx) error {
if SAMLMiddleware == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
}
rawQuery := c.Context().QueryArgs().String()
hasSAMLResponse := formOrQuery(c, "SAMLResponse") != ""
hasRelayState := formOrQuery(c, "RelayState") != ""
hasSigAlg := formOrQuery(c, "SigAlg") != ""
hasSignature := formOrQuery(c, "Signature") != ""
sigAlg := formOrQuery(c, "SigAlg")
log.Printf("saml logout response: method=%s path=%s hasSAMLResponse=%t hasRelayState=%t hasSigAlg=%t hasSignature=%t sigAlg=%q rawQueryLen=%d",
c.Method(), c.Path(), hasSAMLResponse, hasRelayState, hasSigAlg, hasSignature, sigAlg, len(rawQuery))
rawResp := formOrQuery(c, "SAMLResponse")
if rawResp == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing SAMLResponse"})
}
if c.Method() == fiber.MethodGet && hasSigAlg && hasSignature {
if err := validateRedirectSAMLSignatureRaw(rawQuery, "SAMLResponse"); err != nil {
log.Printf("saml logout response: verification_result=failed mode=redirect reason=%v", err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
}
log.Printf("saml logout response: verification_result=ok mode=redirect")
}
xmlBytes, err := decodeSAMLMessage(rawResp)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid SAMLResponse encoding"})
}
if c.Method() == fiber.MethodGet && !(hasSigAlg && hasSignature) {
if hasSigAlg != hasSignature {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing SAMLResponse signature"})
}
if !hasXMLSignature(xmlBytes) {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing SAMLResponse signature"})
}
if err := validateLogoutResponseSignature(xmlBytes); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
}
} else if c.Method() != fiber.MethodGet {
if err := validateLogoutResponseSignature(xmlBytes); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse signature"})
}
}
var logoutResp saml.LogoutResponse
if err := xml.Unmarshal(xmlBytes, &logoutResp); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid LogoutResponse XML"})
}
if err := validateSAMLMessageIssuer(logoutResp.Issuer.Value); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse issuer"})
}
if strings.TrimSpace(logoutResp.Status.StatusCode.Value) != saml.StatusSuccess {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid SAMLResponse status"})
}
h.cleanupUserAuthSession(c, "")
return c.Redirect(h.logoutRedirectURL(), fiber.StatusFound)
}
func (h *Handler) HandleMetadata(c *fiber.Ctx) error {
if SAMLMiddleware == nil {
return c.Status(fiber.StatusInternalServerError).SendString("saml is not initialized")
}
buf, err := xml.MarshalIndent(SAMLMiddleware.ServiceProvider.Metadata(), "", " ")
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("failed to marshal metadata")
}
c.Set(fiber.HeaderContentType, "application/samlmetadata+xml")
return c.Send(buf)
}
func (h *Handler) HandleLogin(c *fiber.Ctx) error {
if SAMLMiddleware == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "saml is not initialized"})
}
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectAuthenticationRequest("")
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to build authn request"})
}
if wantsJSON(c) {
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"data": fiber.Map{
"type": "auth_microsoft_url",
"attributes": fiber.Map{
"url": redirectURL.String(),
"redirect_url": redirectURL.String(),
},
},
})
}
return c.Redirect(redirectURL.String(), fiber.StatusFound)
}
func (h *Handler) HandleLogout(c *fiber.Ctx) error {
return h.HandleBrowserLocalLogout(c)
}
func (h *Handler) HandleBrowserLocalLogout(c *fiber.Ctx) error {
log.Printf("browser local logout: method=%s", c.Method())
var nameID string
if token := strings.TrimSpace(c.Cookies("session_token")); token != "" {
if sess, err := GetSession(token); err == nil {
nameID = strings.TrimSpace(sess.MicrosoftNameID)
}
}
h.cleanupUserAuthSession(c, nameID)
if nameID != "" && SAMLMiddleware != nil {
redirectURL, err := SAMLMiddleware.ServiceProvider.MakeRedirectLogoutRequest(nameID, "")
if err == nil {
return c.Redirect(redirectURL.String(), fiber.StatusFound)
}
}
return c.Redirect(h.logoutRedirectURL(), fiber.StatusFound)
}
func toHTTPRequest(c *fiber.Ctx) (*http.Request, error) {
r := new(http.Request)
if err := fasthttpadaptor.ConvertRequest(c.Context(), r, true); err != nil {
return nil, err
}
return r, nil
}
func validateLogoutRequestSignature(xmlBytes []byte) error {
certs, err := getIDPCertificates()
if err != nil {
return err
}
certStore := dsig.MemoryX509CertificateStore{Roots: certs}
validator := dsig.NewDefaultValidationContext(&certStore)
doc := etree.NewDocument()
if err := doc.ReadFromBytes(xmlBytes); err != nil {
return fmt.Errorf("parse xml: %w", err)
}
root := doc.Root()
if root == nil {
return fmt.Errorf("empty xml")
}
_, err = validator.Validate(root)
return err
}
func validateRedirectSAMLSignature(c *fiber.Ctx) error {
rawQuery := c.Context().QueryArgs().String()
return validateRedirectSAMLSignatureRaw(rawQuery, "")
}
func validateRedirectSAMLSignatureRaw(rawQuery, expectedMessageKey string) error {
params, err := url.ParseQuery(rawQuery)
if err != nil {
return fmt.Errorf("invalid query: %w", err)
}
sigAlg := params.Get("SigAlg")
signatureB64 := params.Get("Signature")
if sigAlg == "" || signatureB64 == "" {
return fmt.Errorf("missing SigAlg or Signature")
}
signedPayload, err := buildRedirectSignaturePayload(rawQuery, expectedMessageKey)
if err != nil {
return err
}
log.Printf("saml redirect signature: canonicalLen=%d", len(signedPayload))
signature, err := base64.StdEncoding.DecodeString(signatureB64)
if err != nil {
return fmt.Errorf("invalid signature encoding: %w", err)
}
certs, err := getIDPCertificates()
if err != nil {
return err
}
for _, cert := range certs {
log.Printf("saml redirect signature: certThumbprint=%s", certificateThumbprintSHA1(cert))
if err := verifyRedirectSignature(cert.PublicKey, sigAlg, signedPayload, signature); err == nil {
return nil
}
}
return fmt.Errorf("signature verification failed")
}
func buildRedirectSignaturePayload(rawQuery, expectedMessageKey string) ([]byte, error) {
var samlReq, samlResp, relay, sigAlg string
for _, part := range strings.Split(rawQuery, "&") {
if part == "" {
continue
}
key, val, found := strings.Cut(part, "=")
if !found {
continue
}
switch key {
case "SAMLRequest":
if samlReq == "" {
samlReq = val
}
case "SAMLResponse":
if samlResp == "" {
samlResp = val
}
case "RelayState":
if relay == "" {
relay = val
}
case "SigAlg":
if sigAlg == "" {
sigAlg = val
}
}
}
msgKey := "SAMLRequest"
msgVal := samlReq
if msgVal == "" {
msgKey = "SAMLResponse"
msgVal = samlResp
}
if expectedMessageKey != "" && msgKey != expectedMessageKey {
return nil, fmt.Errorf("missing %s", expectedMessageKey)
}
if msgVal == "" || sigAlg == "" {
return nil, fmt.Errorf("missing SAML payload or SigAlg")
}
payload := msgKey + "=" + msgVal
if relay != "" {
payload += "&RelayState=" + relay
}
payload += "&SigAlg=" + sigAlg
return []byte(payload), nil
}
func validateLogoutResponseSignature(xmlBytes []byte) error {
return validateLogoutRequestSignature(xmlBytes)
}
func hasXMLSignature(xmlBytes []byte) bool {
doc := etree.NewDocument()
if err := doc.ReadFromBytes(xmlBytes); err != nil {
return false
}
root := doc.Root()
if root == nil {
return false
}
for _, el := range root.FindElements(".//*") {
if strings.EqualFold(el.Tag, "Signature") || strings.HasSuffix(el.Tag, ":Signature") {
return true
}
}
return false
}
func certificateThumbprintSHA1(cert *x509.Certificate) string {
if cert == nil {
return ""
}
sum := sha1.Sum(cert.Raw)
return strings.ToUpper(hex.EncodeToString(sum[:]))
}
func validateSAMLMessageIssuer(issuer string) error {
issuer = strings.TrimSpace(issuer)
if issuer == "" {
return fmt.Errorf("missing issuer")
}
if SAMLMiddleware == nil || SAMLMiddleware.ServiceProvider.IDPMetadata == nil {
return fmt.Errorf("missing idp metadata")
}
expected := strings.TrimSpace(SAMLMiddleware.ServiceProvider.IDPMetadata.EntityID)
if expected == "" {
return fmt.Errorf("missing expected issuer")
}
if issuer != expected {
return fmt.Errorf("unexpected issuer")
}
return nil
}
func (h *Handler) cleanupUserAuthSession(c *fiber.Ctx, nameID string) {
if strings.TrimSpace(nameID) != "" {
_ = DeleteSessionByNameID(nameID)
}
if token := strings.TrimSpace(c.Cookies("session_token")); token != "" {
_ = DeleteSession(token)
}
clearSessionCookie(c, h.cfg.CookieDomain, h.cfg.CookieSecure)
if h.auth == nil {
return
}
accessToken := strings.TrimSpace(c.Cookies(h.auth.AccessCookieName()))
refreshToken := strings.TrimSpace(c.Cookies(h.auth.RefreshCookieName()))
if accessToken != "" {
if userID, err := h.auth.ParseAccessToken(c.UserContext(), accessToken); err == nil && len(userID) == 16 {
_ = h.auth.RevokeAllUserSessions(c.UserContext(), userID)
log.Printf("logout cleanup: revoked app sessions user_id=%x", userID)
}
}
if refreshToken != "" {
_ = h.auth.RevokeRefreshToken(c.UserContext(), refreshToken)
}
clearAuthCookies(c, h.auth)
}
func clearSessionCookie(c *fiber.Ctx, domain string, secure bool) {
c.Cookie(&fiber.Cookie{
Name: "session_token",
Value: "",
Path: "/",
Domain: domain,
HTTPOnly: true,
Secure: secure,
SameSite: "lax",
MaxAge: -1,
Expires: time.Unix(0, 0),
})
}
func clearAuthCookies(c *fiber.Ctx, svc *service.AuthService) {
if svc == nil {
return
}
c.Cookie(&fiber.Cookie{
Name: svc.AccessCookieName(),
Value: "",
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/",
MaxAge: -1,
})
c.Cookie(&fiber.Cookie{
Name: svc.RefreshCookieName(),
Value: "",
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/api/v1/auth/refresh",
MaxAge: -1,
})
}
func (h *Handler) logoutRedirectURL() string {
base := strings.TrimRight(strings.TrimSpace(h.cfg.FrontendURL), "/")
if base == "" {
return "/signin?loggedOut=true"
}
return base + "/signin?loggedOut=true"
}
func verifyRedirectSignature(pub any, sigAlg string, payload, signature []byte) error {
switch sigAlg {
case "http://www.w3.org/2000/09/xmldsig#rsa-sha1":
sum := sha1.Sum(payload)
key, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("unexpected key type %T", pub)
}
return rsa.VerifyPKCS1v15(key, crypto.SHA1, sum[:], signature)
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256":
sum := sha256.Sum256(payload)
key, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("unexpected key type %T", pub)
}
return rsa.VerifyPKCS1v15(key, crypto.SHA256, sum[:], signature)
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384":
sum := sha512.Sum384(payload)
key, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("unexpected key type %T", pub)
}
return rsa.VerifyPKCS1v15(key, crypto.SHA384, sum[:], signature)
case "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512":
sum := sha512.Sum512(payload)
key, ok := pub.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("unexpected key type %T", pub)
}
return rsa.VerifyPKCS1v15(key, crypto.SHA512, sum[:], signature)
case "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256":
sum := sha256.Sum256(payload)
key, ok := pub.(*ecdsa.PublicKey)
if !ok {
return fmt.Errorf("unexpected key type %T", pub)
}
if ecdsa.VerifyASN1(key, sum[:], signature) {
return nil
}
return fmt.Errorf("ecdsa verify failed")
default:
return fmt.Errorf("unsupported SigAlg %q", sigAlg)
}
}
func formOrQuery(c *fiber.Ctx, key string) string {
if v := strings.TrimSpace(c.FormValue(key)); v != "" {
return v
}
return strings.TrimSpace(c.Query(key))
}
func decodeSAMLMessage(raw string) ([]byte, error) {
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return nil, err
}
if bytes.Contains(decoded, []byte("<")) {
return decoded, nil
}
reader := flate.NewReader(bytes.NewReader(decoded))
defer reader.Close()
var out bytes.Buffer
if _, err := out.ReadFrom(reader); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func samlResponseStatus(raw string) (string, string) {
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return "", ""
}
var resp saml.Response
if err := xml.Unmarshal(decoded, &resp); err != nil {
return "", ""
}
if resp.Status.StatusCode.Value == "" && resp.Status.StatusMessage == nil {
return "", ""
}
statusMessage := ""
if resp.Status.StatusMessage != nil {
statusMessage = strings.TrimSpace(resp.Status.StatusMessage.Value)
}
return resp.Status.StatusCode.Value, statusMessage
}
func readDeviceTypeHint(c *fiber.Ctx) string {
return strings.TrimSpace(c.Get("X-Device-Type"))
}
func setAuthCookies(c *fiber.Ctx, svc *service.AuthService, tokens service.TokenPair) {
if svc == nil {
return
}
refreshTTL := tokens.RefreshTTL
if refreshTTL <= 0 {
refreshTTL = svc.RefreshTTL()
}
c.Cookie(&fiber.Cookie{
Name: svc.AccessCookieName(),
Value: tokens.AccessToken,
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/",
MaxAge: int(svc.AccessTTL().Seconds()),
})
c.Cookie(&fiber.Cookie{
Name: svc.RefreshCookieName(),
Value: tokens.RefreshToken,
HTTPOnly: true,
Secure: svc.CookieSecure(),
SameSite: parseSameSite(svc.CookieSameSite()),
Domain: svc.CookieDomain(),
Path: "/api/v1/auth/refresh",
MaxAge: int(refreshTTL.Seconds()),
})
}
func parseSameSite(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case "none":
return "none"
case "strict":
return "strict"
default:
return "lax"
}
}
func wantsJSON(c *fiber.Ctx) bool {
accept := strings.ToLower(strings.TrimSpace(c.Get(fiber.HeaderAccept)))
return strings.Contains(accept, "application/json") || strings.Contains(accept, "application/vnd.api+json")
}

View File

@@ -0,0 +1,257 @@
package auth
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/hex"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
"github.com/gofiber/fiber/v2"
"wucher/internal/config"
)
func TestHandleBrowserLocalLogout_RedirectAndClearSessionCookie(t *testing.T) {
prev := SAMLMiddleware
SAMLMiddleware = nil
t.Cleanup(func() { SAMLMiddleware = prev })
h := NewHandler(config.Config{
FrontendURL: "https://wfm-dev.mybit.co.id",
CookieDomain: ".mybit.co.id",
CookieSecure: true,
}, nil)
app := fiber.New()
app.Get("/api/v1/auth/microsoft/logout", h.HandleBrowserLocalLogout)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/logout", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected 302, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Location"); got != "https://wfm-dev.mybit.co.id/signin?loggedOut=true" {
t.Fatalf("unexpected redirect: %s", got)
}
setCookie := strings.Join(resp.Header.Values("Set-Cookie"), "\n")
if !strings.Contains(setCookie, "session_token=") {
t.Fatalf("expected session_token clear cookie, got %q", setCookie)
}
if !strings.Contains(strings.ToLower(setCookie), "domain=.mybit.co.id") {
t.Fatalf("expected cookie domain .mybit.co.id, got %q", setCookie)
}
}
func TestBuildRedirectSignaturePayload_PreservesRawEncoding(t *testing.T) {
raw := "SAMLResponse=abc%2B123%3D%3D&RelayState=x%2By%252Fz&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256&Signature=ignored"
got, err := buildRedirectSignaturePayload(raw, "SAMLResponse")
if err != nil {
t.Fatalf("build payload: %v", err)
}
want := "SAMLResponse=abc%2B123%3D%3D&RelayState=x%2By%252Fz&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256"
if string(got) != want {
t.Fatalf("payload mismatch\nwant: %s\ngot: %s", want, string(got))
}
}
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Valid_WithRelayState(t *testing.T) {
runValidSAMLResponseRedirectTest(t, "relay-state-1", true)
}
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Valid_WithoutRelayState(t *testing.T) {
runValidSAMLResponseRedirectTest(t, "", false)
}
func TestHandleSAMLLogoutResponse_GetRedirectSignature_Invalid(t *testing.T) {
priv, cert := testSigningCert(t)
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
sigAlg := "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
params := []string{
"SAMLResponse=" + url.QueryEscape(xmlB64),
"SigAlg=" + url.QueryEscape(sigAlg),
}
canonical := strings.Join(params, "&")
sig := signRedirectPayloadRSA256(t, priv, []byte(canonical))
// Break signature on purpose
sig = strings.TrimRight(sig, "=") + "A"
path := "/api/v1/auth/microsoft/logout?" + canonical + "&Signature=" + url.QueryEscape(sig)
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
app := fiber.New()
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
req := httptest.NewRequest(http.MethodGet, path, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func TestHandleSAMLLogoutResponse_GetMissingSignature(t *testing.T) {
_, cert := testSigningCert(t)
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
path := "/api/v1/auth/microsoft/logout?SAMLResponse=" + url.QueryEscape(xmlB64)
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
app := fiber.New()
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
req := httptest.NewRequest(http.MethodGet, path, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
}
func runValidSAMLResponseRedirectTest(t *testing.T, relayState string, withRelay bool) {
t.Helper()
priv, cert := testSigningCert(t)
setTestSAMLMiddlewareWithCert(t, cert, "https://sts.windows.net/tenant/")
xmlB64 := base64.StdEncoding.EncodeToString([]byte(testLogoutResponseXML("https://sts.windows.net/tenant/")))
sigAlg := "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
parts := []string{"SAMLResponse=" + url.QueryEscape(xmlB64)}
if withRelay {
parts = append(parts, "RelayState="+url.QueryEscape(relayState))
}
parts = append(parts, "SigAlg="+url.QueryEscape(sigAlg))
canonical := strings.Join(parts, "&")
sig := signRedirectPayloadRSA256(t, priv, []byte(canonical))
rawQuery := canonical + "&Signature=" + url.QueryEscape(sig)
h := NewHandler(config.Config{FrontendURL: "https://wfm-dev.mybit.co.id"}, nil)
app := fiber.New()
app.Get("/api/v1/auth/microsoft/logout", h.HandleSAMLLogoutResponse)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/microsoft/logout?"+rawQuery, nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
if resp.StatusCode != http.StatusFound {
t.Fatalf("expected 302, got %d", resp.StatusCode)
}
if got := resp.Header.Get("Location"); got != "https://wfm-dev.mybit.co.id/signin?loggedOut=true" {
t.Fatalf("unexpected redirect: %s", got)
}
}
func signRedirectPayloadRSA256(t *testing.T, priv *rsa.PrivateKey, payload []byte) string {
t.Helper()
sum := sha256.Sum256(payload)
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, sum[:])
if err != nil {
t.Fatalf("sign payload: %v", err)
}
return base64.StdEncoding.EncodeToString(sig)
}
func testSigningCert(t *testing.T) (*rsa.PrivateKey, *x509.Certificate) {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("gen key: %v", err)
}
serial, _ := rand.Int(rand.Reader, big.NewInt(1<<62))
tpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "test-idp"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create cert: %v", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse cert: %v", err)
}
return priv, cert
}
func setTestSAMLMiddlewareWithCert(t *testing.T, cert *x509.Certificate, entityID string) {
t.Helper()
prev := SAMLMiddleware
SAMLMiddleware = &samlsp.Middleware{}
SAMLMiddleware.ServiceProvider.IDPMetadata = &saml.EntityDescriptor{
EntityID: entityID,
IDPSSODescriptors: []saml.IDPSSODescriptor{
{
SSODescriptor: saml.SSODescriptor{
RoleDescriptor: saml.RoleDescriptor{
KeyDescriptors: []saml.KeyDescriptor{
{
KeyInfo: dsigKeyInfoFromCert(cert),
},
},
},
},
},
},
}
t.Cleanup(func() { SAMLMiddleware = prev })
}
func dsigKeyInfoFromCert(cert *x509.Certificate) saml.KeyInfo {
if cert == nil {
return saml.KeyInfo{}
}
return saml.KeyInfo{
X509Data: saml.X509Data{
X509Certificates: []saml.X509Certificate{
{Data: base64.StdEncoding.EncodeToString(cert.Raw)},
},
},
}
}
func testLogoutResponseXML(issuer string) string {
return fmt.Sprintf(
`<LogoutResponse xmlns="urn:oasis:names:tc:SAML:2.0:protocol" ID="_id123" Version="2.0" IssueInstant="2026-05-28T00:00:00Z"><Issuer xmlns="urn:oasis:names:tc:SAML:2.0:assertion">%s</Issuer><Status><StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></Status></LogoutResponse>`,
issuer,
)
}
func TestCertificateThumbprintSHA1(t *testing.T) {
_, cert := testSigningCert(t)
got := certificateThumbprintSHA1(cert)
if got == "" {
t.Fatal("thumbprint should not be empty")
}
sum := sha1.Sum(cert.Raw)
want := strings.ToUpper(hex.EncodeToString(sum[:]))
if got != want {
t.Fatalf("thumbprint mismatch: got=%s want=%s", got, want)
}
}

124
internal/auth/saml.go Normal file
View File

@@ -0,0 +1,124 @@
package auth
import (
"crypto"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/xml"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
"wucher/internal/config"
)
var SAMLMiddleware *samlsp.Middleware
func InitSAML(cfg config.Config) error {
keyPair, err := tls.LoadX509KeyPair(cfg.SAMLCertFile, cfg.SAMLKeyFile)
if err != nil {
return fmt.Errorf("load saml keypair: %w", err)
}
cert, err := x509.ParseCertificate(keyPair.Certificate[0])
if err != nil {
return fmt.Errorf("parse saml certificate: %w", err)
}
signer, ok := keyPair.PrivateKey.(crypto.Signer)
if !ok {
return fmt.Errorf("saml private key is not a crypto.Signer")
}
idpMetadataBytes, err := os.ReadFile(cfg.SAMLMetadataFile)
if err != nil {
return fmt.Errorf("read idp metadata: %w", err)
}
var idpMetadata saml.EntityDescriptor
if err := xml.Unmarshal(idpMetadataBytes, &idpMetadata); err != nil {
return fmt.Errorf("parse idp metadata xml: %w", err)
}
rootURL, err := url.Parse(cfg.SAMLRootURL)
if err != nil {
return fmt.Errorf("invalid SAML_ROOT_URL: %w", err)
}
m, err := samlsp.New(samlsp.Options{
EntityID: cfg.SAMLRootURL,
URL: *rootURL,
Key: signer,
Certificate: cert,
IDPMetadata: &idpMetadata,
AllowIDPInitiated: true,
CookieSameSite: http.SameSiteLaxMode,
DefaultRedirectURI: cfg.FrontendURL + "/dashboard",
})
if err != nil {
return fmt.Errorf("init saml middleware: %w", err)
}
m.ServiceProvider.MetadataURL.Path = "/api/v1/auth/saml/metadata"
m.ServiceProvider.AcsURL.Path = "/api/v1/auth/microsoft/callback"
m.ServiceProvider.SloURL.Path = "/api/v1/auth/microsoft/logout"
m.ServiceProvider.ValidateAudienceRestriction = func(assertion *saml.Assertion) error {
if assertion == nil || assertion.Conditions == nil || len(assertion.Conditions.AudienceRestrictions) == 0 {
return nil
}
expected := strings.TrimRight(firstNonEmpty(m.ServiceProvider.EntityID, m.ServiceProvider.MetadataURL.String()), "/")
for _, audienceRestriction := range assertion.Conditions.AudienceRestrictions {
if strings.TrimRight(audienceRestriction.Audience.Value, "/") == expected {
return nil
}
}
return fmt.Errorf("assertion Conditions AudienceRestriction does not contain %q", expected)
}
SAMLMiddleware = m
return nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func getIDPCertificates() ([]*x509.Certificate, error) {
if SAMLMiddleware == nil || SAMLMiddleware.ServiceProvider.IDPMetadata == nil {
return nil, fmt.Errorf("saml middleware not initialized")
}
var certificates []*x509.Certificate
for _, idp := range SAMLMiddleware.ServiceProvider.IDPMetadata.IDPSSODescriptors {
for _, kd := range idp.KeyDescriptors {
for _, certData := range kd.KeyInfo.X509Data.X509Certificates {
raw := strings.TrimSpace(certData.Data)
if raw == "" {
continue
}
der, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
continue
}
cert, err := x509.ParseCertificate(der)
if err != nil {
continue
}
certificates = append(certificates, cert)
}
}
}
if len(certificates) == 0 {
return nil, fmt.Errorf("no idp certificate in metadata")
}
return certificates, nil
}

127
internal/auth/session.go Normal file
View File

@@ -0,0 +1,127 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"gorm.io/gorm"
)
type UserSession struct {
UserID string `json:"user_id"`
MicrosoftNameID string `json:"microsoft_name_id"`
Email string `json:"email"`
Name string `json:"name"`
LoggedInAt time.Time `json:"logged_in_at"`
ExpiresAt time.Time `json:"expires_at"`
}
type SessionRecord struct {
Token string `gorm:"type:varchar(128);primaryKey;column:token"`
UserID string `gorm:"type:varchar(191);index;not null;column:user_id"`
MicrosoftNameID string `gorm:"type:varchar(255);index;not null;column:microsoft_name_id"`
Email string `gorm:"type:varchar(191);index;not null;column:email"`
Name string `gorm:"type:varchar(191);not null;column:name"`
LoggedInAt time.Time `gorm:"not null;column:logged_in_at"`
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (SessionRecord) TableName() string { return "saml_sessions" }
var sessionDB *gorm.DB
func InitSessionStoreDB(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database is required")
}
if err := db.AutoMigrate(&SessionRecord{}); err != nil {
return fmt.Errorf("auto migrate saml_sessions: %w", err)
}
sessionDB = db
return nil
}
func CreateSession(session UserSession) (string, error) {
if sessionDB == nil {
return "", fmt.Errorf("session store is not initialized")
}
token, err := generateToken()
if err != nil {
return "", err
}
if session.LoggedInAt.IsZero() {
session.LoggedInAt = time.Now().UTC()
}
if session.ExpiresAt.IsZero() {
session.ExpiresAt = session.LoggedInAt.Add(8 * time.Hour)
}
record := SessionRecord{
Token: token,
UserID: session.UserID,
MicrosoftNameID: session.MicrosoftNameID,
Email: session.Email,
Name: session.Name,
LoggedInAt: session.LoggedInAt,
ExpiresAt: session.ExpiresAt,
}
if err := sessionDB.Create(&record).Error; err != nil {
return "", fmt.Errorf("create session: %w", err)
}
return token, nil
}
func GetSession(token string) (*UserSession, error) {
if sessionDB == nil {
return nil, fmt.Errorf("session store is not initialized")
}
var record SessionRecord
err := sessionDB.Where("token = ?", token).First(&record).Error
if err != nil {
return nil, err
}
now := time.Now().UTC()
if !record.ExpiresAt.After(now) {
_ = sessionDB.Delete(&SessionRecord{}, "token = ?", token).Error
return nil, gorm.ErrRecordNotFound
}
return &UserSession{
UserID: record.UserID,
MicrosoftNameID: record.MicrosoftNameID,
Email: record.Email,
Name: record.Name,
LoggedInAt: record.LoggedInAt,
ExpiresAt: record.ExpiresAt,
}, nil
}
func DeleteSession(token string) error {
if sessionDB == nil {
return fmt.Errorf("session store is not initialized")
}
return sessionDB.Delete(&SessionRecord{}, "token = ?", token).Error
}
func DeleteSessionByNameID(nameID string) error {
if sessionDB == nil {
return fmt.Errorf("session store is not initialized")
}
return sessionDB.Delete(&SessionRecord{}, "microsoft_name_id = ?", nameID).Error
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate token: %w", err)
}
token := hex.EncodeToString(b)
if token == "" {
return "", errors.New("empty token")
}
return token, nil
}

View File

@@ -0,0 +1,7 @@
package buildinfo
var (
Version = "dev"
Commit = "unknown"
BuildTime = "unknown"
)

928
internal/config/config.go Normal file
View File

@@ -0,0 +1,928 @@
package config
import (
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
AppPort string
AppEnv string
SAMLCertFile string
SAMLKeyFile string
SAMLMetadataFile string
SAMLRootURL string
RedisAddr string
RedisPassword string
RedisDB int
FrontendURL string
CookieDomain string
CookieSecure bool
SessionTTLHour int
}
type SAMLConfig struct {
CertFile string
KeyFile string
MetadataFile string
RootURL string
FrontendURL string
CookieDomain string
CookieSecure bool
SessionTTLHr int
}
func LoadEnvConfig() Config {
return Config{
AppPort: envString("APP_PORT", "3000"),
AppEnv: envString("APP_ENV", "development"),
SAMLCertFile: envString("SAML_CERT_FILE", "configs/saml.crt"),
SAMLKeyFile: envString("SAML_KEY_FILE", "configs/saml.key"),
SAMLMetadataFile: envString("SAML_METADATA_FILE", "configs/metadata.xml"),
SAMLRootURL: envString("SAML_ROOT_URL", "https://api-wfm-dev.mybit.co.id"),
RedisAddr: envString("REDIS_ADDR", "localhost:6379"),
RedisPassword: envString("REDIS_PASSWORD", ""),
RedisDB: envInt("REDIS_DB", 0),
FrontendURL: envString("FRONTEND_URL", "https://wfm-dev.mybit.co.id"),
CookieDomain: strings.TrimSpace(os.Getenv("COOKIE_DOMAIN")),
CookieSecure: envBool("COOKIE_SECURE", true),
SessionTTLHour: envInt("SESSION_TTL_HOUR", 8),
}
}
func envString(key, fallback string) string {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
return v
}
func envInt(key string, fallback int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
func envBool(key string, fallback bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
b, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return b
}
type MySQLConfig struct {
DSN string
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
}
type AuthConfig struct {
PublicBaseURL string
PasswordPepper string
IPEncryptionKey string
HashTime uint32
HashMemoryKB uint32
HashThreads uint8
HashKeyLen uint32
EmailVerifyTTL time.Duration
EmailVerifyURL string
InviteTTL time.Duration
SetPasswordURL string
PasswordResetTTL time.Duration
PasswordResetURL string
SecurityPINResetTTL time.Duration
SecurityPINResetURL string
ForgotSecurityPINCooldown time.Duration
ForgotSecurityPINMinDuration time.Duration
SecurityPINMaxAttempts int
SecurityPINLockDuration time.Duration
SecurityPINActionTokenTTL time.Duration
ForgotPasswordEmailCooldown time.Duration
ForgotPasswordMinDuration time.Duration
ForgotPasswordIPRateMax int
ForgotPasswordIPRateWindow time.Duration
LoginIPRateMax int
LoginIPRateWindow time.Duration
TOTPChallengeTTL time.Duration
EmailOTPChallengeTTL time.Duration
EmailOTPLength int
EmailOTPExpiry time.Duration
EmailOTPMaxAttempts int
EmailOTPResendCooldown time.Duration
EmailOTPMaxResendPerHour int
SSOStateTTL time.Duration
TOTPIssuer string
TOTPSecretKeyB64 string
JWTAccessSecret string
JWTRefreshSecret string
JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration
JWTRefreshTOTPTTL time.Duration
JWTLocalRefreshTTL time.Duration
JWTSSORefreshTTL time.Duration
SessionIdleTTL time.Duration
JWTCookieDomain string
JWTCookieSecure bool
JWTCookieSameSite string
JWTAccessCookieName string
JWTRefreshCookieName string
WebAuthnRPID string
WebAuthnRPDisplayName string
WebAuthnRPOrigins []string
WebAuthnChallengeTTL time.Duration
SSOSuccessRedirectURL string
DefaultRoleName string
DisableRegister bool
}
type SESConfig struct {
Region string
Endpoint string
From string
ConfigurationSetName string
SendTimeout time.Duration
AllowInsecureEndpoint bool
}
type EmailDeliveryConfig struct {
Provider string
MockDir string
}
type MicrosoftSSOConfig struct {
TenantID string
ClientID string
ClientSecret string
RedirectURL string
Authority string
Scopes []string
}
type HTTPConfig struct {
BodyLimit int
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
RequestTimeout time.Duration
RequestTimeoutByPath map[string]time.Duration
ShutdownTimeout time.Duration
CORSAllowOrigins string
CORSAllowMethods string
CORSAllowHeaders string
CORSExposeHeaders string
CORSAllowCredentials bool
CORSMaxAge int
RateLimitMax int
RateLimitWindow time.Duration
RateLimitByEndpoint map[string]RateLimitRule
HSTSMaxAge int
}
type RateLimitRule struct {
Max int
Window time.Duration
}
type AuditConfig struct {
QueueSize int
Workers int
}
type LoggingConfig struct {
Level string
Format string
}
type CircuitBreakerPolicy struct {
Enabled bool
MaxRequests uint32
Interval time.Duration
BucketPeriod time.Duration
Timeout time.Duration
MinRequests uint32
FailureRatio float64
ConsecutiveFailures uint32
}
type ResilienceConfig struct {
Default CircuitBreakerPolicy
MicrosoftSSO CircuitBreakerPolicy
SES CircuitBreakerPolicy
SQS CircuitBreakerPolicy
}
type QueueConfig struct {
MessageSchemaVersion string
EnableLegacyPayloadFallback bool
IdempotencyKeyPrefix string
IdempotencyTTL time.Duration
ProcessingTTL time.Duration
WorkerConcurrency int
WorkerMaxBatchSize int
WorkerProcessTimeout time.Duration
WorkerShutdownTimeout time.Duration
WorkerBackoffMin time.Duration
WorkerBackoffMax time.Duration
WorkerPermanentFailureDelay time.Duration
WorkerMonitorAddr string
WorkerHealthErrorThreshold int
WorkerDepthPollInterval time.Duration
OutboxEnabled bool
OutboxPollInterval time.Duration
OutboxBatchSize int
OutboxDispatchTimeout time.Duration
OutboxLockTTL time.Duration
OutboxMaxAttempts int
}
type FileQueueConfig struct {
MessageSchemaVersion string
IdempotencyKeyPrefix string
IdempotencyTTL time.Duration
ProcessingTTL time.Duration
WorkerConcurrency int
WorkerMaxBatchSize int
WorkerProcessTimeout time.Duration
WorkerShutdownTimeout time.Duration
WorkerBackoffMin time.Duration
WorkerBackoffMax time.Duration
WorkerPermanentFailureDelay time.Duration
WorkerMonitorAddr string
WorkerHealthErrorThreshold int
WorkerDepthPollInterval time.Duration
OutboxEnabled bool
OutboxPollInterval time.Duration
OutboxBatchSize int
OutboxDispatchTimeout time.Duration
OutboxLockTTL time.Duration
OutboxMaxAttempts int
}
type SQSConfig struct {
Region string
AccessKeyID string
SecretAccessKey string
Endpoint string
QueueURL string
QueueType string
MessageGroupID string
MaxMessages int
WaitTimeSeconds int32
VisibilityTimeout time.Duration
AllowInsecureEndpoint bool
}
type FileStorageConfig struct {
Provider string
Bucket string
Region string
Endpoint string
AccessKeyID string
SecretAccessKey string
ForcePathStyle bool
AllowInsecureEndpoint bool
AutoCreateBucket bool
ObjectKeyPrefix string
PresignPutTTL time.Duration
PresignGetTTL time.Duration
}
type FileManagerUploadPolicyConfig struct {
MaxFileSizeBytes int64
AllowedMimeTypes []string
AllowedExtensions []string
AllowEmptyFile bool
MaxFilenameLength int
}
type FileManagerTrashPolicyConfig struct {
AutoPurgeEnabled bool
Retention time.Duration
AutoPurgeInterval time.Duration
AutoPurgeBatchSize int
}
type FileManagerLifecycleConfig struct {
EnableS3Tagging bool
EnableCleanupJob bool
TempUploadGraceHours int
OrphanGraceDays int
CleanupBatchSize int
CleanupDryRun bool
}
type WOPIConfig struct {
TokenSecret string
ProofSecret string
DiscoveryURL string
PublicBaseURL string
EditActionURL string
TokenTTL time.Duration
MaxBodyBytes int
RequireProof bool
}
type GotenbergConfig struct {
URL string
}
func LoadMySQLConfigFromEnv() MySQLConfig {
return MySQLConfig{
DSN: getenv("MYSQL_DSN", ""),
MaxOpenConns: getenvInt("MYSQL_MAX_OPEN_CONNS", 25),
MaxIdleConns: getenvInt("MYSQL_MAX_IDLE_CONNS", 10),
ConnMaxLifetime: getenvDuration("MYSQL_CONN_MAX_LIFETIME", 5*time.Minute),
ConnMaxIdleTime: getenvDuration("MYSQL_CONN_MAX_IDLE_TIME", 2*time.Minute),
}
}
func LoadAuthConfigFromEnv() AuthConfig {
refreshTTL := getenvDuration("AUTH_JWT_REFRESH_TTL", time.Hour)
refreshTOTPTTL := getenvDuration("AUTH_JWT_REFRESH_TOTP_TTL", refreshTTL)
passwordPepper := getenv("AUTH_PASSWORD_PEPPER", "")
ipEncryptionKey := getenv("AUTH_IP_ENCRYPTION_KEY", getenv("AUTH_IP_HMAC_KEY", passwordPepper))
return AuthConfig{
PublicBaseURL: getenv("APP_PUBLIC_BASE_URL", ""),
PasswordPepper: passwordPepper,
IPEncryptionKey: ipEncryptionKey,
HashTime: uint32(getenvInt("AUTH_HASH_TIME", 1)),
HashMemoryKB: uint32(getenvInt("AUTH_HASH_MEMORY_KB", 64*1024)),
HashThreads: uint8(getenvInt("AUTH_HASH_THREADS", 4)),
HashKeyLen: uint32(getenvInt("AUTH_HASH_KEY_LEN", 32)),
EmailVerifyTTL: getenvDuration("AUTH_EMAIL_VERIFY_TTL", 24*time.Hour),
EmailVerifyURL: getenv("AUTH_EMAIL_VERIFY_URL", ""),
InviteTTL: getenvDuration("AUTH_INVITE_TTL", 24*time.Hour),
SetPasswordURL: getenv("AUTH_SET_PASSWORD_URL", getenv("AUTH_EMAIL_VERIFY_URL", "")),
PasswordResetTTL: getenvDuration("AUTH_PASSWORD_RESET_TTL", 20*time.Minute),
PasswordResetURL: getenv("AUTH_PASSWORD_RESET_URL", getenv("AUTH_SET_PASSWORD_URL", "")),
SecurityPINResetTTL: getenvDuration("AUTH_SECURITY_PIN_RESET_TTL", 20*time.Minute),
SecurityPINResetURL: getenv("AUTH_SECURITY_PIN_RESET_URL", getenv("AUTH_PASSWORD_RESET_URL", getenv("AUTH_SET_PASSWORD_URL", ""))),
ForgotSecurityPINCooldown: getenvDuration("AUTH_FORGOT_SECURITY_PIN_EMAIL_COOLDOWN", 2*time.Minute),
ForgotSecurityPINMinDuration: getenvDuration("AUTH_FORGOT_SECURITY_PIN_MIN_DURATION", 150*time.Millisecond),
SecurityPINMaxAttempts: getenvInt("AUTH_SECURITY_PIN_MAX_ATTEMPTS", 5),
SecurityPINLockDuration: getenvDuration("AUTH_SECURITY_PIN_LOCK_DURATION", 0),
SecurityPINActionTokenTTL: getenvDuration("AUTH_SECURITY_PIN_ACTION_TOKEN_TTL", 5*time.Minute),
ForgotPasswordEmailCooldown: getenvDuration("AUTH_FORGOT_PASSWORD_EMAIL_COOLDOWN", 2*time.Minute),
ForgotPasswordMinDuration: getenvDuration("AUTH_FORGOT_PASSWORD_MIN_DURATION", 150*time.Millisecond),
ForgotPasswordIPRateMax: getenvInt("AUTH_FORGOT_PASSWORD_IP_RATE_MAX", 10),
ForgotPasswordIPRateWindow: getenvDuration("AUTH_FORGOT_PASSWORD_IP_RATE_WINDOW", 15*time.Minute),
LoginIPRateMax: getenvInt("AUTH_LOGIN_IP_RATE_MAX", 5),
LoginIPRateWindow: getenvDuration("AUTH_LOGIN_IP_RATE_WINDOW", time.Minute),
TOTPChallengeTTL: getenvDuration("AUTH_TOTP_CHALLENGE_TTL", 5*time.Minute),
EmailOTPChallengeTTL: getenvDuration("AUTH_EMAIL_OTP_CHALLENGE_TTL", 10*time.Minute),
EmailOTPLength: getenvInt("AUTH_EMAIL_OTP_LENGTH", 6),
EmailOTPExpiry: getenvDuration("AUTH_EMAIL_OTP_EXPIRY", 10*time.Minute),
EmailOTPMaxAttempts: getenvInt("AUTH_EMAIL_OTP_MAX_ATTEMPTS", 5),
EmailOTPResendCooldown: getenvDuration("AUTH_EMAIL_OTP_RESEND_COOLDOWN", 60*time.Second),
EmailOTPMaxResendPerHour: getenvInt("AUTH_EMAIL_OTP_MAX_RESEND_PER_HOUR", 5),
SSOStateTTL: getenvDuration("AUTH_SSO_STATE_TTL", 10*time.Minute),
TOTPIssuer: getenv("TOTP_ISSUER", "Wucher"),
TOTPSecretKeyB64: getenv("TOTP_SECRET_KEY", ""),
JWTAccessSecret: getenv("AUTH_JWT_ACCESS_SECRET", ""),
JWTRefreshSecret: getenv("AUTH_JWT_REFRESH_SECRET", ""),
JWTAccessTTL: getenvDuration("AUTH_JWT_ACCESS_TTL", 10*time.Minute),
JWTRefreshTTL: refreshTTL,
JWTRefreshTOTPTTL: refreshTOTPTTL,
JWTLocalRefreshTTL: getenvDuration("AUTH_JWT_LOCAL_REFRESH_TTL", 24*time.Hour),
// Fallback lifetime for Microsoft/SSO sessions when Microsoft does not
// report refresh_token_expires_in. Defaults to Microsoft's default
// refresh-token lifetime (90 days); the actual session follows the
// value Microsoft returns whenever it is present.
JWTSSORefreshTTL: getenvDuration("AUTH_JWT_SSO_REFRESH_TTL", 90*24*time.Hour),
SessionIdleTTL: getenvDuration("AUTH_SESSION_IDLE_TTL", 15*time.Minute),
JWTCookieDomain: getenv("AUTH_JWT_COOKIE_DOMAIN", ""),
JWTCookieSecure: getenvBool("AUTH_JWT_COOKIE_SECURE", false),
JWTCookieSameSite: getenv("AUTH_JWT_COOKIE_SAMESITE", "Lax"),
JWTAccessCookieName: getenv("AUTH_JWT_ACCESS_COOKIE", "wucher_at"),
JWTRefreshCookieName: getenv("AUTH_JWT_REFRESH_COOKIE", "wucher_rt"),
WebAuthnRPID: getenv("AUTH_WEBAUTHN_RP_ID", ""),
WebAuthnRPDisplayName: getenv("AUTH_WEBAUTHN_RP_DISPLAY_NAME", "Wucher"),
WebAuthnRPOrigins: getenvCSV("AUTH_WEBAUTHN_RP_ORIGINS", ""),
WebAuthnChallengeTTL: getenvDuration("AUTH_WEBAUTHN_CHALLENGE_TTL", 5*time.Minute),
SSOSuccessRedirectURL: getenv("AUTH_SSO_SUCCESS_REDIRECT", ""),
DefaultRoleName: getenv("AUTH_DEFAULT_ROLE", "user"),
DisableRegister: getenvBool("AUTH_DISABLE_REGISTER", false),
}
}
func LoadSESConfigFromEnv() SESConfig {
return SESConfig{
Region: getenv("SES_REGION", getenv("AWS_REGION", "")),
Endpoint: getenv("SES_ENDPOINT", ""),
From: getenv("SES_FROM", ""),
ConfigurationSetName: getenv("SES_CONFIGURATION_SET", ""),
SendTimeout: getenvDuration("SES_SEND_TIMEOUT", 10*time.Second),
AllowInsecureEndpoint: getenvBool("SES_ALLOW_INSECURE_ENDPOINT", false),
}
}
func LoadEmailDeliveryConfigFromEnv() EmailDeliveryConfig {
return EmailDeliveryConfig{
Provider: strings.ToLower(strings.TrimSpace(getenv("EMAIL_PROVIDER", "ses"))),
MockDir: strings.TrimSpace(getenv("EMAIL_MOCK_DIR", ".local/emails")),
}
}
func LoadMicrosoftSSOConfigFromEnv() MicrosoftSSOConfig {
scopes := strings.Split(getenv("MS_ENTRA_SCOPES", "openid,profile,email,offline_access,User.Read"), ",")
for i := range scopes {
scopes[i] = strings.TrimSpace(scopes[i])
}
return MicrosoftSSOConfig{
TenantID: getenv("MS_ENTRA_TENANT_ID", ""),
ClientID: getenv("MS_ENTRA_CLIENT_ID", ""),
ClientSecret: getenv("MS_ENTRA_CLIENT_SECRET", ""),
RedirectURL: getenv("MS_ENTRA_REDIRECT_URL", ""),
Authority: getenv("MS_ENTRA_AUTHORITY", "https://login.microsoftonline.com/"),
Scopes: scopes,
}
}
func LoadHTTPConfigFromEnv() HTTPConfig {
return HTTPConfig{
BodyLimit: getenvInt("HTTP_BODY_LIMIT", 4*1024*1024),
ReadTimeout: getenvDuration("HTTP_READ_TIMEOUT", 10*time.Second),
WriteTimeout: getenvDuration("HTTP_WRITE_TIMEOUT", 15*time.Second),
IdleTimeout: getenvDuration("HTTP_IDLE_TIMEOUT", 60*time.Second),
RequestTimeout: getenvDuration("HTTP_REQUEST_TIMEOUT", 30*time.Second),
RequestTimeoutByPath: parseHTTPTimeoutOverrides(getenv("HTTP_REQUEST_TIMEOUT_OVERRIDES", "")),
ShutdownTimeout: getenvDuration("HTTP_SHUTDOWN_TIMEOUT", 30*time.Second),
CORSAllowOrigins: getenv("HTTP_CORS_ALLOW_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173"),
CORSAllowMethods: getenv("HTTP_CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD"),
CORSAllowHeaders: getenv("HTTP_CORS_ALLOW_HEADERS", "Origin,Content-Type,Accept,Authorization,X-Requested-With,X-Pin-Verification-Token,X-Device-Type"),
CORSExposeHeaders: getenv("HTTP_CORS_EXPOSE_HEADERS", "X-Request-ID"),
CORSAllowCredentials: getenvBool("HTTP_CORS_ALLOW_CREDENTIALS", true),
CORSMaxAge: getenvInt("HTTP_CORS_MAX_AGE", 300),
RateLimitMax: getenvInt("HTTP_RATE_LIMIT_MAX", 120),
RateLimitWindow: getenvDuration("HTTP_RATE_LIMIT_WINDOW", time.Minute),
RateLimitByEndpoint: parseHTTPEndpointRateLimitOverrides(getenv("HTTP_RATE_LIMIT_ENDPOINTS", "")),
HSTSMaxAge: getenvInt("HTTP_SECURITY_HSTS_MAX_AGE", 0),
}
}
func parseHTTPTimeoutOverrides(raw string) map[string]time.Duration {
parsed := make(map[string]time.Duration)
if strings.TrimSpace(raw) == "" {
return parsed
}
entries := strings.Split(raw, ",")
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.SplitN(entry, "=", 2)
if len(parts) != 2 {
continue
}
keyParts := strings.Fields(strings.TrimSpace(parts[0]))
if len(keyParts) < 2 {
continue
}
method := strings.ToUpper(strings.TrimSpace(keyParts[0]))
path := strings.Join(keyParts[1:], " ")
path = strings.TrimSpace(path)
if method == "" || path == "" {
continue
}
key := method + " " + path
duration, err := time.ParseDuration(strings.TrimSpace(parts[1]))
if err != nil || duration <= 0 {
continue
}
parsed[key] = duration
}
return parsed
}
func parseHTTPEndpointRateLimitOverrides(raw string) map[string]RateLimitRule {
parsed := make(map[string]RateLimitRule)
if strings.TrimSpace(raw) == "" {
return parsed
}
entries := strings.Split(raw, ";")
for _, entry := range entries {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
parts := strings.SplitN(entry, "=", 2)
if len(parts) != 2 {
continue
}
keyParts := strings.Fields(strings.TrimSpace(parts[0]))
if len(keyParts) < 2 {
continue
}
method := strings.ToUpper(strings.TrimSpace(keyParts[0]))
path := strings.Join(keyParts[1:], " ")
path = strings.TrimSpace(path)
if method == "" || path == "" {
continue
}
key := method + " " + path
ruleParts := strings.SplitN(strings.TrimSpace(parts[1]), ",", 2)
if len(ruleParts) != 2 {
continue
}
max, err := strconv.Atoi(strings.TrimSpace(ruleParts[0]))
if err != nil || max <= 0 {
continue
}
window, err := time.ParseDuration(strings.TrimSpace(ruleParts[1]))
if err != nil || window <= 0 {
continue
}
parsed[key] = RateLimitRule{
Max: max,
Window: window,
}
}
return parsed
}
func LoadAuditConfigFromEnv() AuditConfig {
return AuditConfig{
QueueSize: getenvInt("AUDIT_LOG_QUEUE_SIZE", 1024),
Workers: getenvInt("AUDIT_LOG_WORKERS", 2),
}
}
func LoadLoggingConfigFromEnv() LoggingConfig {
return LoggingConfig{
Level: getenv("LOG_LEVEL", "info"),
Format: getenv("LOG_FORMAT", "json"),
}
}
func LoadResilienceConfigFromEnv() ResilienceConfig {
defaults := loadCircuitBreakerPolicyFromEnv("CB_DEFAULT_", CircuitBreakerPolicy{
Enabled: true,
MaxRequests: 1,
Interval: time.Minute,
BucketPeriod: 10 * time.Second,
Timeout: 30 * time.Second,
MinRequests: 5,
FailureRatio: 0.6,
ConsecutiveFailures: 5,
})
return ResilienceConfig{
Default: defaults,
MicrosoftSSO: loadCircuitBreakerPolicyFromEnv("CB_MICROSOFT_SSO_", defaults),
SES: loadCircuitBreakerPolicyFromEnv("CB_SES_", defaults),
SQS: loadCircuitBreakerPolicyFromEnv("CB_SQS_", defaults),
}
}
func LoadQueueConfigFromEnv() QueueConfig {
return QueueConfig{
MessageSchemaVersion: getenv("QUEUE_MESSAGE_SCHEMA_VERSION", "v2"),
EnableLegacyPayloadFallback: getenvBool("QUEUE_ENABLE_LEGACY_PAYLOAD_FALLBACK", true),
IdempotencyKeyPrefix: getenv("QUEUE_IDEMPOTENCY_KEY_PREFIX", "queue:idempotency:email:"),
IdempotencyTTL: getenvDuration("QUEUE_IDEMPOTENCY_TTL", 24*time.Hour),
ProcessingTTL: getenvDuration("QUEUE_PROCESSING_TTL", 15*time.Minute),
WorkerConcurrency: getenvInt("QUEUE_WORKER_CONCURRENCY", 4),
WorkerMaxBatchSize: getenvInt("QUEUE_WORKER_MAX_BATCH_SIZE", 10),
WorkerProcessTimeout: getenvDuration("QUEUE_WORKER_PROCESS_TIMEOUT", 30*time.Second),
WorkerShutdownTimeout: getenvDuration("QUEUE_WORKER_SHUTDOWN_TIMEOUT", 30*time.Second),
WorkerBackoffMin: getenvDuration("QUEUE_WORKER_BACKOFF_MIN", time.Second),
WorkerBackoffMax: getenvDuration("QUEUE_WORKER_BACKOFF_MAX", 5*time.Minute),
WorkerPermanentFailureDelay: getenvDuration("QUEUE_WORKER_PERMANENT_FAILURE_DELAY", 30*time.Second),
WorkerMonitorAddr: getenv("QUEUE_WORKER_MONITOR_ADDR", "127.0.0.1:9090"),
WorkerHealthErrorThreshold: getenvInt("QUEUE_WORKER_HEALTH_ERROR_THRESHOLD", 3),
WorkerDepthPollInterval: getenvDuration("QUEUE_WORKER_DEPTH_POLL_INTERVAL", 30*time.Second),
OutboxEnabled: getenvBool("QUEUE_OUTBOX_ENABLED", true),
OutboxPollInterval: getenvDuration("QUEUE_OUTBOX_POLL_INTERVAL", time.Second),
OutboxBatchSize: getenvInt("QUEUE_OUTBOX_BATCH_SIZE", 10),
OutboxDispatchTimeout: getenvDuration("QUEUE_OUTBOX_DISPATCH_TIMEOUT", 10*time.Second),
OutboxLockTTL: getenvDuration("QUEUE_OUTBOX_LOCK_TTL", time.Minute),
OutboxMaxAttempts: getenvInt("QUEUE_OUTBOX_MAX_ATTEMPTS", 20),
}
}
func LoadFileQueueConfigFromEnv() FileQueueConfig {
return FileQueueConfig{
MessageSchemaVersion: getenv("FILE_QUEUE_MESSAGE_SCHEMA_VERSION", "v1"),
IdempotencyKeyPrefix: getenv("FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX", "queue:idempotency:file:"),
IdempotencyTTL: getenvDuration("FILE_QUEUE_IDEMPOTENCY_TTL", 24*time.Hour),
ProcessingTTL: getenvDuration("FILE_QUEUE_PROCESSING_TTL", 15*time.Minute),
WorkerConcurrency: getenvInt("FILE_QUEUE_WORKER_CONCURRENCY", 4),
WorkerMaxBatchSize: getenvInt("FILE_QUEUE_WORKER_MAX_BATCH_SIZE", 10),
WorkerProcessTimeout: getenvDuration("FILE_QUEUE_WORKER_PROCESS_TIMEOUT", 30*time.Second),
WorkerShutdownTimeout: getenvDuration("FILE_QUEUE_WORKER_SHUTDOWN_TIMEOUT", 30*time.Second),
WorkerBackoffMin: getenvDuration("FILE_QUEUE_WORKER_BACKOFF_MIN", time.Second),
WorkerBackoffMax: getenvDuration("FILE_QUEUE_WORKER_BACKOFF_MAX", 5*time.Minute),
WorkerPermanentFailureDelay: getenvDuration("FILE_QUEUE_WORKER_PERMANENT_FAILURE_DELAY", 30*time.Second),
WorkerMonitorAddr: getenv("FILE_QUEUE_WORKER_MONITOR_ADDR", "127.0.0.1:9091"),
WorkerHealthErrorThreshold: getenvInt("FILE_QUEUE_WORKER_HEALTH_ERROR_THRESHOLD", 3),
WorkerDepthPollInterval: getenvDuration("FILE_QUEUE_WORKER_DEPTH_POLL_INTERVAL", 30*time.Second),
OutboxEnabled: getenvBool("FILE_QUEUE_OUTBOX_ENABLED", true),
OutboxPollInterval: getenvDuration("FILE_QUEUE_OUTBOX_POLL_INTERVAL", time.Second),
OutboxBatchSize: getenvInt("FILE_QUEUE_OUTBOX_BATCH_SIZE", 10),
OutboxDispatchTimeout: getenvDuration("FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT", 10*time.Second),
OutboxLockTTL: getenvDuration("FILE_QUEUE_OUTBOX_LOCK_TTL", time.Minute),
OutboxMaxAttempts: getenvInt("FILE_QUEUE_OUTBOX_MAX_ATTEMPTS", 20),
}
}
func LoadSQSConfigFromEnv() SQSConfig {
return SQSConfig{
Region: getenv("AWS_REGION", ""),
AccessKeyID: getenv("AWS_ACCESS_KEY_ID", ""),
SecretAccessKey: getenv("AWS_SECRET_ACCESS_KEY", ""),
Endpoint: getenv("SQS_ENDPOINT", ""),
QueueURL: getenv("SQS_QUEUE_URL", ""),
QueueType: getenv("SQS_QUEUE_TYPE", "standard"),
MessageGroupID: getenv("SQS_MESSAGE_GROUP_ID", "email"),
MaxMessages: getenvInt("SQS_MAX_MESSAGES", 10),
WaitTimeSeconds: int32(getenvInt("SQS_WAIT_TIME_SECONDS", 20)),
VisibilityTimeout: getenvDuration("SQS_VISIBILITY_TIMEOUT", 60*time.Second),
AllowInsecureEndpoint: getenvBool("SQS_ALLOW_INSECURE_ENDPOINT", false),
}
}
func LoadFileSQSConfigFromEnv() SQSConfig {
return SQSConfig{
Region: getenv("FILE_SQS_REGION", getenv("AWS_REGION", "")),
AccessKeyID: getenv("AWS_ACCESS_KEY_ID", ""),
SecretAccessKey: getenv("AWS_SECRET_ACCESS_KEY", ""),
Endpoint: getenv("FILE_SQS_ENDPOINT", getenv("SQS_ENDPOINT", "")),
QueueURL: getenv("FILE_SQS_QUEUE_URL", ""),
QueueType: getenv("FILE_SQS_QUEUE_TYPE", "standard"),
MessageGroupID: getenv("FILE_SQS_MESSAGE_GROUP_ID", "file-processing"),
MaxMessages: getenvInt("FILE_SQS_MAX_MESSAGES", 10),
WaitTimeSeconds: int32(getenvInt("FILE_SQS_WAIT_TIME_SECONDS", 20)),
VisibilityTimeout: getenvDuration("FILE_SQS_VISIBILITY_TIMEOUT", 60*time.Second),
AllowInsecureEndpoint: getenvBool("FILE_SQS_ALLOW_INSECURE_ENDPOINT", getenvBool("SQS_ALLOW_INSECURE_ENDPOINT", false)),
}
}
func ValidateDistinctQueueURLs(emailCfg, fileCfg SQSConfig) error {
emailQueueURL := canonicalQueueURL(emailCfg.QueueURL)
fileQueueURL := canonicalQueueURL(fileCfg.QueueURL)
if emailQueueURL == "" || fileQueueURL == "" {
return nil
}
if emailQueueURL == fileQueueURL {
return fmt.Errorf("invalid queue configuration: FILE_SQS_QUEUE_URL must be different from SQS_QUEUE_URL")
}
return nil
}
func canonicalQueueURL(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil {
return strings.TrimRight(strings.ToLower(trimmed), "/")
}
parsed.Host = strings.ToLower(parsed.Host)
parsed.Path = strings.TrimRight(parsed.Path, "/")
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func LoadFileStorageConfigFromEnv() FileStorageConfig {
provider := strings.ToLower(strings.TrimSpace(getenv("FILE_STORAGE_PROVIDER", "s3")))
return FileStorageConfig{
Provider: provider,
Bucket: strings.TrimSpace(getenv("FILE_S3_BUCKET", "")),
Region: strings.TrimSpace(getenv("FILE_S3_REGION", getenv("AWS_REGION", ""))),
Endpoint: strings.TrimSpace(getenv("FILE_S3_ENDPOINT", "")),
AccessKeyID: strings.TrimSpace(getenv("FILE_S3_ACCESS_KEY_ID", getenv("AWS_ACCESS_KEY_ID", ""))),
SecretAccessKey: strings.TrimSpace(getenv("FILE_S3_SECRET_ACCESS_KEY", getenv("AWS_SECRET_ACCESS_KEY", ""))),
ForcePathStyle: getenvBool("FILE_S3_FORCE_PATH_STYLE", false),
AllowInsecureEndpoint: getenvBool("FILE_S3_ALLOW_INSECURE_ENDPOINT", false),
AutoCreateBucket: getenvBool("FILE_S3_AUTO_CREATE_BUCKET", false),
ObjectKeyPrefix: strings.TrimSpace(getenv("FILE_OBJECT_KEY_PREFIX", "fm/objects")),
PresignPutTTL: getenvDuration("FILE_S3_PRESIGN_PUT_TTL", 15*time.Minute),
PresignGetTTL: getenvDuration("FILE_S3_PRESIGN_GET_TTL", 15*time.Minute),
}
}
func LoadFileManagerUploadPolicyConfigFromEnv() FileManagerUploadPolicyConfig {
return FileManagerUploadPolicyConfig{
MaxFileSizeBytes: int64(getenvInt("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", 4*1024*1024)),
AllowedMimeTypes: normalizeMimeTypes(getenvCSV("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", "")),
AllowedExtensions: normalizeExtensions(getenvCSV("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", "")),
AllowEmptyFile: getenvBool("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", false),
MaxFilenameLength: getenvInt("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", 255),
}
}
func LoadFileManagerTrashPolicyConfigFromEnv() FileManagerTrashPolicyConfig {
retention := getenvDuration("FILE_MANAGER_TRASH_RETENTION", 30*24*time.Hour)
if retention < 0 {
retention = 0
}
interval := getenvDuration("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", time.Hour)
if interval <= 0 {
interval = time.Hour
}
batchSize := getenvInt("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", 200)
if batchSize <= 0 {
batchSize = 200
}
return FileManagerTrashPolicyConfig{
AutoPurgeEnabled: getenvBool("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", true),
Retention: retention,
AutoPurgeInterval: interval,
AutoPurgeBatchSize: batchSize,
}
}
func LoadFileManagerLifecycleConfigFromEnv() FileManagerLifecycleConfig {
tempHours := getenvInt("TEMP_UPLOAD_GRACE_HOURS", 2)
if tempHours <= 0 {
tempHours = 2
}
orphanDays := getenvInt("ORPHAN_GRACE_PERIOD_DAYS", 30)
if orphanDays <= 0 {
orphanDays = 30
}
batchSize := getenvInt("FILE_CLEANUP_BATCH_SIZE", 100)
if batchSize <= 0 {
batchSize = 100
}
return FileManagerLifecycleConfig{
EnableS3Tagging: getenvBool("ENABLE_S3_TAGGING", true),
EnableCleanupJob: getenvBool("ENABLE_FILE_CLEANUP_JOB", false),
TempUploadGraceHours: tempHours,
OrphanGraceDays: orphanDays,
CleanupBatchSize: batchSize,
CleanupDryRun: getenvBool("FILE_CLEANUP_DRY_RUN", true),
}
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getenvInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getenvUint32(key string, def uint32) uint32 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseUint(v, 10, 32); err == nil {
return uint32(n)
}
}
return def
}
func getenvFloat64(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if n, err := strconv.ParseFloat(v, 64); err == nil {
return n
}
}
return def
}
func getenvDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return def
}
func getenvBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return def
}
func getenvCSV(key, def string) []string {
raw := os.Getenv(key)
if strings.TrimSpace(raw) == "" {
raw = def
}
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for i := range parts {
part := strings.TrimSpace(parts[i])
if part == "" {
continue
}
out = append(out, part)
}
return out
}
func normalizeMimeTypes(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for i := range values {
v := strings.ToLower(strings.TrimSpace(values[i]))
if idx := strings.Index(v, ";"); idx >= 0 {
v = strings.TrimSpace(v[:idx])
}
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func normalizeExtensions(values []string) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for i := range values {
v := strings.ToLower(strings.TrimSpace(values[i]))
v = strings.TrimPrefix(v, ".")
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func loadCircuitBreakerPolicyFromEnv(prefix string, defaults CircuitBreakerPolicy) CircuitBreakerPolicy {
return CircuitBreakerPolicy{
Enabled: getenvBool(prefix+"ENABLED", defaults.Enabled),
MaxRequests: getenvUint32(prefix+"MAX_REQUESTS", defaults.MaxRequests),
Interval: getenvDuration(prefix+"INTERVAL", defaults.Interval),
BucketPeriod: getenvDuration(prefix+"BUCKET_PERIOD", defaults.BucketPeriod),
Timeout: getenvDuration(prefix+"TIMEOUT", defaults.Timeout),
MinRequests: getenvUint32(prefix+"MIN_REQUESTS", defaults.MinRequests),
FailureRatio: getenvFloat64(prefix+"FAILURE_RATIO", defaults.FailureRatio),
ConsecutiveFailures: getenvUint32(prefix+"CONSECUTIVE_FAILURES", defaults.ConsecutiveFailures),
}
}

View File

@@ -0,0 +1,340 @@
package config
import (
"testing"
"time"
)
func TestLoadFileManagerUploadPolicyConfigFromEnv_Defaults(t *testing.T) {
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", "")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", "")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", "")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", "")
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", "")
cfg := LoadFileManagerUploadPolicyConfigFromEnv()
if cfg.MaxFileSizeBytes != 4*1024*1024 {
t.Fatalf("expected default max file size 4194304, got %d", cfg.MaxFileSizeBytes)
}
if cfg.AllowEmptyFile {
t.Fatalf("expected default allow empty file false")
}
if cfg.MaxFilenameLength != 255 {
t.Fatalf("expected default max filename length 255, got %d", cfg.MaxFilenameLength)
}
if len(cfg.AllowedMimeTypes) != 0 {
t.Fatalf("expected empty default allowed mime types, got %v", cfg.AllowedMimeTypes)
}
if len(cfg.AllowedExtensions) != 0 {
t.Fatalf("expected empty default allowed extensions, got %v", cfg.AllowedExtensions)
}
}
func TestLoadFileManagerUploadPolicyConfigFromEnv_Normalize(t *testing.T) {
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILE_SIZE_BYTES", "1048576")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_MIME_TYPES", " image/PNG ; charset=utf-8,application/pdf,IMAGE/PNG")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOWED_EXTENSIONS", " .PDF, png ,PNG")
t.Setenv("FILE_MANAGER_UPLOAD_ALLOW_EMPTY_FILE", "true")
t.Setenv("FILE_MANAGER_UPLOAD_MAX_FILENAME_LENGTH", "120")
cfg := LoadFileManagerUploadPolicyConfigFromEnv()
if cfg.MaxFileSizeBytes != 1048576 {
t.Fatalf("expected max file size 1048576, got %d", cfg.MaxFileSizeBytes)
}
if !cfg.AllowEmptyFile {
t.Fatalf("expected allow empty file true")
}
if cfg.MaxFilenameLength != 120 {
t.Fatalf("expected max filename length 120, got %d", cfg.MaxFilenameLength)
}
if len(cfg.AllowedMimeTypes) != 2 || cfg.AllowedMimeTypes[0] != "image/png" || cfg.AllowedMimeTypes[1] != "application/pdf" {
t.Fatalf("unexpected normalized mime types: %v", cfg.AllowedMimeTypes)
}
if len(cfg.AllowedExtensions) != 2 || cfg.AllowedExtensions[0] != "pdf" || cfg.AllowedExtensions[1] != "png" {
t.Fatalf("unexpected normalized extensions: %v", cfg.AllowedExtensions)
}
}
func TestLoadFileManagerTrashPolicyConfigFromEnv_Defaults(t *testing.T) {
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", "")
t.Setenv("FILE_MANAGER_TRASH_RETENTION", "")
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", "")
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", "")
cfg := LoadFileManagerTrashPolicyConfigFromEnv()
if !cfg.AutoPurgeEnabled {
t.Fatalf("expected default auto purge enabled")
}
if cfg.Retention != 30*24*time.Hour {
t.Fatalf("expected default retention 720h, got %s", cfg.Retention)
}
if cfg.AutoPurgeInterval != time.Hour {
t.Fatalf("expected default auto purge interval 1h, got %s", cfg.AutoPurgeInterval)
}
if cfg.AutoPurgeBatchSize != 200 {
t.Fatalf("expected default batch size 200, got %d", cfg.AutoPurgeBatchSize)
}
}
func TestLoadFileManagerTrashPolicyConfigFromEnv_Custom(t *testing.T) {
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_ENABLED", "false")
t.Setenv("FILE_MANAGER_TRASH_RETENTION", "168h")
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_INTERVAL", "15m")
t.Setenv("FILE_MANAGER_TRASH_AUTO_PURGE_BATCH_SIZE", "50")
cfg := LoadFileManagerTrashPolicyConfigFromEnv()
if cfg.AutoPurgeEnabled {
t.Fatalf("expected auto purge disabled")
}
if cfg.Retention != 168*time.Hour {
t.Fatalf("expected retention 168h, got %s", cfg.Retention)
}
if cfg.AutoPurgeInterval != 15*time.Minute {
t.Fatalf("expected auto purge interval 15m, got %s", cfg.AutoPurgeInterval)
}
if cfg.AutoPurgeBatchSize != 50 {
t.Fatalf("expected batch size 50, got %d", cfg.AutoPurgeBatchSize)
}
}
func TestLoadFileManagerLifecycleConfigFromEnv_Defaults(t *testing.T) {
t.Setenv("ENABLE_S3_TAGGING", "")
t.Setenv("ENABLE_FILE_CLEANUP_JOB", "")
t.Setenv("TEMP_UPLOAD_GRACE_HOURS", "")
t.Setenv("ORPHAN_GRACE_PERIOD_DAYS", "")
t.Setenv("FILE_CLEANUP_BATCH_SIZE", "")
t.Setenv("FILE_CLEANUP_DRY_RUN", "")
cfg := LoadFileManagerLifecycleConfigFromEnv()
if !cfg.EnableS3Tagging {
t.Fatalf("expected s3 tagging enabled by default")
}
if cfg.EnableCleanupJob {
t.Fatalf("expected cleanup job disabled by default")
}
if cfg.TempUploadGraceHours != 2 {
t.Fatalf("expected temp upload grace 2 hours, got %d", cfg.TempUploadGraceHours)
}
if cfg.OrphanGraceDays != 30 {
t.Fatalf("expected orphan grace 30 days, got %d", cfg.OrphanGraceDays)
}
if cfg.CleanupBatchSize != 100 {
t.Fatalf("expected cleanup batch size 100, got %d", cfg.CleanupBatchSize)
}
if !cfg.CleanupDryRun {
t.Fatalf("expected cleanup dry run enabled by default")
}
}
func TestLoadFileManagerLifecycleConfigFromEnv_Custom(t *testing.T) {
t.Setenv("ENABLE_S3_TAGGING", "false")
t.Setenv("ENABLE_FILE_CLEANUP_JOB", "true")
t.Setenv("TEMP_UPLOAD_GRACE_HOURS", "6")
t.Setenv("ORPHAN_GRACE_PERIOD_DAYS", "45")
t.Setenv("FILE_CLEANUP_BATCH_SIZE", "250")
t.Setenv("FILE_CLEANUP_DRY_RUN", "false")
cfg := LoadFileManagerLifecycleConfigFromEnv()
if cfg.EnableS3Tagging {
t.Fatalf("expected s3 tagging disabled")
}
if !cfg.EnableCleanupJob {
t.Fatalf("expected cleanup job enabled")
}
if cfg.TempUploadGraceHours != 6 {
t.Fatalf("expected temp upload grace 6 hours, got %d", cfg.TempUploadGraceHours)
}
if cfg.OrphanGraceDays != 45 {
t.Fatalf("expected orphan grace 45 days, got %d", cfg.OrphanGraceDays)
}
if cfg.CleanupBatchSize != 250 {
t.Fatalf("expected cleanup batch size 250, got %d", cfg.CleanupBatchSize)
}
if cfg.CleanupDryRun {
t.Fatalf("expected cleanup dry run disabled")
}
}
func TestLoadFileQueueConfigFromEnv_Defaults(t *testing.T) {
t.Setenv("FILE_QUEUE_MESSAGE_SCHEMA_VERSION", "")
t.Setenv("FILE_QUEUE_IDEMPOTENCY_KEY_PREFIX", "")
t.Setenv("FILE_QUEUE_WORKER_MONITOR_ADDR", "")
t.Setenv("FILE_QUEUE_OUTBOX_ENABLED", "")
t.Setenv("FILE_QUEUE_OUTBOX_POLL_INTERVAL", "")
t.Setenv("FILE_QUEUE_OUTBOX_BATCH_SIZE", "")
t.Setenv("FILE_QUEUE_OUTBOX_DISPATCH_TIMEOUT", "")
t.Setenv("FILE_QUEUE_OUTBOX_LOCK_TTL", "")
t.Setenv("FILE_QUEUE_OUTBOX_MAX_ATTEMPTS", "")
cfg := LoadFileQueueConfigFromEnv()
if cfg.MessageSchemaVersion != "v1" {
t.Fatalf("expected default schema version v1, got %q", cfg.MessageSchemaVersion)
}
if cfg.IdempotencyKeyPrefix != "queue:idempotency:file:" {
t.Fatalf("unexpected default idempotency prefix: %q", cfg.IdempotencyKeyPrefix)
}
if cfg.WorkerMonitorAddr != "127.0.0.1:9091" {
t.Fatalf("unexpected default monitor addr: %q", cfg.WorkerMonitorAddr)
}
if !cfg.OutboxEnabled {
t.Fatalf("expected default file outbox enabled")
}
if cfg.OutboxPollInterval != time.Second {
t.Fatalf("expected default outbox poll interval 1s, got %s", cfg.OutboxPollInterval)
}
if cfg.OutboxBatchSize != 10 {
t.Fatalf("expected default outbox batch size 10, got %d", cfg.OutboxBatchSize)
}
if cfg.OutboxDispatchTimeout != 10*time.Second {
t.Fatalf("expected default outbox dispatch timeout 10s, got %s", cfg.OutboxDispatchTimeout)
}
if cfg.OutboxLockTTL != time.Minute {
t.Fatalf("expected default outbox lock ttl 1m, got %s", cfg.OutboxLockTTL)
}
if cfg.OutboxMaxAttempts != 20 {
t.Fatalf("expected default outbox max attempts 20, got %d", cfg.OutboxMaxAttempts)
}
}
func TestLoadFileSQSConfigFromEnv(t *testing.T) {
t.Setenv("AWS_REGION", "ap-southeast-1")
t.Setenv("SQS_ENDPOINT", "http://localhost:4566")
t.Setenv("SQS_ALLOW_INSECURE_ENDPOINT", "true")
t.Setenv("FILE_SQS_REGION", "")
t.Setenv("FILE_SQS_ENDPOINT", "")
t.Setenv("FILE_SQS_QUEUE_URL", "http://localhost:4566/000000000000/app-file-queue")
t.Setenv("FILE_SQS_QUEUE_TYPE", "standard")
t.Setenv("FILE_SQS_MESSAGE_GROUP_ID", "file")
t.Setenv("FILE_SQS_MAX_MESSAGES", "8")
t.Setenv("FILE_SQS_WAIT_TIME_SECONDS", "15")
t.Setenv("FILE_SQS_VISIBILITY_TIMEOUT", "90s")
t.Setenv("FILE_SQS_ALLOW_INSECURE_ENDPOINT", "")
cfg := LoadFileSQSConfigFromEnv()
if cfg.Region != "ap-southeast-1" {
t.Fatalf("unexpected region: %q", cfg.Region)
}
if cfg.Endpoint != "http://localhost:4566" {
t.Fatalf("unexpected endpoint: %q", cfg.Endpoint)
}
if cfg.QueueURL == "" {
t.Fatalf("expected queue url")
}
if cfg.MaxMessages != 8 {
t.Fatalf("unexpected max messages: %d", cfg.MaxMessages)
}
if cfg.WaitTimeSeconds != 15 {
t.Fatalf("unexpected wait time seconds: %d", cfg.WaitTimeSeconds)
}
if !cfg.AllowInsecureEndpoint {
t.Fatalf("expected insecure endpoint allowed from fallback")
}
}
func TestValidateDistinctQueueURLs(t *testing.T) {
t.Run("different queue urls", func(t *testing.T) {
err := ValidateDistinctQueueURLs(
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue"},
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-file-queue"},
)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
t.Run("same queue urls with different case and slash", func(t *testing.T) {
err := ValidateDistinctQueueURLs(
SQSConfig{QueueURL: "HTTP://LOCALHOST:4566/000000000000/app-queue"},
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue/"},
)
if err == nil {
t.Fatalf("expected error for same queue urls")
}
})
t.Run("skip when one queue url empty", func(t *testing.T) {
err := ValidateDistinctQueueURLs(
SQSConfig{QueueURL: "http://localhost:4566/000000000000/app-queue"},
SQSConfig{QueueURL: ""},
)
if err != nil {
t.Fatalf("expected no error when file queue url is empty, got %v", err)
}
})
}
func TestParseHTTPTimeoutOverrides(t *testing.T) {
t.Run("invalid format ignored", func(t *testing.T) {
parsed := parseHTTPTimeoutOverrides("GET /users/:id 100ms,foo")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("invalid duration ignored", func(t *testing.T) {
parsed := parseHTTPTimeoutOverrides("GET /users/:id=not-a-duration")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("zero and negative duration ignored", func(t *testing.T) {
parsed := parseHTTPTimeoutOverrides("GET /users/:id=0s,POST /users=-1s")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("valid entries parsed", func(t *testing.T) {
parsed := parseHTTPTimeoutOverrides("GET /users/:id=120ms, POST /auth/login = 2s")
if len(parsed) != 2 {
t.Fatalf("expected 2 parsed entries, got %d", len(parsed))
}
if parsed["GET /users/:id"] != 120*time.Millisecond {
t.Fatalf("unexpected GET /users/:id timeout: %s", parsed["GET /users/:id"])
}
if parsed["POST /auth/login"] != 2*time.Second {
t.Fatalf("unexpected POST /auth/login timeout: %s", parsed["POST /auth/login"])
}
})
}
func TestParseHTTPEndpointRateLimitOverrides(t *testing.T) {
t.Run("invalid entries ignored", func(t *testing.T) {
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=abc,1m;foo;GET /x=1")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("zero or negative values ignored", func(t *testing.T) {
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=0,1m;POST /auth/totp/verify=5,0s")
if len(parsed) != 0 {
t.Fatalf("expected no parsed entries, got %v", parsed)
}
})
t.Run("valid entries parsed", func(t *testing.T) {
parsed := parseHTTPEndpointRateLimitOverrides("POST /auth/login=5,1m; POST /auth/totp/verify = 3,30s")
if len(parsed) != 2 {
t.Fatalf("expected 2 parsed entries, got %d", len(parsed))
}
login, ok := parsed["POST /auth/login"]
if !ok {
t.Fatalf("expected POST /auth/login to be parsed")
}
if login.Max != 5 || login.Window != time.Minute {
t.Fatalf("unexpected login rule: %+v", login)
}
totp, ok := parsed["POST /auth/totp/verify"]
if !ok {
t.Fatalf("expected POST /auth/totp/verify to be parsed")
}
if totp.Max != 3 || totp.Window != 30*time.Second {
t.Fatalf("unexpected totp rule: %+v", totp)
}
})
}

638
internal/config/loader.go Normal file
View File

@@ -0,0 +1,638 @@
package config
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Duration unmarshals a Go duration string (e.g. "30s", "5m", "150ms") from JSON.
// An empty string is treated as zero duration.
type Duration struct{ time.Duration }
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
if s == "" {
d.Duration = 0
return nil
}
dur, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", s, err)
}
d.Duration = dur
return nil
}
// AppConfig is the unified config loaded from a JSON file.
type AppConfig struct {
HTTPPort int
MySQL MySQLConfig
Logging LoggingConfig
Audit AuditConfig
Auth AuthConfig
SES SESConfig
Email EmailDeliveryConfig
MicrosoftSSO MicrosoftSSOConfig
HTTP HTTPConfig
Resilience ResilienceConfig
Queue QueueConfig
FileQueue FileQueueConfig
SQS SQSConfig
FileSQS SQSConfig
FileStorage FileStorageConfig
FileUpload FileManagerUploadPolicyConfig
SAML SAMLConfig
WOPI WOPIConfig
Gotenberg GotenbergConfig
}
// LoadFromFile reads path, parses it as JSON, and returns a populated AppConfig.
// The file is typically produced by scripts/inject-secrets.sh.
func LoadFromFile(path string) (AppConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return AppConfig{}, fmt.Errorf("config: read %q: %w", path, err)
}
var raw jsonAppConfig
if err := json.Unmarshal(data, &raw); err != nil {
return AppConfig{}, fmt.Errorf("config: parse %q: %w", path, err)
}
return raw.toAppConfig(), nil
}
// ── JSON intermediate types ───────────────────────────────────────────────────
type jsonCircuitBreakerPolicy struct {
Enabled bool `json:"enabled"`
MaxRequests uint32 `json:"maxRequests"`
Interval Duration `json:"interval"`
BucketPeriod Duration `json:"bucketPeriod"`
Timeout Duration `json:"timeout"`
MinRequests uint32 `json:"minRequests"`
FailureRatio float64 `json:"failureRatio"`
ConsecutiveFailures uint32 `json:"consecutiveFailures"`
}
func (j jsonCircuitBreakerPolicy) toPolicy() CircuitBreakerPolicy {
return CircuitBreakerPolicy{
Enabled: j.Enabled,
MaxRequests: j.MaxRequests,
Interval: j.Interval.Duration,
BucketPeriod: j.BucketPeriod.Duration,
Timeout: j.Timeout.Duration,
MinRequests: j.MinRequests,
FailureRatio: j.FailureRatio,
ConsecutiveFailures: j.ConsecutiveFailures,
}
}
type jsonWorker struct {
Concurrency int `json:"concurrency"`
MaxBatchSize int `json:"maxBatchSize"`
ProcessTimeout Duration `json:"processTimeout"`
ShutdownTimeout Duration `json:"shutdownTimeout"`
BackoffMin Duration `json:"backoffMin"`
BackoffMax Duration `json:"backoffMax"`
PermanentFailureDelay Duration `json:"permanentFailureDelay"`
MonitorAddr string `json:"monitorAddr"`
HealthErrorThreshold int `json:"healthErrorThreshold"`
DepthPollInterval Duration `json:"depthPollInterval"`
}
type jsonOutbox struct {
Enabled bool `json:"enabled"`
PollInterval Duration `json:"pollInterval"`
BatchSize int `json:"batchSize"`
DispatchTimeout Duration `json:"dispatchTimeout"`
LockTTL Duration `json:"lockTTL"`
MaxAttempts int `json:"maxAttempts"`
}
type jsonSQS struct {
Region string `json:"region"`
Endpoint string `json:"endpoint"`
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
QueueURL string `json:"queueUrl"`
QueueType string `json:"queueType"`
MessageGroupID string `json:"messageGroupId"`
MaxMessages int `json:"maxMessages"`
WaitTimeSeconds int32 `json:"waitTimeSeconds"`
VisibilityTimeout Duration `json:"visibilityTimeout"`
}
func (j jsonSQS) toSQSConfig(regionFallback, endpointFallback, accessKeyID, secretAccessKey string) SQSConfig {
region := j.Region
if region == "" {
region = regionFallback
}
endpoint := j.Endpoint
if endpoint == "" {
endpoint = endpointFallback
}
return SQSConfig{
Region: region,
AccessKeyID: strings.TrimSpace(accessKeyID),
SecretAccessKey: strings.TrimSpace(secretAccessKey),
Endpoint: endpoint,
QueueURL: j.QueueURL,
QueueType: j.QueueType,
MessageGroupID: j.MessageGroupID,
MaxMessages: j.MaxMessages,
WaitTimeSeconds: j.WaitTimeSeconds,
VisibilityTimeout: j.VisibilityTimeout.Duration,
AllowInsecureEndpoint: j.AllowInsecureEndpoint,
}
}
type jsonAppConfig struct {
HTTP struct {
Port int `json:"port"`
BodyLimit int `json:"bodyLimit"`
Timeouts struct {
Read Duration `json:"read"`
Write Duration `json:"write"`
Idle Duration `json:"idle"`
Request Duration `json:"request"`
RequestOverrides string `json:"requestOverrides"`
Shutdown Duration `json:"shutdown"`
} `json:"timeouts"`
CORS struct {
AllowOrigins string `json:"allowOrigins"`
AllowMethods string `json:"allowMethods"`
AllowHeaders string `json:"allowHeaders"`
ExposeHeaders string `json:"exposeHeaders"`
AllowCredentials bool `json:"allowCredentials"`
MaxAge int `json:"maxAge"`
} `json:"cors"`
RateLimit struct {
Max int `json:"max"`
Window Duration `json:"window"`
Endpoints string `json:"endpoints"`
} `json:"rateLimit"`
Security struct {
HSTSMaxAge int `json:"hstsMaxAge"`
} `json:"security"`
} `json:"http"`
Database struct {
MySQL struct {
DSN string `json:"dsn"`
MaxOpenConns int `json:"maxOpenConns"`
MaxIdleConns int `json:"maxIdleConns"`
ConnMaxLifetime Duration `json:"connMaxLifetime"`
ConnMaxIdleTime Duration `json:"connMaxIdleTime"`
} `json:"mysql"`
} `json:"database"`
Logging struct {
Level string `json:"level"`
Format string `json:"format"`
} `json:"logging"`
Audit struct {
QueueSize int `json:"queueSize"`
Workers int `json:"workers"`
} `json:"audit"`
Email struct {
Provider string `json:"provider"`
MockDir string `json:"mockDir"`
} `json:"email"`
CircuitBreaker struct {
Default jsonCircuitBreakerPolicy `json:"default"`
MicrosoftSSO jsonCircuitBreakerPolicy `json:"microsoftSSO"`
SES jsonCircuitBreakerPolicy `json:"ses"`
SQS jsonCircuitBreakerPolicy `json:"sqs"`
} `json:"circuitBreaker"`
Auth struct {
PublicBaseURL string `json:"publicBaseURL"`
PasswordPepper string `json:"passwordPepper"`
IPEncryptionKey string `json:"ipEncryptionKey"`
DefaultRole string `json:"defaultRole"`
DisableRegister bool `json:"disableRegister"`
Hash struct {
Time uint32 `json:"time"`
MemoryKB uint32 `json:"memoryKB"`
Threads uint8 `json:"threads"`
KeyLen uint32 `json:"keyLen"`
} `json:"hash"`
Email struct {
VerifyTTL Duration `json:"verifyTTL"`
VerifyURL string `json:"verifyURL"`
InviteTTL Duration `json:"inviteTTL"`
} `json:"email"`
Password struct {
SetURL string `json:"setURL"`
ResetTTL Duration `json:"resetTTL"`
ResetURL string `json:"resetURL"`
ForgotEmailCooldown Duration `json:"forgotEmailCooldown"`
ForgotMinDuration Duration `json:"forgotMinDuration"`
ForgotIPRateMax int `json:"forgotIPRateMax"`
ForgotIPRateWindow Duration `json:"forgotIPRateWindow"`
LoginIPRateMax int `json:"loginIPRateMax"`
LoginIPRateWindow Duration `json:"loginIPRateWindow"`
} `json:"password"`
SecurityPin struct {
ResetTTL Duration `json:"resetTTL"`
ResetURL string `json:"resetURL"`
ForgotEmailCooldown Duration `json:"forgotEmailCooldown"`
ForgotMinDuration Duration `json:"forgotMinDuration"`
MaxAttempts int `json:"maxAttempts"`
LockDuration Duration `json:"lockDuration"`
ActionTokenTTL Duration `json:"actionTokenTTL"`
} `json:"securityPin"`
JWT struct {
AccessSecret string `json:"accessSecret"`
RefreshSecret string `json:"refreshSecret"`
AccessTTL Duration `json:"accessTTL"`
RefreshTTL Duration `json:"refreshTTL"`
RefreshTotpTTL Duration `json:"refreshTotpTTL"`
LocalRefreshTTL Duration `json:"localRefreshTTL"`
SessionIdleTTL Duration `json:"sessionIdleTTL"`
Cookie struct {
Domain string `json:"domain"`
Secure bool `json:"secure"`
SameSite string `json:"sameSite"`
AccessName string `json:"accessName"`
RefreshName string `json:"refreshName"`
} `json:"cookie"`
} `json:"jwt"`
TOTP struct {
ChallengeTTL Duration `json:"challengeTTL"`
} `json:"totp"`
SSO struct {
StateTTL Duration `json:"stateTTL"`
SuccessRedirect string `json:"successRedirect"`
} `json:"sso"`
WebAuthn struct {
RPID string `json:"rpId"`
RPDisplayName string `json:"rpDisplayName"`
RPOrigins string `json:"rpOrigins"`
ChallengeTTL Duration `json:"challengeTTL"`
} `json:"webauthn"`
} `json:"auth"`
TOTP struct {
Issuer string `json:"issuer"`
SecretKey string `json:"secretKey"`
} `json:"totp"`
MicrosoftSSO struct {
TenantID string `json:"tenantId"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
RedirectURL string `json:"redirectUrl"`
Authority string `json:"authority"`
Scopes string `json:"scopes"`
} `json:"microsoftSSO"`
SAML struct {
CertFile string `json:"certFile"`
KeyFile string `json:"keyFile"`
MetadataFile string `json:"metadataFile"`
RootURL string `json:"rootUrl"`
FrontendURL string `json:"frontendUrl"`
CookieDomain string `json:"cookieDomain"`
CookieSecure bool `json:"cookieSecure"`
SessionTTLHr int `json:"sessionTTLHour"`
} `json:"saml"`
Queue struct {
MessageSchemaVersion string `json:"messageSchemaVersion"`
EnableLegacyPayloadFallback bool `json:"enableLegacyPayloadFallback"`
IdempotencyKeyPrefix string `json:"idempotencyKeyPrefix"`
IdempotencyTTL Duration `json:"idempotencyTTL"`
ProcessingTTL Duration `json:"processingTTL"`
Worker jsonWorker `json:"worker"`
Outbox jsonOutbox `json:"outbox"`
} `json:"queue"`
FileQueue struct {
MessageSchemaVersion string `json:"messageSchemaVersion"`
IdempotencyKeyPrefix string `json:"idempotencyKeyPrefix"`
IdempotencyTTL Duration `json:"idempotencyTTL"`
ProcessingTTL Duration `json:"processingTTL"`
Worker jsonWorker `json:"worker"`
Outbox jsonOutbox `json:"outbox"`
} `json:"fileQueue"`
AWS struct {
Region string `json:"region"`
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
SQS jsonSQS `json:"sqs"`
FileSQS jsonSQS `json:"fileSqs"`
SES struct {
Region string `json:"region"`
Endpoint string `json:"endpoint"`
From string `json:"from"`
ConfigurationSet string `json:"configurationSet"`
SendTimeout Duration `json:"sendTimeout"`
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
} `json:"ses"`
} `json:"aws"`
File struct {
Provider string `json:"provider"`
S3 struct {
Bucket string `json:"bucket"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
ForcePathStyle bool `json:"forcePathStyle"`
AllowInsecureEndpoint bool `json:"allowInsecureEndpoint"`
AutoCreateBucket bool `json:"autoCreateBucket"`
ObjectKeyPrefix string `json:"objectKeyPrefix"`
PresignPutTTL Duration `json:"presignPutTTL"`
PresignGetTTL Duration `json:"presignGetTTL"`
} `json:"s3"`
Upload struct {
MaxSize int64 `json:"maxSize"`
AllowedMimeTypes string `json:"allowedMimeTypes"`
AllowedExtensions string `json:"allowedExtensions"`
AllowEmptyFile bool `json:"allowEmptyFile"`
MaxFilenameLength int `json:"maxFilenameLength"`
} `json:"upload"`
} `json:"file"`
WOPI struct {
TokenSecret string `json:"tokenSecret"`
ProofSecret string `json:"proofSecret"`
DiscoveryURL string `json:"discoveryUrl"`
PublicBaseURL string `json:"publicBaseUrl"`
EditActionURL string `json:"editActionUrl"`
TokenTTL Duration `json:"tokenTTL"`
MaxBodyBytes int `json:"maxBodyBytes"`
RequireProof bool `json:"requireProof"`
} `json:"wopi"`
Gotenberg struct {
URL string `json:"url"`
} `json:"gotenberg"`
}
func (r *jsonAppConfig) toAppConfig() AppConfig {
// IPEncryptionKey falls back to PasswordPepper when not set (mirrors env loader behaviour)
ipKey := r.Auth.IPEncryptionKey
if ipKey == "" {
ipKey = r.Auth.PasswordPepper
}
// RefreshTotpTTL falls back to RefreshTTL when not set
refreshTotpTTL := r.Auth.JWT.RefreshTotpTTL.Duration
if refreshTotpTTL == 0 {
refreshTotpTTL = r.Auth.JWT.RefreshTTL.Duration
}
// SES region falls back to aws.region
sesRegion := r.AWS.SES.Region
if sesRegion == "" {
sesRegion = r.AWS.Region
}
return AppConfig{
HTTPPort: r.HTTP.Port,
MySQL: MySQLConfig{
DSN: r.Database.MySQL.DSN,
MaxOpenConns: r.Database.MySQL.MaxOpenConns,
MaxIdleConns: r.Database.MySQL.MaxIdleConns,
ConnMaxLifetime: r.Database.MySQL.ConnMaxLifetime.Duration,
ConnMaxIdleTime: r.Database.MySQL.ConnMaxIdleTime.Duration,
},
Logging: LoggingConfig{
Level: r.Logging.Level,
Format: r.Logging.Format,
},
Audit: AuditConfig{
QueueSize: r.Audit.QueueSize,
Workers: r.Audit.Workers,
},
Email: EmailDeliveryConfig{
Provider: strings.ToLower(strings.TrimSpace(r.Email.Provider)),
MockDir: strings.TrimSpace(r.Email.MockDir),
},
SES: SESConfig{
Region: sesRegion,
Endpoint: r.AWS.SES.Endpoint,
From: r.AWS.SES.From,
ConfigurationSetName: r.AWS.SES.ConfigurationSet,
SendTimeout: r.AWS.SES.SendTimeout.Duration,
AllowInsecureEndpoint: r.AWS.SES.AllowInsecureEndpoint,
},
Auth: AuthConfig{
PublicBaseURL: r.Auth.PublicBaseURL,
PasswordPepper: r.Auth.PasswordPepper,
IPEncryptionKey: ipKey,
HashTime: r.Auth.Hash.Time,
HashMemoryKB: r.Auth.Hash.MemoryKB,
HashThreads: r.Auth.Hash.Threads,
HashKeyLen: r.Auth.Hash.KeyLen,
EmailVerifyTTL: r.Auth.Email.VerifyTTL.Duration,
EmailVerifyURL: r.Auth.Email.VerifyURL,
InviteTTL: r.Auth.Email.InviteTTL.Duration,
SetPasswordURL: r.Auth.Password.SetURL,
PasswordResetTTL: r.Auth.Password.ResetTTL.Duration,
PasswordResetURL: r.Auth.Password.ResetURL,
SecurityPINResetTTL: r.Auth.SecurityPin.ResetTTL.Duration,
SecurityPINResetURL: r.Auth.SecurityPin.ResetURL,
ForgotSecurityPINCooldown: r.Auth.SecurityPin.ForgotEmailCooldown.Duration,
ForgotSecurityPINMinDuration: r.Auth.SecurityPin.ForgotMinDuration.Duration,
SecurityPINMaxAttempts: r.Auth.SecurityPin.MaxAttempts,
SecurityPINLockDuration: r.Auth.SecurityPin.LockDuration.Duration,
SecurityPINActionTokenTTL: r.Auth.SecurityPin.ActionTokenTTL.Duration,
ForgotPasswordEmailCooldown: r.Auth.Password.ForgotEmailCooldown.Duration,
ForgotPasswordMinDuration: r.Auth.Password.ForgotMinDuration.Duration,
ForgotPasswordIPRateMax: r.Auth.Password.ForgotIPRateMax,
ForgotPasswordIPRateWindow: r.Auth.Password.ForgotIPRateWindow.Duration,
LoginIPRateMax: r.Auth.Password.LoginIPRateMax,
LoginIPRateWindow: r.Auth.Password.LoginIPRateWindow.Duration,
TOTPChallengeTTL: r.Auth.TOTP.ChallengeTTL.Duration,
SSOStateTTL: r.Auth.SSO.StateTTL.Duration,
TOTPIssuer: r.TOTP.Issuer,
TOTPSecretKeyB64: r.TOTP.SecretKey,
JWTAccessSecret: r.Auth.JWT.AccessSecret,
JWTRefreshSecret: r.Auth.JWT.RefreshSecret,
JWTAccessTTL: r.Auth.JWT.AccessTTL.Duration,
JWTRefreshTTL: r.Auth.JWT.RefreshTTL.Duration,
JWTRefreshTOTPTTL: refreshTotpTTL,
JWTLocalRefreshTTL: r.Auth.JWT.LocalRefreshTTL.Duration,
SessionIdleTTL: r.Auth.JWT.SessionIdleTTL.Duration,
JWTCookieDomain: r.Auth.JWT.Cookie.Domain,
JWTCookieSecure: r.Auth.JWT.Cookie.Secure,
JWTCookieSameSite: r.Auth.JWT.Cookie.SameSite,
JWTAccessCookieName: r.Auth.JWT.Cookie.AccessName,
JWTRefreshCookieName: r.Auth.JWT.Cookie.RefreshName,
WebAuthnRPID: r.Auth.WebAuthn.RPID,
WebAuthnRPDisplayName: r.Auth.WebAuthn.RPDisplayName,
WebAuthnRPOrigins: splitCSV(r.Auth.WebAuthn.RPOrigins),
WebAuthnChallengeTTL: r.Auth.WebAuthn.ChallengeTTL.Duration,
SSOSuccessRedirectURL: r.Auth.SSO.SuccessRedirect,
DefaultRoleName: r.Auth.DefaultRole,
DisableRegister: r.Auth.DisableRegister,
},
MicrosoftSSO: MicrosoftSSOConfig{
TenantID: r.MicrosoftSSO.TenantID,
ClientID: r.MicrosoftSSO.ClientID,
ClientSecret: r.MicrosoftSSO.ClientSecret,
RedirectURL: r.MicrosoftSSO.RedirectURL,
Authority: r.MicrosoftSSO.Authority,
Scopes: splitCSV(r.MicrosoftSSO.Scopes),
},
SAML: SAMLConfig{
CertFile: strings.TrimSpace(r.SAML.CertFile),
KeyFile: strings.TrimSpace(r.SAML.KeyFile),
MetadataFile: strings.TrimSpace(r.SAML.MetadataFile),
RootURL: strings.TrimSpace(r.SAML.RootURL),
FrontendURL: strings.TrimSpace(r.SAML.FrontendURL),
CookieDomain: strings.TrimSpace(r.SAML.CookieDomain),
CookieSecure: r.SAML.CookieSecure,
SessionTTLHr: r.SAML.SessionTTLHr,
},
HTTP: HTTPConfig{
BodyLimit: r.HTTP.BodyLimit,
ReadTimeout: r.HTTP.Timeouts.Read.Duration,
WriteTimeout: r.HTTP.Timeouts.Write.Duration,
IdleTimeout: r.HTTP.Timeouts.Idle.Duration,
RequestTimeout: r.HTTP.Timeouts.Request.Duration,
RequestTimeoutByPath: parseHTTPTimeoutOverrides(r.HTTP.Timeouts.RequestOverrides),
ShutdownTimeout: r.HTTP.Timeouts.Shutdown.Duration,
CORSAllowOrigins: r.HTTP.CORS.AllowOrigins,
CORSAllowMethods: r.HTTP.CORS.AllowMethods,
CORSAllowHeaders: r.HTTP.CORS.AllowHeaders,
CORSExposeHeaders: r.HTTP.CORS.ExposeHeaders,
CORSAllowCredentials: r.HTTP.CORS.AllowCredentials,
CORSMaxAge: r.HTTP.CORS.MaxAge,
RateLimitMax: r.HTTP.RateLimit.Max,
RateLimitWindow: r.HTTP.RateLimit.Window.Duration,
RateLimitByEndpoint: parseHTTPEndpointRateLimitOverrides(r.HTTP.RateLimit.Endpoints),
HSTSMaxAge: r.HTTP.Security.HSTSMaxAge,
},
Resilience: ResilienceConfig{
Default: r.CircuitBreaker.Default.toPolicy(),
MicrosoftSSO: r.CircuitBreaker.MicrosoftSSO.toPolicy(),
SES: r.CircuitBreaker.SES.toPolicy(),
SQS: r.CircuitBreaker.SQS.toPolicy(),
},
Queue: QueueConfig{
MessageSchemaVersion: r.Queue.MessageSchemaVersion,
EnableLegacyPayloadFallback: r.Queue.EnableLegacyPayloadFallback,
IdempotencyKeyPrefix: r.Queue.IdempotencyKeyPrefix,
IdempotencyTTL: r.Queue.IdempotencyTTL.Duration,
ProcessingTTL: r.Queue.ProcessingTTL.Duration,
WorkerConcurrency: r.Queue.Worker.Concurrency,
WorkerMaxBatchSize: r.Queue.Worker.MaxBatchSize,
WorkerProcessTimeout: r.Queue.Worker.ProcessTimeout.Duration,
WorkerShutdownTimeout: r.Queue.Worker.ShutdownTimeout.Duration,
WorkerBackoffMin: r.Queue.Worker.BackoffMin.Duration,
WorkerBackoffMax: r.Queue.Worker.BackoffMax.Duration,
WorkerPermanentFailureDelay: r.Queue.Worker.PermanentFailureDelay.Duration,
WorkerMonitorAddr: r.Queue.Worker.MonitorAddr,
WorkerHealthErrorThreshold: r.Queue.Worker.HealthErrorThreshold,
WorkerDepthPollInterval: r.Queue.Worker.DepthPollInterval.Duration,
OutboxEnabled: r.Queue.Outbox.Enabled,
OutboxPollInterval: r.Queue.Outbox.PollInterval.Duration,
OutboxBatchSize: r.Queue.Outbox.BatchSize,
OutboxDispatchTimeout: r.Queue.Outbox.DispatchTimeout.Duration,
OutboxLockTTL: r.Queue.Outbox.LockTTL.Duration,
OutboxMaxAttempts: r.Queue.Outbox.MaxAttempts,
},
FileQueue: FileQueueConfig{
MessageSchemaVersion: r.FileQueue.MessageSchemaVersion,
IdempotencyKeyPrefix: r.FileQueue.IdempotencyKeyPrefix,
IdempotencyTTL: r.FileQueue.IdempotencyTTL.Duration,
ProcessingTTL: r.FileQueue.ProcessingTTL.Duration,
WorkerConcurrency: r.FileQueue.Worker.Concurrency,
WorkerMaxBatchSize: r.FileQueue.Worker.MaxBatchSize,
WorkerProcessTimeout: r.FileQueue.Worker.ProcessTimeout.Duration,
WorkerShutdownTimeout: r.FileQueue.Worker.ShutdownTimeout.Duration,
WorkerBackoffMin: r.FileQueue.Worker.BackoffMin.Duration,
WorkerBackoffMax: r.FileQueue.Worker.BackoffMax.Duration,
WorkerPermanentFailureDelay: r.FileQueue.Worker.PermanentFailureDelay.Duration,
WorkerMonitorAddr: r.FileQueue.Worker.MonitorAddr,
WorkerHealthErrorThreshold: r.FileQueue.Worker.HealthErrorThreshold,
WorkerDepthPollInterval: r.FileQueue.Worker.DepthPollInterval.Duration,
OutboxEnabled: r.FileQueue.Outbox.Enabled,
OutboxPollInterval: r.FileQueue.Outbox.PollInterval.Duration,
OutboxBatchSize: r.FileQueue.Outbox.BatchSize,
OutboxDispatchTimeout: r.FileQueue.Outbox.DispatchTimeout.Duration,
OutboxLockTTL: r.FileQueue.Outbox.LockTTL.Duration,
OutboxMaxAttempts: r.FileQueue.Outbox.MaxAttempts,
},
// SQS email queue: region falls back to aws.region
SQS: r.AWS.SQS.toSQSConfig(r.AWS.Region, "", r.AWS.AccessKeyID, r.AWS.SecretAccessKey),
// FileSQS: region + endpoint fall back to aws.region / aws.sqs.endpoint
FileSQS: r.AWS.FileSQS.toSQSConfig(r.AWS.Region, r.AWS.SQS.Endpoint, r.AWS.AccessKeyID, r.AWS.SecretAccessKey),
FileStorage: FileStorageConfig{
Provider: strings.ToLower(strings.TrimSpace(r.File.Provider)),
Bucket: strings.TrimSpace(r.File.S3.Bucket),
Region: strings.TrimSpace(r.File.S3.Region),
Endpoint: strings.TrimSpace(r.File.S3.Endpoint),
AccessKeyID: strings.TrimSpace(r.AWS.AccessKeyID),
SecretAccessKey: strings.TrimSpace(r.AWS.SecretAccessKey),
ForcePathStyle: r.File.S3.ForcePathStyle,
AllowInsecureEndpoint: r.File.S3.AllowInsecureEndpoint,
AutoCreateBucket: r.File.S3.AutoCreateBucket,
ObjectKeyPrefix: strings.TrimSpace(r.File.S3.ObjectKeyPrefix),
PresignPutTTL: r.File.S3.PresignPutTTL.Duration,
PresignGetTTL: r.File.S3.PresignGetTTL.Duration,
},
FileUpload: FileManagerUploadPolicyConfig{
MaxFileSizeBytes: r.File.Upload.MaxSize,
AllowedMimeTypes: normalizeMimeTypes(splitCSV(r.File.Upload.AllowedMimeTypes)),
AllowedExtensions: normalizeExtensions(splitCSV(r.File.Upload.AllowedExtensions)),
AllowEmptyFile: r.File.Upload.AllowEmptyFile,
MaxFilenameLength: r.File.Upload.MaxFilenameLength,
},
WOPI: WOPIConfig{
TokenSecret: strings.TrimSpace(r.WOPI.TokenSecret),
ProofSecret: strings.TrimSpace(r.WOPI.ProofSecret),
DiscoveryURL: strings.TrimSpace(r.WOPI.DiscoveryURL),
PublicBaseURL: strings.TrimSpace(r.WOPI.PublicBaseURL),
EditActionURL: strings.TrimSpace(r.WOPI.EditActionURL),
TokenTTL: r.WOPI.TokenTTL.Duration,
MaxBodyBytes: r.WOPI.MaxBodyBytes,
RequireProof: r.WOPI.RequireProof,
},
Gotenberg: GotenbergConfig{
URL: strings.TrimSpace(r.Gotenberg.URL),
},
}
}
func splitCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}

View File

@@ -0,0 +1,5 @@
package actionsignoff
import "errors"
var ErrNotFound = errors.New("action sign-off not found")

View File

@@ -0,0 +1,49 @@
package actionsignoff
import (
"time"
"gorm.io/gorm"
flightdomain "wucher/internal/domain/flight"
helicopter "wucher/internal/domain/helicopter"
"wucher/internal/shared/pkg/uuidv7"
)
// ActionSignoff is a corrective-action sign-off for a helicopter. When linked to a
// complaint (ComplaintID set) it records that the complaint's corrective action was
// signed off; when unlinked (ComplaintID nil) it is a complaint-independent duty
// sign-off (gating the EASA maintenance release even without a complaint). There is
// one row per complaint, and one current unlinked row per helicopter.
type ActionSignoff struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
HelicopterID []byte `gorm:"type:binary(16);not null;index;column:helicopter_id"`
// FlightID scopes the standalone (complaint-independent) sign-off to a flight,
// so it no longer leaks across takeovers of the same aircraft. Nullable; unset
// for complaint-linked rows (those are keyed by complaint_id).
FlightID []byte `gorm:"type:binary(16);index;column:flight_id"`
// ComplaintID unique (NULLs distinct in MySQL) → one sign-off per complaint;
// standalone rows (NULL) are unconstrained here and instead deduped per flight
// by the functional unique index uidx_action_signoffs_standalone_flight.
ComplaintID []byte `gorm:"type:binary(16);uniqueIndex:uidx_action_signoffs_complaint_id;column:complaint_id"`
SignedAt *time.Time `gorm:"type:datetime(3);column:signed_at"`
SignedBy []byte `gorm:"type:binary(16);column:signed_by"`
Helicopter *helicopter.Helicopter `gorm:"foreignKey:HelicopterID;references:ID"`
Flight *flightdomain.Flight `gorm:"foreignKey:FlightID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
CreatedAt time.Time `gorm:"type:datetime(3);column:created_at"`
UpdatedAt time.Time `gorm:"type:datetime(3);column:updated_at"`
}
func (ActionSignoff) TableName() string { return "action_signoffs" }
func (a *ActionSignoff) BeforeCreate(_ *gorm.DB) error {
if len(a.ID) == 0 {
a.ID = uuidv7.MustBytes()
}
return nil
}
func (a *ActionSignoff) IsSigned() bool { return a != nil && a.SignedAt != nil }

View File

@@ -0,0 +1,11 @@
package actionsignoff
import "context"
type Repository interface {
GetByFlight(ctx context.Context, flightID []byte) (*ActionSignoff, error)
GetByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*ActionSignoff, error)
Create(ctx context.Context, row *ActionSignoff) error
Update(ctx context.Context, row *ActionSignoff) error
}

View File

@@ -0,0 +1,16 @@
package actionsignoff
import "context"
type Service interface {
GetByFlight(ctx context.Context, flightID []byte) (*ActionSignoff, error)
GetByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*ActionSignoff, error)
Sign(ctx context.Context, flightID, helicopterID []byte, signer []byte) (*ActionSignoff, error)
SignForComplaint(ctx context.Context, complaintID, flightID, helicopterID []byte, signer []byte) (*ActionSignoff, error)
Unsign(ctx context.Context, flightID []byte, actor []byte) (*ActionSignoff, error)
// UnsignByComplaint clears the signature on a complaint-linked sign-off (no-op when
// there is none or it is already unsigned) — used when a closed complaint is reopened
// and to toggle a complaint sign-off off. Returns nil row when none exists.
UnsignByComplaint(ctx context.Context, complaintID []byte) (*ActionSignoff, error)
}

View File

@@ -0,0 +1,58 @@
package afterflightinspection
import (
"errors"
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type AfterFlightInspection struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
FlightInspectionID []byte `gorm:"type:binary(16);not null;uniqueIndex;column:flight_inspection_id"`
OilEngineNR1Checked *string `gorm:"type:varchar(255);null;column:oil_engine_nr1_checked"`
OilEngineNR2Checked *string `gorm:"type:varchar(255);null;column:oil_engine_nr2_checked"`
HydraulicLHChecked *string `gorm:"type:varchar(255);null;column:hydraulic_lh_checked"`
HydraulicRHChecked *string `gorm:"type:varchar(255);null;column:hydraulic_rh_checked"`
OilTransmissionMGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_mgb_checked"`
OilTransmissionIGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_igb_checked"`
OilTransmissionTGBChecked *string `gorm:"type:varchar(255);null;column:oil_transmission_tgb_checked"`
Note *string `gorm:"type:text;column:note"`
CreatedAt time.Time `gorm:"column:created_at"`
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time `gorm:"column:updated_at"`
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (AfterFlightInspection) TableName() string { return "after_flight_inspections" }
func (a *AfterFlightInspection) BeforeCreate(tx *gorm.DB) error {
if len(a.ID) == 0 {
a.ID = uuidv7.MustBytes()
}
return nil
}
var (
ErrInvalidFlightInspectionID = errors.New("flight_inspection_id is invalid")
ErrFlightInspectionNotFound = errors.New("flight inspection not found")
ErrCapabilitiesUnavailable = errors.New("helicopter capabilities could not be resolved")
ErrNR1Required = errors.New("oil_engine_nr1_checked is required for this helicopter")
ErrNR1NotAvailable = errors.New("NR1 capability not available on this helicopter")
ErrNR2Required = errors.New("oil_engine_nr2_checked is required for this helicopter")
ErrNR2NotAvailable = errors.New("NR2 capability not available on this helicopter")
ErrLHRequired = errors.New("hydraulic_lh_checked is required for this helicopter")
ErrLHNotAvailable = errors.New("LH capability not available on this helicopter")
ErrRHRequired = errors.New("hydraulic_rh_checked is required for this helicopter")
ErrRHNotAvailable = errors.New("RH capability not available on this helicopter")
ErrMGBRequired = errors.New("oil_transmission_mgb_checked is required for this helicopter")
ErrMGBNotAvailable = errors.New("MGB capability not available on this helicopter")
ErrIGBRequired = errors.New("oil_transmission_igb_checked is required for this helicopter")
ErrIGBNotAvailable = errors.New("IGB capability not available on this helicopter")
ErrTGBRequired = errors.New("oil_transmission_tgb_checked is required for this helicopter")
ErrTGBNotAvailable = errors.New("TGB capability not available on this helicopter")
)

View File

@@ -0,0 +1,21 @@
package afterflightinspection
import "context"
type Repository interface {
Upsert(ctx context.Context, row *AfterFlightInspection) error
GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*AfterFlightInspection, error)
DeleteByFlightInspectionID(ctx context.Context, flightInspectionID []byte) error
FlightInspectionExists(ctx context.Context, flightInspectionID []byte) (bool, error)
GetHelicopterCapabilities(ctx context.Context, flightInspectionID []byte) (*HelicopterCapabilities, error)
}
type HelicopterCapabilities struct {
NR1 bool
NR2 bool
LH bool
RH bool
MGB bool
IGB bool
TGB bool
}

View File

@@ -0,0 +1,20 @@
package afterflightinspection
import "context"
type Service interface {
Upsert(ctx context.Context, flightInspectionID []byte, req *UpsertRequest) (*AfterFlightInspection, error)
GetByFlightInspectionID(ctx context.Context, flightInspectionID []byte) (*AfterFlightInspection, error)
DeleteByFlightInspectionID(ctx context.Context, flightInspectionID []byte) error
}
type UpsertRequest struct {
OilEngineNR1Checked *string
OilEngineNR2Checked *string
HydraulicLHChecked *string
HydraulicRHChecked *string
OilTransmissionMGBChecked *string
OilTransmissionIGBChecked *string
OilTransmissionTGBChecked *string
Note *string
}

View File

@@ -0,0 +1,230 @@
package airrescuechecklist
import "strings"
const (
AirRescuerChecklistScopeCAT = "CAT"
AirRescuerChecklistScopeTA = "TA"
AirRescuerChecklistScopeMO = "MO"
AirRescuerChecklistScopeDI = "DI"
AirRescuerChecklistScopeMI = "MI"
AirRescuerChecklistScopeDO = "DO"
AirRescuerChecklistScopeFR = "FR"
AirRescuerChecklistScopeSA = "SA"
AirRescuerChecklistScopeSO = "SO"
AirRescuerChecklistScopeM10 = "M10"
AirRescuerChecklistScopeM15 = "M15"
AirRescuerChecklistScopeM20 = "M20"
AirRescuerChecklistScopeM25 = "M25"
AirRescuerChecklistScopeDS = "DS"
)
const (
AirRescuerChecklistScopeOverview = AirRescuerChecklistScopeCAT
AirRescuerChecklistScopeDaily = AirRescuerChecklistScopeTA
AirRescuerChecklistScopeMonday = AirRescuerChecklistScopeMO
AirRescuerChecklistScopeTuesday = AirRescuerChecklistScopeDI
AirRescuerChecklistScopeWednesday = AirRescuerChecklistScopeMI
AirRescuerChecklistScopeThursday = AirRescuerChecklistScopeDO
AirRescuerChecklistScopeFriday = AirRescuerChecklistScopeFR
AirRescuerChecklistScopeSaturday = AirRescuerChecklistScopeSA
AirRescuerChecklistScopeSunday = AirRescuerChecklistScopeSO
AirRescuerChecklistScopeDeadline = AirRescuerChecklistScopeDS
)
var airRescuerChecklistPersistedScopeOrder = []string{
AirRescuerChecklistScopeTA,
AirRescuerChecklistScopeMO,
AirRescuerChecklistScopeDI,
AirRescuerChecklistScopeMI,
AirRescuerChecklistScopeDO,
AirRescuerChecklistScopeFR,
AirRescuerChecklistScopeSA,
AirRescuerChecklistScopeSO,
AirRescuerChecklistScopeM10,
AirRescuerChecklistScopeM15,
AirRescuerChecklistScopeM20,
AirRescuerChecklistScopeM25,
AirRescuerChecklistScopeDS,
}
var airRescuerChecklistPersistedScopeSet = map[string]struct{}{
AirRescuerChecklistScopeTA: {},
AirRescuerChecklistScopeMO: {},
AirRescuerChecklistScopeDI: {},
AirRescuerChecklistScopeMI: {},
AirRescuerChecklistScopeDO: {},
AirRescuerChecklistScopeFR: {},
AirRescuerChecklistScopeSA: {},
AirRescuerChecklistScopeSO: {},
AirRescuerChecklistScopeM10: {},
AirRescuerChecklistScopeM15: {},
AirRescuerChecklistScopeM20: {},
AirRescuerChecklistScopeM25: {},
AirRescuerChecklistScopeDS: {},
}
var airRescuerChecklistScopeDisplayNamesEN = map[string]string{
AirRescuerChecklistScopeCAT: "Category",
AirRescuerChecklistScopeTA: "Daily",
AirRescuerChecklistScopeMO: "Monday",
AirRescuerChecklistScopeDI: "Tuesday",
AirRescuerChecklistScopeMI: "Wednesday",
AirRescuerChecklistScopeDO: "Thursday",
AirRescuerChecklistScopeFR: "Friday",
AirRescuerChecklistScopeSA: "Saturday",
AirRescuerChecklistScopeSO: "Sunday",
AirRescuerChecklistScopeM10: "M10",
AirRescuerChecklistScopeM15: "M15",
AirRescuerChecklistScopeM20: "M20",
AirRescuerChecklistScopeM25: "M25",
AirRescuerChecklistScopeDS: "Deadline",
}
var airRescuerChecklistScopeDisplayNamesDE = map[string]string{
AirRescuerChecklistScopeCAT: "Category",
AirRescuerChecklistScopeTA: "Taeglich",
AirRescuerChecklistScopeMO: "Montag",
AirRescuerChecklistScopeDI: "Dienstag",
AirRescuerChecklistScopeMI: "Mittwoch",
AirRescuerChecklistScopeDO: "Donnerstag",
AirRescuerChecklistScopeFR: "Freitag",
AirRescuerChecklistScopeSA: "Samstag",
AirRescuerChecklistScopeSO: "Sonntag",
AirRescuerChecklistScopeM10: "M10",
AirRescuerChecklistScopeM15: "M15",
AirRescuerChecklistScopeM20: "M20",
AirRescuerChecklistScopeM25: "M25",
AirRescuerChecklistScopeDS: "Deadline",
}
var airRescuerChecklistScopeCodesEN = map[string]string{
AirRescuerChecklistScopeCAT: "CAT",
AirRescuerChecklistScopeTA: "DA",
AirRescuerChecklistScopeMO: "MO",
AirRescuerChecklistScopeDI: "TU",
AirRescuerChecklistScopeMI: "WE",
AirRescuerChecklistScopeDO: "TH",
AirRescuerChecklistScopeFR: "FR",
AirRescuerChecklistScopeSA: "SA",
AirRescuerChecklistScopeSO: "SU",
AirRescuerChecklistScopeM10: "M10",
AirRescuerChecklistScopeM15: "M15",
AirRescuerChecklistScopeM20: "M20",
AirRescuerChecklistScopeM25: "M25",
AirRescuerChecklistScopeDS: "DL",
}
var airRescuerChecklistScopeAliases = map[string]string{
"CAT": AirRescuerChecklistScopeCAT,
"OVERVIEW": AirRescuerChecklistScopeCAT,
"TA": AirRescuerChecklistScopeTA,
"DA": AirRescuerChecklistScopeTA,
"DAY": AirRescuerChecklistScopeTA,
"DAILY": AirRescuerChecklistScopeTA,
"MO": AirRescuerChecklistScopeMO,
"MON": AirRescuerChecklistScopeMO,
"MONDAY": AirRescuerChecklistScopeMO,
"DI": AirRescuerChecklistScopeDI,
"TU": AirRescuerChecklistScopeDI,
"TUE": AirRescuerChecklistScopeDI,
"TUESDAY": AirRescuerChecklistScopeDI,
"MI": AirRescuerChecklistScopeMI,
"WE": AirRescuerChecklistScopeMI,
"WED": AirRescuerChecklistScopeMI,
"WEDNESDAY": AirRescuerChecklistScopeMI,
"DO": AirRescuerChecklistScopeDO,
"TH": AirRescuerChecklistScopeDO,
"THU": AirRescuerChecklistScopeDO,
"THURSDAY": AirRescuerChecklistScopeDO,
"FR": AirRescuerChecklistScopeFR,
"FRI": AirRescuerChecklistScopeFR,
"FRIDAY": AirRescuerChecklistScopeFR,
"SA": AirRescuerChecklistScopeSA,
"SAT": AirRescuerChecklistScopeSA,
"SATURDAY": AirRescuerChecklistScopeSA,
"SO": AirRescuerChecklistScopeSO,
"SU": AirRescuerChecklistScopeSO,
"SUN": AirRescuerChecklistScopeSO,
"SUNDAY": AirRescuerChecklistScopeSO,
"M10": AirRescuerChecklistScopeM10,
"M15": AirRescuerChecklistScopeM15,
"M20": AirRescuerChecklistScopeM20,
"M25": AirRescuerChecklistScopeM25,
"DS": AirRescuerChecklistScopeDS,
"DL": AirRescuerChecklistScopeDS,
"DEADLINE": AirRescuerChecklistScopeDS,
}
func CanonicalizeAirRescuerChecklistScope(scopeCode string) string {
return normalizeAirRescuerChecklistScope(scopeCode)
}
func IsAirRescuerChecklistPersistedScope(scopeCode string) bool {
_, ok := airRescuerChecklistPersistedScopeSet[normalizeAirRescuerChecklistScope(scopeCode)]
return ok
}
func IsAirRescuerChecklistQueryScope(scopeCode string) bool {
normalized := normalizeAirRescuerChecklistScope(scopeCode)
if normalized == AirRescuerChecklistScopeCAT {
return true
}
return IsAirRescuerChecklistPersistedScope(normalized)
}
func AirRescuerChecklistScopeDisplayName(scopeCode string) string {
normalized := normalizeAirRescuerChecklistScope(scopeCode)
if v, ok := airRescuerChecklistScopeDisplayNamesEN[normalized]; ok {
return v
}
return normalized
}
func AirRescuerChecklistScopeDisplayNameDE(scopeCode string) string {
normalized := normalizeAirRescuerChecklistScope(scopeCode)
if v, ok := airRescuerChecklistScopeDisplayNamesDE[normalized]; ok {
return v
}
return normalized
}
func AirRescuerChecklistScopeCodeEN(scopeCode string) string {
normalized := normalizeAirRescuerChecklistScope(scopeCode)
if v, ok := airRescuerChecklistScopeCodesEN[normalized]; ok {
return v
}
return normalized
}
func AirRescuerChecklistScopeCodeDE(scopeCode string) string {
return normalizeAirRescuerChecklistScope(scopeCode)
}
func AirRescuerChecklistPersistedScopeOrder() []string {
out := make([]string, 0, len(airRescuerChecklistPersistedScopeOrder))
out = append(out, airRescuerChecklistPersistedScopeOrder...)
return out
}
func IsAirRescuerChecklistScope(scopeCode string) bool {
return IsAirRescuerChecklistPersistedScope(scopeCode)
}
func normalizeAirRescuerChecklistScope(scopeCode string) string {
normalized := strings.ToUpper(strings.TrimSpace(scopeCode))
if canonical, ok := airRescuerChecklistScopeAliases[normalized]; ok {
return canonical
}
return normalized
}

View File

@@ -0,0 +1,159 @@
package airrescuechecklist
import "testing"
func TestAirRescuerChecklistScopeHelpers(t *testing.T) {
persistedScopes := []string{
AirRescuerChecklistScopeTA,
AirRescuerChecklistScopeMO,
AirRescuerChecklistScopeDI,
AirRescuerChecklistScopeMI,
AirRescuerChecklistScopeDO,
AirRescuerChecklistScopeFR,
AirRescuerChecklistScopeSA,
AirRescuerChecklistScopeSO,
AirRescuerChecklistScopeM10,
AirRescuerChecklistScopeM15,
AirRescuerChecklistScopeM20,
AirRescuerChecklistScopeM25,
AirRescuerChecklistScopeDS,
}
for _, scope := range persistedScopes {
if !IsAirRescuerChecklistPersistedScope(scope) {
t.Fatalf("expected persisted scope valid: %s", scope)
}
if !IsAirRescuerChecklistPersistedScope(" " + scope + " ") {
t.Fatalf("expected persisted scope valid with trim: %s", scope)
}
if !IsAirRescuerChecklistPersistedScope(lower(scope)) {
t.Fatalf("expected persisted scope valid with lowercase: %s", scope)
}
if !IsAirRescuerChecklistQueryScope(scope) {
t.Fatalf("expected query scope valid: %s", scope)
}
if !IsAirRescuerChecklistScope(scope) {
t.Fatalf("expected compatibility scope valid: %s", scope)
}
}
if IsAirRescuerChecklistPersistedScope(AirRescuerChecklistScopeCAT) {
t.Fatalf("expected CAT not persisted")
}
if !IsAirRescuerChecklistQueryScope(AirRescuerChecklistScopeCAT) {
t.Fatalf("expected CAT valid for query")
}
if IsAirRescuerChecklistQueryScope("UNKNOWN") {
t.Fatalf("expected unknown query scope invalid")
}
if IsAirRescuerChecklistScope("UNKNOWN") {
t.Fatalf("expected unknown compatibility scope invalid")
}
englishAliases := map[string]string{
"daily": AirRescuerChecklistScopeTA,
"tuesday": AirRescuerChecklistScopeDI,
"wednesday": AirRescuerChecklistScopeMI,
"thursday": AirRescuerChecklistScopeDO,
"sunday": AirRescuerChecklistScopeSO,
"deadline": AirRescuerChecklistScopeDS,
"overview": AirRescuerChecklistScopeCAT,
"tu": AirRescuerChecklistScopeDI,
"we": AirRescuerChecklistScopeMI,
"th": AirRescuerChecklistScopeDO,
"su": AirRescuerChecklistScopeSO,
}
for alias, expected := range englishAliases {
if got := CanonicalizeAirRescuerChecklistScope(alias); got != expected {
t.Fatalf("expected alias %s canonicalized to %s, got %s", alias, expected, got)
}
}
if !IsAirRescuerChecklistPersistedScope("tu") {
t.Fatalf("expected TU alias valid as persisted scope")
}
if !IsAirRescuerChecklistQueryScope("overview") {
t.Fatalf("expected OVERVIEW alias valid as query scope")
}
}
func TestAirRescuerChecklistScopeDisplayName(t *testing.T) {
if got := AirRescuerChecklistScopeDisplayName(AirRescuerChecklistScopeCAT); got != "Category" {
t.Fatalf("unexpected CAT display name: %s", got)
}
if got := AirRescuerChecklistScopeDisplayName(" mo "); got != "Monday" {
t.Fatalf("unexpected MO display name: %s", got)
}
if got := AirRescuerChecklistScopeDisplayName("x"); got != "X" {
t.Fatalf("unexpected fallback display name: %s", got)
}
if got := AirRescuerChecklistScopeDisplayName("tu"); got != "Tuesday" {
t.Fatalf("unexpected TU display name: %s", got)
}
}
func TestAirRescuerChecklistScopeDisplayNameDE(t *testing.T) {
if got := AirRescuerChecklistScopeDisplayNameDE(AirRescuerChecklistScopeCAT); got != "Category" {
t.Fatalf("unexpected CAT DE display name: %s", got)
}
if got := AirRescuerChecklistScopeDisplayNameDE(" mo "); got != "Montag" {
t.Fatalf("unexpected MO DE display name: %s", got)
}
if got := AirRescuerChecklistScopeDisplayNameDE("x"); got != "X" {
t.Fatalf("unexpected DE fallback display name: %s", got)
}
if got := AirRescuerChecklistScopeDisplayNameDE("thursday"); got != "Donnerstag" {
t.Fatalf("unexpected THURSDAY DE display name: %s", got)
}
if got := AirRescuerChecklistScopeDisplayNameDE(AirRescuerChecklistScopeDS); got != "Deadline" {
t.Fatalf("unexpected DS DE display name: %s", got)
}
}
func TestAirRescuerChecklistScopeCodes(t *testing.T) {
if got := AirRescuerChecklistScopeCodeEN(AirRescuerChecklistScopeTA); got != "DA" {
t.Fatalf("unexpected TA EN code: %s", got)
}
if got := AirRescuerChecklistScopeCodeEN("thursday"); got != "TH" {
t.Fatalf("unexpected THURSDAY EN code: %s", got)
}
if got := AirRescuerChecklistScopeCodeEN("x"); got != "X" {
t.Fatalf("unexpected EN fallback code: %s", got)
}
if got := AirRescuerChecklistScopeCodeDE(AirRescuerChecklistScopeDI); got != AirRescuerChecklistScopeDI {
t.Fatalf("unexpected DI DE code: %s", got)
}
if got := AirRescuerChecklistScopeCodeDE("tu"); got != AirRescuerChecklistScopeDI {
t.Fatalf("unexpected TU DE code: %s", got)
}
}
func TestAirRescuerChecklistPersistedScopeOrderReturnsCopy(t *testing.T) {
first := AirRescuerChecklistPersistedScopeOrder()
if len(first) != 13 {
t.Fatalf("unexpected persisted scope order size: %d", len(first))
}
if first[0] != AirRescuerChecklistScopeTA || first[len(first)-1] != AirRescuerChecklistScopeDS {
t.Fatalf("unexpected persisted scope order: %+v", first)
}
first[0] = "BROKEN"
second := AirRescuerChecklistPersistedScopeOrder()
if second[0] != AirRescuerChecklistScopeTA {
t.Fatalf("expected second call to return independent copy, got: %s", second[0])
}
}
func lower(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
ch := s[i]
if ch >= 'A' && ch <= 'Z' {
ch = ch + ('a' - 'A')
}
out = append(out, ch)
}
return string(out)
}

View File

@@ -0,0 +1,117 @@
package airrescuechecklist
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type AirRescuerChecklist struct {
ID []byte `gorm:"column:id;type:binary(16);primaryKey"`
HEMSBaseID []byte `gorm:"column:hems_base_id;type:binary(16);not null"`
ScopeCode string `gorm:"column:scope_code;type:varchar(20);not null"`
Title string `gorm:"column:title;type:varchar(150);not null"`
Position int `gorm:"column:position;not null;default:1"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
CreatedBy []byte `gorm:"column:created_by;type:binary(16)"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
UpdatedBy []byte `gorm:"column:updated_by;type:binary(16)"`
DeletedAt *time.Time `gorm:"column:deleted_at"`
DeletedBy []byte `gorm:"column:deleted_by;type:binary(16)"`
}
func (AirRescuerChecklist) TableName() string {
return "air_rescuer_checklists"
}
func (h *AirRescuerChecklist) BeforeCreate(tx *gorm.DB) error {
if len(h.ID) == 0 {
h.ID = uuidv7.MustBytes()
}
return nil
}
type AirRescuerChecklistItem struct {
ID []byte `gorm:"column:id;type:binary(16);primaryKey"`
ChecklistID []byte `gorm:"column:checklist_id;type:binary(16);not null"`
Name string `gorm:"column:name;type:varchar(255);not null"`
Position int `gorm:"column:position;not null;default:1"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
CreatedBy []byte `gorm:"column:created_by;type:binary(16)"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
UpdatedBy []byte `gorm:"column:updated_by;type:binary(16)"`
DeletedAt *time.Time `gorm:"column:deleted_at"`
DeletedBy []byte `gorm:"column:deleted_by;type:binary(16)"`
}
func (AirRescuerChecklistItem) TableName() string {
return "air_rescuer_checklist_items"
}
func (h *AirRescuerChecklistItem) BeforeCreate(tx *gorm.DB) error {
if len(h.ID) == 0 {
h.ID = uuidv7.MustBytes()
}
return nil
}
type HEMSBaseView struct {
ID []byte
BaseName string
BaseAbbreviation string
BaseCategory string
Position int
}
type ChecklistHeaderView struct {
ID []byte
HEMSBaseID []byte
BaseName string
BaseAbbreviation string
BaseCategory string
ScopeCode string
Title string
Position int
CreatedAt time.Time
CreatedBy []byte
UpdatedAt time.Time
}
type ChecklistItemView struct {
ID []byte
ChecklistID []byte
Name string
Position int
CreatedAt time.Time
CreatedBy []byte
UpdatedAt time.Time
}
type ChecklistHeaderFilter struct {
HEMSBaseID []byte
BaseName string
BaseAbbreviation string
ScopeCode string
CategoryType string
}
type HEMSBaseFilter struct {
HEMSBaseID []byte
BaseName string
BaseAbbreviation string
CategoryType string
}
type ChecklistCreateInput struct {
Checklist AirRescuerChecklist
Items []AirRescuerChecklistItem
}
type ChecklistItemReorderInput struct {
ID []byte
Position int
}

View File

@@ -0,0 +1,52 @@
package airrescuechecklist
import (
"bytes"
"testing"
)
func TestAirRescuerChecklistModelTableNameAndBeforeCreate(t *testing.T) {
var header AirRescuerChecklist
if got := header.TableName(); got != "air_rescuer_checklists" {
t.Fatalf("unexpected checklist table name: %s", got)
}
if err := header.BeforeCreate(nil); err != nil {
t.Fatalf("BeforeCreate checklist: %v", err)
}
if len(header.ID) == 0 {
t.Fatalf("expected checklist id generated")
}
existingID := []byte("1234567890123456")
header2 := AirRescuerChecklist{ID: append([]byte(nil), existingID...)}
if err := header2.BeforeCreate(nil); err != nil {
t.Fatalf("BeforeCreate checklist with existing id: %v", err)
}
if !bytes.Equal(header2.ID, existingID) {
t.Fatalf("expected existing checklist id preserved")
}
}
func TestAirRescuerChecklistItemModelTableNameAndBeforeCreate(t *testing.T) {
var item AirRescuerChecklistItem
if got := item.TableName(); got != "air_rescuer_checklist_items" {
t.Fatalf("unexpected checklist item table name: %s", got)
}
if err := item.BeforeCreate(nil); err != nil {
t.Fatalf("BeforeCreate checklist item: %v", err)
}
if len(item.ID) == 0 {
t.Fatalf("expected checklist item id generated")
}
existingID := []byte("abcdefghijklmnop")
item2 := AirRescuerChecklistItem{ID: append([]byte(nil), existingID...)}
if err := item2.BeforeCreate(nil); err != nil {
t.Fatalf("BeforeCreate checklist item with existing id: %v", err)
}
if !bytes.Equal(item2.ID, existingID) {
t.Fatalf("expected existing checklist item id preserved")
}
}

View File

@@ -0,0 +1,28 @@
package airrescuechecklist
import "context"
type TxRepository interface {
CreateChecklist(ctx context.Context, row *AirRescuerChecklist) error
NextChecklistPosition(ctx context.Context, hemsBaseID []byte, scopeCode string) (int, error)
LockChecklistByID(ctx context.Context, id []byte) (*AirRescuerChecklist, error)
UpdateChecklist(ctx context.Context, row *AirRescuerChecklist) error
SoftDeleteChecklist(ctx context.Context, id []byte, deletedBy []byte) error
SoftDeleteItemsByChecklistID(ctx context.Context, checklistID []byte, deletedBy []byte) error
CreateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
CreateChecklistItems(ctx context.Context, rows []AirRescuerChecklistItem) error
NextChecklistItemPosition(ctx context.Context, checklistID []byte) (int, error)
LockChecklistItemByID(ctx context.Context, id []byte) (*AirRescuerChecklistItem, error)
UpdateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
SoftDeleteChecklistItem(ctx context.Context, id []byte, deletedBy []byte) error
}
type Repository interface {
InTx(ctx context.Context, fn func(txRepo TxRepository) error) error
GetChecklistByID(ctx context.Context, id []byte) (*ChecklistHeaderView, error)
GetChecklistItemByID(ctx context.Context, id []byte) (*ChecklistItemView, error)
ListChecklistHeaders(ctx context.Context, filter ChecklistHeaderFilter) ([]ChecklistHeaderView, error)
ListChecklistItemsByChecklistIDs(ctx context.Context, checklistIDs [][]byte) ([]ChecklistItemView, error)
ListHEMSBases(ctx context.Context, filter HEMSBaseFilter) ([]HEMSBaseView, error)
}

View File

@@ -0,0 +1,21 @@
package airrescuechecklist
import "context"
type Service interface {
CreateBulk(ctx context.Context, rows []ChecklistCreateInput) error
UpdateChecklist(ctx context.Context, row *AirRescuerChecklist) error
UpdateChecklistWithNewItems(ctx context.Context, row *AirRescuerChecklist, newItems []AirRescuerChecklistItem) error
DeleteChecklist(ctx context.Context, id []byte, deletedBy []byte) error
CreateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
CreateChecklistItems(ctx context.Context, checklistID []byte, rows []AirRescuerChecklistItem) error
ReorderChecklistItems(ctx context.Context, checklistID []byte, rows []ChecklistItemReorderInput, updatedBy []byte) error
UpdateChecklistItem(ctx context.Context, row *AirRescuerChecklistItem) error
DeleteChecklistItem(ctx context.Context, id []byte, deletedBy []byte) error
GetChecklistByID(ctx context.Context, id []byte) (*ChecklistHeaderView, error)
GetChecklistItemByID(ctx context.Context, id []byte) (*ChecklistItemView, error)
ListChecklistHeaders(ctx context.Context, filter ChecklistHeaderFilter) ([]ChecklistHeaderView, error)
ListChecklistItemsByChecklistIDs(ctx context.Context, checklistIDs [][]byte) ([]ChecklistItemView, error)
ListHEMSBases(ctx context.Context, filter HEMSBaseFilter) ([]HEMSBaseView, error)
}

View File

@@ -0,0 +1,36 @@
package audit
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type AuditLog struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
RequestID string `gorm:"type:varchar(64);index;column:request_id"`
ActorUserID []byte `gorm:"type:binary(16);index;column:actor_user_id"`
Layer string `gorm:"type:varchar(32);index;not null;column:layer"`
Module string `gorm:"type:varchar(64);index;column:module"`
Action string `gorm:"type:varchar(128);index;not null;column:action"`
Method string `gorm:"type:varchar(12);index;column:method"`
Path string `gorm:"type:varchar(255);index;column:path"`
StatusCode int `gorm:"index;column:status_code"`
Success bool `gorm:"type:tinyint(1);index;not null;default:0;column:success"`
IP string `gorm:"type:varchar(128);column:ip"`
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
Message string `gorm:"type:varchar(512);column:message"`
Metadata []byte `gorm:"type:json;column:metadata"`
CreatedAt time.Time
}
func (AuditLog) TableName() string { return "audit_logs" }
func (a *AuditLog) BeforeCreate(tx *gorm.DB) error {
if len(a.ID) == 0 {
a.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,7 @@
package audit
import "context"
type Repository interface {
Create(ctx context.Context, entry *AuditLog) error
}

View File

@@ -0,0 +1,16 @@
package auth
const (
RoleCodePilot = "pilot"
RoleCodeDoctor = "doctor"
RoleCodeAirRescuer = "air_rescuer"
RoleCodeTechnician = "technician"
RoleCodeFlightAssistant = "flight_assistant"
RoleCodeStaff = "staff"
)
const (
PilotCategoryRegular = "regular"
PilotCategoryFreelance = "freelance"
PilotCategoryDryLease = "dry_lease"
)

View File

@@ -0,0 +1,141 @@
package auth
import "time"
// PilotProfile stores role-specific data for role=pilot.
type PilotProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
PilotCategory string `gorm:"type:varchar(16);not null;index;column:pilot_category"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
LicenseNo string `gorm:"type:varchar(120);index;column:license_no"`
LicenseIssuedAt *time.Time `gorm:"column:license_issued_at"`
LicenseExpiredAt *time.Time `gorm:"column:license_expired_at"`
TechnicianLicenseNo string `gorm:"type:varchar(120);column:technician_license_no"`
HasIFRQualification bool `gorm:"not null;default:false;column:has_ifr_qualification"`
IsChiefPilot bool `gorm:"not null;default:false;column:is_chief_pilot"`
IsDUL bool `gorm:"not null;default:false;column:is_dul"`
TotalFlightMinutes int `gorm:"not null;default:0;column:total_flight_minutes"`
ResponsiblePilotMinutes int `gorm:"not null;default:0;column:responsible_pilot_minutes"`
SecondPilotMinutes int `gorm:"not null;default:0;column:second_pilot_minutes"`
DoubleCommandMinutes int `gorm:"not null;default:0;column:double_command_minutes"`
FlightInstructorMinutes int `gorm:"not null;default:0;column:flight_instructor_minutes"`
NightFlightMinutes int `gorm:"not null;default:0;column:night_flight_minutes"`
IFRFlightMinutes int `gorm:"not null;default:0;column:ifr_flight_minutes"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
WeightKG *float64 `gorm:"type:decimal(8,2);column:weight_kg"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (PilotProfile) TableName() string { return "pilot_profiles" }
// DoctorProfile stores role-specific data for role=doctor.
type DoctorProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
Specialization string `gorm:"type:varchar(120);column:specialization"`
SubSpecialization string `gorm:"type:varchar(120);column:sub_specialization"`
IsChiefPhysician bool `gorm:"not null;default:false;column:is_chief_physician"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (DoctorProfile) TableName() string { return "doctor_profiles" }
// AirRescuerProfile stores role-specific data for role=air_rescuer.
type AirRescuerProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
ResponsibleFlightRescuer bool `gorm:"not null;default:false;column:responsible_flight_rescuer"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (AirRescuerProfile) TableName() string { return "air_rescuer_profiles" }
// TechnicianProfile stores role-specific data for role=technician.
type TechnicianProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
LicenseNo string `gorm:"type:varchar(120);index;column:license_no"`
IsChiefTechnician bool `gorm:"not null;default:false;column:is_chief_technician"`
IsResponsibleComplaints bool `gorm:"not null;default:false;column:is_responsible_complaints"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (TechnicianProfile) TableName() string { return "technician_profiles" }
// FlightAssistantProfile stores role-specific data for role=flight_assistant.
type FlightAssistantProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (FlightAssistantProfile) TableName() string { return "flight_assistant_profiles" }
// StaffProfile stores role-specific data for role=staff.
type StaffProfile struct {
UserID []byte `gorm:"type:binary(16);primaryKey;column:user_id"`
ShortName string `gorm:"type:varchar(120);column:short_name"`
RoleLabel string `gorm:"type:varchar(120);column:role_label"`
Location string `gorm:"type:varchar(255);column:location"`
Postcode string `gorm:"type:varchar(32);column:postcode"`
StreetLine string `gorm:"type:varchar(255);column:street_line"`
Note string `gorm:"type:text;column:note"`
PhotoFileID string `gorm:"type:varchar(255);column:photo_file_id"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
User User `gorm:"foreignKey:UserID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
func (StaffProfile) TableName() string { return "staff_profiles" }

View File

@@ -0,0 +1,34 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type UserEmailLoginOTP struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
OTPHash []byte `gorm:"type:binary(32);not null;column:otp_hash"`
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
UsedAt *time.Time `gorm:"index;column:used_at"`
AttemptCount int `gorm:"not null;default:0;column:attempt_count"`
MaxAttempts int `gorm:"not null;default:5;column:max_attempts"`
ResendCount int `gorm:"not null;default:1;column:resend_count"`
LastSentAt time.Time `gorm:"index;not null;column:last_sent_at"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserEmailLoginOTP) TableName() string { return "user_email_login_otps" }
func (o *UserEmailLoginOTP) BeforeCreate(_ *gorm.DB) error {
if len(o.ID) == 0 {
o.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,143 @@
package auth
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
"wucher/internal/queue"
)
const (
EmailOutboxStatusPending = "pending"
EmailOutboxStatusProcessing = "processing"
EmailOutboxStatusPublished = "published"
EmailOutboxStatusDead = "dead"
)
type EmailOutboxMessage struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
MessageID string `gorm:"type:varchar(64);uniqueIndex;not null;column:message_id"`
Kind string `gorm:"type:varchar(64);index;not null;column:kind"`
JobType string `gorm:"type:varchar(64);index;not null;column:job_type"`
Body []byte `gorm:"type:longblob;not null;column:body"`
Attributes []byte `gorm:"type:longblob;column:attributes"`
MessageGroupID string `gorm:"type:varchar(128);column:message_group_id"`
DeduplicationID string `gorm:"type:varchar(128);column:deduplication_id"`
Status string `gorm:"type:varchar(32);index;not null;column:status"`
Attempts int `gorm:"not null;default:0;column:attempts"`
AvailableAt time.Time `gorm:"index;not null;column:available_at"`
LockedAt *time.Time `gorm:"index;column:locked_at"`
LockedBy string `gorm:"type:varchar(64);column:locked_by"`
PublishedAt *time.Time `gorm:"column:published_at"`
LastError string `gorm:"type:text;column:last_error"`
CorrelationID string `gorm:"type:varchar(64);index;column:correlation_id"`
TraceID string `gorm:"type:varchar(64);index;column:trace_id"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (EmailOutboxMessage) TableName() string { return "email_outbox_messages" }
func (m *EmailOutboxMessage) BeforeCreate(_ *gorm.DB) error {
if len(m.ID) == 0 {
m.ID = uuidv7.MustBytes()
}
if strings.TrimSpace(m.Status) == "" {
m.Status = EmailOutboxStatusPending
}
if m.AvailableAt.IsZero() {
m.AvailableAt = time.Now().UTC()
}
return nil
}
func NewEmailOutboxMessage(now time.Time, message *queue.OutboundMessage) (*EmailOutboxMessage, error) {
if message == nil {
return nil, errors.New("outbound message is required")
}
if strings.TrimSpace(message.ID) == "" {
return nil, errors.New("outbound message id is required")
}
if len(message.Body) == 0 {
return nil, errors.New("outbound message body is required")
}
attributes, err := json.Marshal(message.Attributes)
if err != nil {
return nil, err
}
if now.IsZero() {
now = time.Now().UTC()
}
kind := strings.TrimSpace(message.Attributes["message_kind"])
if kind == "" {
kind = queue.EmailKind
}
jobType := strings.TrimSpace(message.Attributes["job_type"])
if jobType == "" {
jobType = "generic"
}
return &EmailOutboxMessage{
MessageID: strings.TrimSpace(message.ID),
Kind: kind,
JobType: jobType,
Body: append([]byte(nil), message.Body...),
Attributes: append([]byte(nil), attributes...),
MessageGroupID: strings.TrimSpace(message.MessageGroupID),
DeduplicationID: strings.TrimSpace(message.DeduplicationID),
Status: EmailOutboxStatusPending,
AvailableAt: now.UTC(),
CorrelationID: strings.TrimSpace(message.Attributes["correlation_id"]),
TraceID: strings.TrimSpace(message.Attributes["trace_id"]),
}, nil
}
func (m *EmailOutboxMessage) OutboundMessage() (*queue.OutboundMessage, error) {
if m == nil {
return nil, errors.New("email outbox message is required")
}
attributes := map[string]string{}
if len(m.Attributes) > 0 {
if err := json.Unmarshal(m.Attributes, &attributes); err != nil {
return nil, err
}
}
if strings.TrimSpace(m.MessageID) == "" {
return nil, errors.New("message id is required")
}
if len(m.Body) == 0 {
return nil, errors.New("message body is required")
}
return &queue.OutboundMessage{
ID: m.MessageID,
Body: append([]byte(nil), m.Body...),
Attributes: attributes,
MessageGroupID: strings.TrimSpace(m.MessageGroupID),
DeduplicationID: strings.TrimSpace(m.DeduplicationID),
}, nil
}
type EmailOutboxWriter interface {
CreateEmailOutboxMessage(ctx context.Context, message *EmailOutboxMessage) error
}
type EmailOutboxRepository interface {
EmailOutboxWriter
ClaimPendingEmailOutboxMessages(ctx context.Context, limit int, now time.Time, lockTTL time.Duration, workerID string) ([]EmailOutboxMessage, error)
MarkEmailOutboxMessagePublished(ctx context.Context, id []byte, publishedAt time.Time) error
MarkEmailOutboxMessageRetry(ctx context.Context, id []byte, availableAt time.Time, lastErr string) error
MarkEmailOutboxMessageDead(ctx context.Context, id []byte, failedAt time.Time, lastErr string) error
CountPendingEmailOutboxMessages(ctx context.Context, now time.Time) (int64, error)
}
type TransactionalRepository interface {
WithTransaction(ctx context.Context, fn func(repo Repository, outbox EmailOutboxWriter) error) error
}

View File

@@ -0,0 +1,31 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type UserIdentity struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);uniqueIndex:ux_user_identity_user;not null;column:user_id"`
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_provider_subject;not null;column:provider"`
ProviderSubject string `gorm:"type:varchar(191);uniqueIndex:ux_provider_subject;not null;column:provider_subject"`
Email string `gorm:"type:varchar(191);index;column:email"`
Metadata []byte `gorm:"type:json;column:metadata"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserIdentity) TableName() string { return "user_identities" }
func (ui *UserIdentity) BeforeCreate(tx *gorm.DB) error {
if len(ui.ID) == 0 {
ui.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,40 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type UserMicrosoftOAuthToken struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);uniqueIndex:ux_user_ms_oauth_user_provider;not null;column:user_id"`
Provider string `gorm:"type:varchar(32);uniqueIndex:ux_user_ms_oauth_user_provider;not null;column:provider"`
TenantID string `gorm:"type:varchar(128);index;not null;column:tenant_id"`
MicrosoftObjectID string `gorm:"type:varchar(191);not null;column:microsoft_object_id"`
MicrosoftSubject string `gorm:"type:varchar(191);not null;column:microsoft_subject"`
MicrosoftSessionID string `gorm:"type:varchar(191);column:microsoft_session_id"`
RefreshTokenEncrypted []byte `gorm:"type:blob;not null;column:refresh_token_encrypted"`
AccessTokenEncrypted []byte `gorm:"type:blob;column:access_token_encrypted"`
IDTokenEncrypted []byte `gorm:"type:blob;column:id_token_encrypted"`
Scope string `gorm:"type:text;column:scope"`
TokenType string `gorm:"type:varchar(32);column:token_type"`
AccessTokenExpiresAt *time.Time `gorm:"column:access_token_expires_at"`
RefreshTokenExpiresAt *time.Time `gorm:"column:refresh_token_expires_at"`
RefreshTokenRotatedAt *time.Time `gorm:"column:refresh_token_rotated_at"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (UserMicrosoftOAuthToken) TableName() string { return "user_microsoft_oauth_tokens" }
func (t *UserMicrosoftOAuthToken) BeforeCreate(tx *gorm.DB) error {
if len(t.ID) == 0 {
t.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,32 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type PasswordResetToken struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
UserID []byte `gorm:"type:binary(16);index;not null;column:user_id"`
TokenHash []byte `gorm:"type:varbinary(32);uniqueIndex;not null;column:token_hash"`
ExpiresAt time.Time `gorm:"index;not null;column:expires_at"`
UsedAt *time.Time `gorm:"index;column:used_at"`
RequestedIP string `gorm:"type:varchar(128);column:requested_ip"`
UserAgent string `gorm:"type:varchar(255);column:user_agent"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (PasswordResetToken) TableName() string { return "password_reset_tokens" }
func (prt *PasswordResetToken) BeforeCreate(tx *gorm.DB) error {
if len(prt.ID) == 0 {
prt.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,32 @@
package auth
import (
"time"
"gorm.io/gorm"
"wucher/internal/shared/pkg/uuidv7"
)
type Permission struct {
ID []byte `gorm:"type:binary(16);primaryKey;column:id"`
Name string `gorm:"type:varchar(64);uniqueIndex;not null;column:name"`
Key string `gorm:"type:varchar(64);uniqueIndex;not null;column:key"`
Description string `gorm:"type:varchar(255);column:description"`
Module string `gorm:"type:varchar(64);index;column:module"`
RequiresPIN bool `gorm:"not null;default:false;column:requires_pin"`
CreatedAt time.Time
CreatedBy []byte `gorm:"type:binary(16);column:created_by"`
UpdatedAt time.Time
UpdatedBy []byte `gorm:"type:binary(16);column:updated_by"`
}
func (Permission) TableName() string { return "permissions" }
func (p *Permission) BeforeCreate(tx *gorm.DB) error {
if len(p.ID) == 0 {
p.ID = uuidv7.MustBytes()
}
return nil
}

View File

@@ -0,0 +1,129 @@
package auth
import "strings"
const (
ModuleUser = "user"
ModuleRole = "role"
ModuleHelicopter = "helicopter"
ModuleHelicopterUsage = "helicopter_usage"
ModuleReserveAC = "reserve_ac"
ModuleComplaint = "complaint"
ModuleActionSignoff = "action_signoff"
ModuleMCF = "mcf"
ModuleEASARelease = "easa_release"
ModuleFlightInspection = "flight_inspection"
ModuleAfterFlight = "after_flight"
ModuleFlight = "flight"
ModuleFlightPrepCheck = "flight_prep_check"
ModuleDutyRoster = "duty_roster"
ModuleDUL = "dul"
ModuleHEMSOperation = "hems_operation"
ModuleHEMSBase = "hems_base"
ModuleOperation = "operation"
ModuleOperationCategory = "operation_category"
ModuleForcesPresent = "forces_present"
ModuleAirRescuerChecklist = "air_rescuer_checklist"
ModuleMedicine = "medicine"
ModulePatientData = "patient_data"
ModuleInsurancePatientData = "insurance_patient_data"
ModuleHealthInsuranceCompanies = "health_insurance_companies"
ModuleVocation = "vocation"
ModuleBase = "base"
ModuleFacility = "facility"
ModuleCountry = "country"
ModuleFederalState = "federal_state"
ModuleICAO = "icao"
ModuleNoICAOCode = "no_icao_code"
ModuleOPC = "opc"
ModuleMasterSettings = "master_settings"
ModuleFileManager = "file_manager"
ModuleFilesystem = "filesystem"
ModuleAudit = "audit"
)
type ModuleInfo struct {
Key string
Label string
}
var ModuleRegistry = []ModuleInfo{
{ModuleUser, "User"},
{ModuleRole, "Role"},
{ModuleHelicopter, "Helicopter"},
{ModuleHelicopterUsage, "Helicopter Usage"},
{ModuleReserveAC, "Reserve AC"},
{ModuleComplaint, "Complaint"},
{ModuleActionSignoff, "Action Sign-off"},
{ModuleMCF, "MCF"},
{ModuleEASARelease, "EASA Release"},
{ModuleFlightInspection, "Flight Inspection"},
{ModuleAfterFlight, "After Flight Inspection"},
{ModuleFlight, "Flight"},
{ModuleFlightPrepCheck, "Flight Prep Check"},
{ModuleDutyRoster, "Duty Roster"},
{ModuleDUL, "DUL"},
{ModuleHEMSOperation, "HEMS Operation"},
{ModuleHEMSBase, "HEMS Base"},
{ModuleOperation, "Operation"},
{ModuleOperationCategory, "Operation Category"},
{ModuleForcesPresent, "Forces Present"},
{ModuleAirRescuerChecklist, "Air Rescuer Checklist"},
{ModuleMedicine, "Medicine"},
{ModulePatientData, "Patient Data"},
{ModuleInsurancePatientData, "Insurance Patient Data"},
{ModuleHealthInsuranceCompanies, "Health Insurance Companies"},
{ModuleVocation, "Vocation"},
{ModuleBase, "Base"},
{ModuleFacility, "Facility"},
{ModuleCountry, "Country"},
{ModuleFederalState, "Federal State"},
{ModuleICAO, "ICAO"},
{ModuleNoICAOCode, "No ICAO Code"},
{ModuleOPC, "OPC"},
{ModuleMasterSettings, "Master Settings"},
{ModuleFileManager, "File Manager"},
{ModuleFilesystem, "Filesystem"},
{ModuleAudit, "Audit"},
}
var moduleOrder = func() map[string]int {
m := make(map[string]int, len(ModuleRegistry))
for i := range ModuleRegistry {
m[ModuleRegistry[i].Key] = i
}
return m
}()
// ModuleLabel returns the display label for a module key, falling back to the
// key itself when the module is not registered.
func ModuleLabel(key string) string {
if i, ok := moduleOrder[key]; ok {
return ModuleRegistry[i].Label
}
return key
}
// IsRegisteredModule reports whether key is a known module in ModuleRegistry.
func IsRegisteredModule(key string) bool {
_, ok := moduleOrder[key]
return ok
}
// ModuleSortIndex returns a module's position in ModuleRegistry, or a large
// value (sorting unknown modules last) when it is not registered.
func ModuleSortIndex(key string) int {
if idx, ok := moduleOrder[key]; ok {
return idx
}
return len(ModuleRegistry)
}
// ModuleForKey derives the module from a permission key by taking the segment
// before the first dot, e.g. "dul.create" -> "dul", "role.permission.read" -> "role".
func ModuleForKey(permissionKey string) string {
if i := strings.Index(permissionKey, "."); i >= 0 {
return permissionKey[:i]
}
return permissionKey
}

View File

@@ -0,0 +1,49 @@
package auth
import "testing"
func TestModuleRegistryIsConsistent(t *testing.T) {
seen := make(map[string]bool, len(ModuleRegistry))
for _, m := range ModuleRegistry {
if m.Key == "" {
t.Errorf("registry has an entry with empty key (label %q)", m.Label)
}
if m.Label == "" {
t.Errorf("module %q has an empty label", m.Key)
}
if seen[m.Key] {
t.Errorf("duplicate module key %q in ModuleRegistry", m.Key)
}
seen[m.Key] = true
}
}
func TestModuleForKey(t *testing.T) {
cases := map[string]string{
"dul.create": "dul",
"role.permission.read": "role",
"file_manager.file.read": "file_manager",
"audit.read": "audit",
"filesystem": "filesystem",
}
for in, want := range cases {
if got := ModuleForKey(in); got != want {
t.Errorf("ModuleForKey(%q) = %q, want %q", in, got, want)
}
}
}
func TestModuleLabelAndRegistration(t *testing.T) {
if got := ModuleLabel(ModuleDUL); got != "DUL" {
t.Errorf("ModuleLabel(%q) = %q, want %q", ModuleDUL, got, "DUL")
}
if got := ModuleLabel("unknown_module"); got != "unknown_module" {
t.Errorf("ModuleLabel fallback = %q, want the key itself", got)
}
if !IsRegisteredModule(ModuleComplaint) {
t.Errorf("expected %q to be a registered module", ModuleComplaint)
}
if IsRegisteredModule("unknown_module") {
t.Errorf("did not expect %q to be registered", "unknown_module")
}
}

Some files were not shown because too many files have changed in this diff Show More