diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ed14b7..33d1da7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: run: go install github.com/swaggo/swag/cmd/swag@latest - name: Generate Swagger docs working-directory: backend - run: swag init -d cmd/server,internal/api -g main.go --parseDependency --parseInternal -o docs + run: swag init -d cmd/server,internal/handler -g main.go --parseDependency --parseInternal -o docs - name: Test run: cd backend && go test ./... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 978f692..123b264 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,7 +45,7 @@ jobs: - name: Generate Swagger docs if: matrix.language == 'go' working-directory: backend - run: swag init -d cmd/server,internal/api -g main.go --parseDependency --parseInternal -o docs + run: swag init -d cmd/server,internal/handler -g main.go --parseDependency --parseInternal -o docs - name: Initialize CodeQL uses: github/codeql-action/init@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87a21b0..b8c0a46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,7 +57,7 @@ jobs: - name: Generate Swagger docs working-directory: backend - run: swag init -d cmd/server,internal/api -g main.go --parseDependency --parseInternal -o docs + run: swag init -d cmd/server,internal/handler -g main.go --parseDependency --parseInternal -o docs - name: Build binary working-directory: backend diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d9abdb..b819201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- HTTP handlers have been decomposed from the monolithic `internal/api` + package into feature-scoped subpackages under `internal/handler/` + (`auth`, `calls`, `bookmarks`, `share`, `setup`, `health`, + `admin/{imports,radioreference,transcriptions}`). Route registration + now lives in `internal/handler/routes`, and shared swagger DTOs and + helpers live in `internal/handler/shared`. No route paths, methods, + middleware ordering, response shapes, or status codes changed. - Admin CRUD business logic has been extracted from `internal/ws` into a new transport-agnostic `internal/admin` package. The WebSocket layer now only routes `ADM_REQ` frames to `admin.Operations` methods; the diff --git a/backend/.golangci.yml b/backend/.golangci.yml index c38c365..eac7edf 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -50,7 +50,7 @@ issues: - "internal/db/models\\.go$" - "internal/db/db\\.go$" - "internal/db/querier\\.go$" - - "internal/api/swagger_models\\.go$" # swagger stubs — field names must mirror wire shape for doc readability + - "internal/handler/shared/dto\\.go$" # swagger stubs — field names must mirror wire shape for doc readability exclude-rules: # Test files: relax a handful of rules that don't add value there. - path: _test\.go diff --git a/backend/Makefile b/backend/Makefile index 703d484..33adad4 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -9,7 +9,7 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS=-s -w -X github.com/openscanner/openscanner/internal/config.Version=$(VERSION) build: - swag init -d cmd/server,internal/api -g main.go --parseDependency --parseInternal -o docs + swag init -d cmd/server,internal/handler -g main.go --parseDependency --parseInternal -o docs go build -ldflags="$(LDFLAGS)" -o $(OUTPUT) $(CMD) dev: diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index bf7e7d7..bf7eeda 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -37,7 +37,7 @@ import ( "github.com/gin-gonic/gin" "github.com/kardianos/service" "github.com/openscanner/openscanner/internal/admin" - "github.com/openscanner/openscanner/internal/api" + "github.com/openscanner/openscanner/internal/handler/routes" "github.com/openscanner/openscanner/internal/audio" "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/cli" @@ -892,7 +892,7 @@ func (p *program) run() { // Start transcription result consumer (stores results in DB, broadcasts TRN). go consumeTranscriptionResults(ctx, queries, hub, transcriberMgr) - api.RegisterRoutes(router, api.Deps{ + routes.RegisterRoutes(router, routes.Deps{ Queries: queries, RateLimiter: rateLimiter, Processor: processor, diff --git a/backend/internal/api/health.go b/backend/internal/api/health.go deleted file mode 100644 index 3c96762..0000000 --- a/backend/internal/api/health.go +++ /dev/null @@ -1,25 +0,0 @@ -// Package api — health check endpoint for readiness probes and Docker HEALTHCHECK. -package api - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -// RegisterHealth godoc -// -// @Summary Health check -// @Description Returns server status and version for readiness probes and Docker HEALTHCHECK. -// @Tags Health -// @Produce json -// @Success 200 {object} object{status=string,version=string} "Server is healthy" -// @Router /health [get] -func RegisterHealth(rg *gin.RouterGroup, version string) { - rg.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "version": version, - }) - }) -} diff --git a/backend/internal/api/import.go b/backend/internal/handler/admin/imports/imports.go similarity index 79% rename from backend/internal/api/import.go rename to backend/internal/handler/admin/imports/imports.go index 27ed409..e11304b 100644 --- a/backend/internal/api/import.go +++ b/backend/internal/handler/admin/imports/imports.go @@ -1,4 +1,6 @@ -package api +// Package imports provides the admin CSV import endpoints +// (talkgroups, units, groups, tags). +package imports import ( "context" @@ -14,8 +16,25 @@ import ( "github.com/gin-gonic/gin" "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/shared" ) +// AdminBroadcaster is the subset of ws.Hub used to broadcast admin events. +type AdminBroadcaster interface { + BroadcastAdminEvent(event string, payload any) +} + +// Handler serves the admin CSV import endpoints. +type Handler struct { + queries *db.Queries + hub AdminBroadcaster +} + +// New constructs an imports Handler. +func New(queries *db.Queries, hub AdminBroadcaster) *Handler { + return &Handler{queries: queries, hub: hub} +} + // tgColumnMap maps logical field names to their CSV column index. // A value of -1 means the column is not present. type tgColumnMap struct { @@ -51,21 +70,16 @@ func detectTgColumns(header []string) *tgColumnMap { for i, raw := range header { col := strings.ToLower(strings.TrimSpace(raw)) switch col { - // OpenScanner + rdio-scanner: decimal talkgroup ID case "talkgroup_id", "dec", "decimal": m.talkgroupID = i - // OpenScanner label, rdio-scanner alpha_tag case "label", "alpha_tag", "alpha tag": m.label = i - // OpenScanner name, rdio-scanner description case "name", "description": m.name = i - // OpenScanner integer FK columns case "tag_id": m.tagID = i case "group_id": m.groupID = i - // rdio-scanner text name columns case "tag", "category": m.tagName = i case "group", "service_type": @@ -76,14 +90,11 @@ func detectTgColumns(header []string) *tgColumnMap { m.led = i case "order", "priority": m.order = i - // skip unknown columns (e.g. "hex") } } return m } -// defaultTgColumns returns the positional column map matching -// OpenScanner's native CSV format (no header present). func defaultTgColumns() *tgColumnMap { return &tgColumnMap{ talkgroupID: 0, label: 1, name: 2, @@ -92,7 +103,6 @@ func defaultTgColumns() *tgColumnMap { } } -// col returns the trimmed value at index i, or "" if out of range. func col(record []string, i int) string { if i < 0 || i >= len(record) { return "" @@ -101,23 +111,21 @@ func col(record []string, i int) string { } // ImportTalkgroups handles POST /api/admin/import/talkgroups. -// Accepts a multipart CSV file, a system_id form field, and an optional mode field. -// Supports both OpenScanner and rdio-scanner CSV formats via header detection. // -// @Summary Import talkgroups from CSV -// @Description Accepts a multipart CSV file with talkgroup data and a system_id form field. Supports OpenScanner format (talkgroup_id, label, name, tag_id, group_id, frequency, led, order) and rdio-scanner format (dec, hex, alpha_tag, description, tag, group, priority). Header rows are auto-detected; tag/group names are resolved to IDs automatically. Use mode=overwrite (default) to update existing talkgroups or mode=skip to leave existing talkgroups unchanged. -// @Tags Admin -// @Accept multipart/form-data -// @Produce json -// @Param system_id formData int true "System ID to import talkgroups into" -// @Param file formData file true "CSV file" -// @Param mode formData string false "Duplicate handling: overwrite (default) or skip" -// @Success 200 {object} object "inserted, updated, skipped counts" -// @Failure 400 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse -// @Security BearerAuth -// @Router /admin/import/talkgroups [post] -func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { +// @Summary Import talkgroups from CSV +// @Description Accepts a multipart CSV file with talkgroup data and a system_id form field. Supports OpenScanner format (talkgroup_id, label, name, tag_id, group_id, frequency, led, order) and rdio-scanner format (dec, hex, alpha_tag, description, tag, group, priority). Header rows are auto-detected; tag/group names are resolved to IDs automatically. Use mode=overwrite (default) to update existing talkgroups or mode=skip to leave existing talkgroups unchanged. +// @Tags Admin +// @Accept multipart/form-data +// @Produce json +// @Param system_id formData int true "System ID to import talkgroups into" +// @Param file formData file true "CSV file" +// @Param mode formData string false "Duplicate handling: overwrite (default) or skip" +// @Success 200 {object} object "inserted, updated, skipped counts" +// @Failure 400 {object} shared.ErrorResponse +// @Failure 500 {object} shared.ErrorResponse +// @Security BearerAuth +// @Router /admin/import/talkgroups [post] +func (h *Handler) ImportTalkgroups(c *gin.Context) { ctx := c.Request.Context() systemIDStr := c.PostForm("system_id") @@ -131,7 +139,6 @@ func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { return } - // Verify system exists. if _, err := h.queries.GetSystem(ctx, systemID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "system not found"}) return @@ -151,10 +158,9 @@ func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { defer file.Close() reader := csv.NewReader(file) - reader.FieldsPerRecord = -1 // allow variable number of fields + reader.FieldsPerRecord = -1 reader.TrimLeadingSpace = true - // Read the first non-blank row to detect column layout. var columns *tgColumnMap var firstDataRow []string for { @@ -176,7 +182,6 @@ func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { } columns = detectTgColumns(record) if columns == nil { - // First row is data, not a header — use default positional layout. columns = defaultTgColumns() firstDataRow = record } @@ -200,10 +205,9 @@ func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { tgID, err := strconv.ParseInt(tgIDStr, 10, 64) if err != nil { failed++ - return nil //nolint:nilerr // invalid talkgroup_id: count and skip + return nil //nolint:nilerr } - // Check if talkgroup already exists. _, existsErr := h.queries.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ SystemID: systemID, TalkgroupID: tgID, @@ -227,30 +231,28 @@ func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { params.Name = sql.NullString{String: v, Valid: true} } - // Tag: prefer integer FK (but verify it exists), fall back to name. if v := col(record, columns.tagID); v != "" { if id, err := strconv.ParseInt(v, 10, 64); err == nil { if _, gerr := h.queries.GetTag(ctx, id); gerr == nil { params.TagID = sql.NullInt64{Int64: id, Valid: true} } else if name := col(record, columns.tagName); name != "" { - params.TagID = resolveTagID(ctx, h.queries, name) + params.TagID = shared.ResolveTagID(ctx, h.queries, name) } } } else if v := col(record, columns.tagName); v != "" { - params.TagID = resolveTagID(ctx, h.queries, v) + params.TagID = shared.ResolveTagID(ctx, h.queries, v) } - // Group: prefer integer FK (but verify it exists), fall back to name. if v := col(record, columns.groupID); v != "" { if id, err := strconv.ParseInt(v, 10, 64); err == nil { if _, gerr := h.queries.GetGroup(ctx, id); gerr == nil { params.GroupID = sql.NullInt64{Int64: id, Valid: true} } else if name := col(record, columns.groupName); name != "" { - params.GroupID = resolveGroupID(ctx, h.queries, name) + params.GroupID = shared.ResolveGroupID(ctx, h.queries, name) } } } else if v := col(record, columns.groupName); v != "" { - params.GroupID = resolveGroupID(ctx, h.queries, v) + params.GroupID = shared.ResolveGroupID(ctx, h.queries, v) } if v := col(record, columns.frequency); v != "" { @@ -278,7 +280,6 @@ func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { return nil } - // Process the first data row if header detection consumed it. if firstDataRow != nil { if err := processRow(firstDataRow); err != nil { slog.Error("failed to upsert talkgroup", "error", err) @@ -288,8 +289,8 @@ func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { } for { - if inserted+updated+skipped >= maxImportRows { - slog.Warn("CSV import row limit reached", "limit", maxImportRows) + if inserted+updated+skipped >= shared.MaxImportRows { + slog.Warn("CSV import row limit reached", "limit", shared.MaxImportRows) break } @@ -324,22 +325,21 @@ func (h *AdminHandler) ImportTalkgroups(c *gin.Context) { } // ImportUnits handles POST /api/admin/import/units. -// Accepts a multipart CSV file, a system_id form field, and an optional mode field. // -// @Summary Import units from CSV -// @Description Accepts a multipart CSV file with unit data and a system_id form field. Columns: unit_id, label, order. Header rows are auto-skipped. Use mode=overwrite (default) to update existing units or mode=skip to leave existing units unchanged. -// @Tags Admin -// @Accept multipart/form-data -// @Produce json -// @Param system_id formData int true "System ID to import units into" -// @Param file formData file true "CSV file" -// @Param mode formData string false "Duplicate handling: overwrite (default) or skip" -// @Success 200 {object} object "inserted, updated, skipped counts" -// @Failure 400 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse -// @Security BearerAuth -// @Router /admin/import/units [post] -func (h *AdminHandler) ImportUnits(c *gin.Context) { +// @Summary Import units from CSV +// @Description Accepts a multipart CSV file with unit data and a system_id form field. Columns: unit_id, label, order. Header rows are auto-skipped. Use mode=overwrite (default) to update existing units or mode=skip to leave existing units unchanged. +// @Tags Admin +// @Accept multipart/form-data +// @Produce json +// @Param system_id formData int true "System ID to import units into" +// @Param file formData file true "CSV file" +// @Param mode formData string false "Duplicate handling: overwrite (default) or skip" +// @Success 200 {object} object "inserted, updated, skipped counts" +// @Failure 400 {object} shared.ErrorResponse +// @Failure 500 {object} shared.ErrorResponse +// @Security BearerAuth +// @Router /admin/import/units [post] +func (h *Handler) ImportUnits(c *gin.Context) { ctx := c.Request.Context() systemIDStr := c.PostForm("system_id") @@ -353,7 +353,6 @@ func (h *AdminHandler) ImportUnits(c *gin.Context) { return } - // Verify system exists. if _, err := h.queries.GetSystem(ctx, systemID); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "system not found"}) return @@ -376,8 +375,6 @@ func (h *AdminHandler) ImportUnits(c *gin.Context) { reader.FieldsPerRecord = -1 reader.TrimLeadingSpace = true - // Column layout: positional default is [unit_id, label, order]. If the - // first non-blank row is a header, parse its column positions. unitIDCol, labelCol, orderCol := 0, 1, 2 var firstDataRow []string @@ -402,7 +399,6 @@ func (h *AdminHandler) ImportUnits(c *gin.Context) { col0 := strings.TrimSpace(record[0]) if len(col0) > 0 && !unicode.IsDigit(rune(col0[0])) { - // Header row — parse column positions. unitIDCol, labelCol, orderCol = -1, -1, -1 for i, raw := range record { switch strings.ToLower(strings.TrimSpace(raw)) { @@ -480,8 +476,8 @@ func (h *AdminHandler) ImportUnits(c *gin.Context) { } for { - if inserted+updated+skipped >= maxImportRows { - slog.Warn("CSV import row limit reached", "limit", maxImportRows) + if inserted+updated+skipped >= shared.MaxImportRows { + slog.Warn("CSV import row limit reached", "limit", shared.MaxImportRows) break } record, err := reader.Read() @@ -513,12 +509,8 @@ func (h *AdminHandler) ImportUnits(c *gin.Context) { }) } -// importLabelOnly is the shared core for groups and tags. Both are -// global label-only entities, so the import is just "insert if a -// row with this label doesn't already exist". `mode` is honoured but -// only `inserted` vs `skipped` apply (there are no other fields to -// overwrite). -func (h *AdminHandler) importLabelOnly(c *gin.Context, kind string, +// importLabelOnly is the shared core for groups and tags. +func (h *Handler) importLabelOnly(c *gin.Context, kind string, getByLabel func(ctx context.Context, label string) (int64, bool, error), create func(ctx context.Context, label string) error, ) { @@ -535,8 +527,6 @@ func (h *AdminHandler) importLabelOnly(c *gin.Context, kind string, reader.FieldsPerRecord = -1 reader.TrimLeadingSpace = true - // Optional header. If first non-blank cell looks like the word - // "label" / "name", treat as header; otherwise treat as data. labelCol := 0 headerSeen := false @@ -569,8 +559,8 @@ func (h *AdminHandler) importLabelOnly(c *gin.Context, kind string, } for { - if inserted+skipped >= maxImportRows { - slog.Warn("CSV import row limit reached", "limit", maxImportRows) + if inserted+skipped >= shared.MaxImportRows { + slog.Warn("CSV import row limit reached", "limit", shared.MaxImportRows) break } record, err := reader.Read() @@ -589,7 +579,6 @@ func (h *AdminHandler) importLabelOnly(c *gin.Context, kind string, headerSeen = true first := strings.ToLower(strings.TrimSpace(record[0])) if first == "label" || first == "name" || first == "tag" || first == "group" { - // Header row — find the label column and skip the row. for i, raw := range record { col := strings.ToLower(strings.TrimSpace(raw)) if col == "label" || col == "name" || col == "tag" || col == "group" { @@ -633,11 +622,11 @@ func (h *AdminHandler) importLabelOnly(c *gin.Context, kind string, // @Produce json // @Param file formData file true "CSV file" // @Success 200 {object} object "inserted, skipped, failed counts" -// @Failure 400 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Failure 400 {object} shared.ErrorResponse +// @Failure 500 {object} shared.ErrorResponse // @Security BearerAuth // @Router /admin/import/groups [post] -func (h *AdminHandler) ImportGroups(c *gin.Context) { +func (h *Handler) ImportGroups(c *gin.Context) { h.importLabelOnly(c, "groups", func(ctx context.Context, label string) (int64, bool, error) { g, err := h.queries.GetGroupByLabel(ctx, label) @@ -665,11 +654,11 @@ func (h *AdminHandler) ImportGroups(c *gin.Context) { // @Produce json // @Param file formData file true "CSV file" // @Success 200 {object} object "inserted, skipped, failed counts" -// @Failure 400 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse +// @Failure 400 {object} shared.ErrorResponse +// @Failure 500 {object} shared.ErrorResponse // @Security BearerAuth // @Router /admin/import/tags [post] -func (h *AdminHandler) ImportTags(c *gin.Context) { +func (h *Handler) ImportTags(c *gin.Context) { h.importLabelOnly(c, "tags", func(ctx context.Context, label string) (int64, bool, error) { t, err := h.queries.GetTagByLabel(ctx, label) diff --git a/backend/internal/api/radioreference.go b/backend/internal/handler/admin/radioreference/radioreference.go similarity index 93% rename from backend/internal/api/radioreference.go rename to backend/internal/handler/admin/radioreference/radioreference.go index 1b4e37c..3f5b1ae 100644 --- a/backend/internal/api/radioreference.go +++ b/backend/internal/handler/admin/radioreference/radioreference.go @@ -1,4 +1,5 @@ -package api +// Package radioreference provides the admin RadioReference CSV preview endpoint. +package radioreference import ( "database/sql" @@ -12,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/shared" ) const rrMergeModeFillMissing = "fill_missing" @@ -55,7 +57,17 @@ type RRPreviewResponse struct { Rows []RRPreviewRow `json:"rows"` } // @name RRPreviewResponse -// RadioReferencePreviewCSV handles POST /api/admin/radioreference/preview/csv. +// Handler serves the RadioReference CSV preview endpoint. +type Handler struct { + queries *db.Queries +} + +// New constructs a Handler. +func New(queries *db.Queries) *Handler { + return &Handler{queries: queries} +} + +// PreviewCSV handles POST /api/admin/radioreference/preview/csv. // // @Summary Preview RadioReference CSV enrichment // @Description Upload a RadioReference CSV export and preview which local talkgroups would be enriched. Frequency is never updated. Columns: talkgroup id (decimal/tgid), alpha tag, description, group/category, tag/service type, led, order. @@ -65,10 +77,10 @@ type RRPreviewResponse struct { // @Param system_id formData int true "Local system ID to match talkgroups against" // @Param file formData file true "RadioReference CSV file" // @Success 200 {object} RRPreviewResponse -// @Failure 400 {object} ErrorResponse +// @Failure 400 {object} shared.ErrorResponse // @Security BearerAuth // @Router /admin/radioreference/preview/csv [post] -func (h *AdminHandler) RadioReferencePreviewCSV(c *gin.Context) { +func (h *Handler) PreviewCSV(c *gin.Context) { ctx := c.Request.Context() systemID, ok := parseSystemIDForm(c) @@ -106,7 +118,6 @@ func (h *AdminHandler) RadioReferencePreviewCSV(c *gin.Context) { if tgErr != nil { if errors.Is(tgErr, sql.ErrNoRows) { resp.Skipped++ - // Do not add not-found rows to the preview; skipped count is enough. continue } resp.Errors++ @@ -178,7 +189,7 @@ func parseRadioReferenceCSV(r io.Reader) ([]RRTalkgroupCandidate, []RRRowError, if len(record) == 0 || (len(record) == 1 && strings.TrimSpace(record[0]) == "") { continue } - if len(candidates) >= maxImportRows { + if len(candidates) >= shared.MaxImportRows { break } diff --git a/backend/internal/api/crud.go b/backend/internal/handler/admin/transcriptions/transcriptions.go similarity index 59% rename from backend/internal/api/crud.go rename to backend/internal/handler/admin/transcriptions/transcriptions.go index 3fa31b2..47a3949 100644 --- a/backend/internal/api/crud.go +++ b/backend/internal/handler/admin/transcriptions/transcriptions.go @@ -1,32 +1,15 @@ -package api +// Package transcriptions provides the admin transcription status endpoint. +package transcriptions import ( - "database/sql" "log/slog" "net/http" - "strings" "github.com/gin-gonic/gin" "github.com/openscanner/openscanner/internal/db" - "github.com/openscanner/openscanner/internal/ws" ) -const maxImportRows = 100_000 // CSV import safety limit. - -// AdminHandler handles admin CRUD endpoints. -type AdminHandler struct { - queries *db.Queries - hub *ws.Hub - sqlDB *sql.DB - dwReload DirMonitorReloader - dsReload DownstreamReloader - recordingsDir string - ffmpegAvailable bool - fdkAACAvailable bool - whisperAvailable bool -} - -// transcriptionStatusResponse is the JSON shape returned by GetTranscriptionStatus. +// transcriptionStatusResponse is the JSON shape returned by GetStatus. type transcriptionStatusResponse struct { Enabled bool `json:"enabled"` URL string `json:"url"` @@ -36,8 +19,18 @@ type transcriptionStatusResponse struct { WhisperAvailable bool `json:"whisperAvailable"` } // @name TranscriptionStatusResponse -// GetTranscriptionStatus handles GET /api/admin/transcriptions/status. -// Returns the current transcription configuration and statistics. +// Handler serves the transcription status endpoint. +type Handler struct { + queries *db.Queries + whisperAvailable bool +} + +// New constructs a Handler. +func New(queries *db.Queries, whisperAvailable bool) *Handler { + return &Handler{queries: queries, whisperAvailable: whisperAvailable} +} + +// GetStatus handles GET /api/admin/transcriptions/status. // // @Summary Get transcription status // @Description Returns transcription settings, total count, and whisper availability. @@ -45,9 +38,9 @@ type transcriptionStatusResponse struct { // @Produce json // @Security BearerAuth // @Success 200 {object} transcriptionStatusResponse -// @Failure 500 {object} ErrorResponse +// @Failure 500 {object} shared.ErrorResponse // @Router /admin/transcriptions/status [get] -func (h *AdminHandler) GetTranscriptionStatus(c *gin.Context) { +func (h *Handler) GetStatus(c *gin.Context) { ctx := c.Request.Context() getSetting := func(key string) string { @@ -74,12 +67,3 @@ func (h *AdminHandler) GetTranscriptionStatus(c *gin.Context) { WhisperAvailable: h.whisperAvailable, }) } - -// NewAdminHandler constructs an AdminHandler. -func NewAdminHandler(queries *db.Queries, hub *ws.Hub, sqlDB *sql.DB, dwReload DirMonitorReloader, dsReload DownstreamReloader, recordingsDir ...string) *AdminHandler { - rd := "." - if len(recordingsDir) > 0 && strings.TrimSpace(recordingsDir[0]) != "" { - rd = recordingsDir[0] - } - return &AdminHandler{queries: queries, hub: hub, sqlDB: sqlDB, dwReload: dwReload, dsReload: dsReload, recordingsDir: rd} -} diff --git a/backend/internal/api/admin.go b/backend/internal/handler/auth/auth.go similarity index 91% rename from backend/internal/api/admin.go rename to backend/internal/handler/auth/auth.go index ef03ebd..e7cc0e9 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/handler/auth/auth.go @@ -1,5 +1,6 @@ -// Package api — admin handlers (auth, config, CRUD endpoints). -package api +// Package auth contains authentication handlers (login, refresh, logout, password change, /me, TG selection) +// and the Swagger docs session endpoint that mints a short-lived HTTP-only cookie. +package auth import ( "context" @@ -15,22 +16,22 @@ import ( "github.com/openscanner/openscanner/internal/db" ) -// AuthHandler handles authentication endpoints. -type AuthHandler struct { - queries *db.Queries - rateLimiter *auth.RateLimiter - hub WSDisconnecter -} - -// WSDisconnecter is the subset of ws.Hub used by AuthHandler for session eviction. +// WSDisconnecter is the subset of ws.Hub used by Handler for session eviction. type WSDisconnecter interface { DisconnectByUser(userID int64) DisconnectByJTI(jti string) } -// NewAuthHandler constructs an AuthHandler. -func NewAuthHandler(queries *db.Queries, rateLimiter *auth.RateLimiter, hub WSDisconnecter) *AuthHandler { - return &AuthHandler{ +// Handler handles authentication endpoints. +type Handler struct { + queries *db.Queries + rateLimiter *auth.RateLimiter + hub WSDisconnecter +} + +// New constructs an auth Handler. +func New(queries *db.Queries, rateLimiter *auth.RateLimiter, hub WSDisconnecter) *Handler { + return &Handler{ queries: queries, rateLimiter: rateLimiter, hub: hub, @@ -70,7 +71,7 @@ type loginResponse struct { // @Failure 429 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/login [post] -func (h *AuthHandler) PostLogin(c *gin.Context) { +func (h *Handler) PostLogin(c *gin.Context) { ip := c.ClientIP() var req loginRequest @@ -198,7 +199,7 @@ func (h *AuthHandler) PostLogin(c *gin.Context) { // logAuthEvent writes an authentication event to the logs table for auditing // (OWASP A09 — security logging & monitoring). -func (h *AuthHandler) logAuthEvent(ctx context.Context, level, message, ip string) { +func (h *Handler) logAuthEvent(ctx context.Context, level, message, ip string) { _ = h.queries.CreateLog(ctx, db.CreateLogParams{ DateTime: time.Now().Unix(), Level: level, @@ -218,7 +219,7 @@ func (h *AuthHandler) logAuthEvent(ctx context.Context, level, message, ip strin // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/logout [post] -func (h *AuthHandler) PostLogout(c *gin.Context) { +func (h *Handler) PostLogout(c *gin.Context) { if jtiVal, ok := c.Get("jti"); ok { if jti, ok := jtiVal.(string); ok { auth.Tokens.Revoke(jti) @@ -257,7 +258,7 @@ type refreshResponse struct { // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/refresh [post] -func (h *AuthHandler) PostRefresh(c *gin.Context) { +func (h *Handler) PostRefresh(c *gin.Context) { rawToken, err := c.Cookie(auth.RefreshCookieName) if err != nil || rawToken == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "no refresh token"}) @@ -378,7 +379,7 @@ type changePasswordRequest struct { // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/password [put] -func (h *AuthHandler) PutPassword(c *gin.Context) { +func (h *Handler) PutPassword(c *gin.Context) { userIDVal, _ := c.Get("userID") userID, _ := userIDVal.(int64) @@ -454,7 +455,7 @@ func (h *AuthHandler) PutPassword(c *gin.Context) { // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/me [get] -func (h *AuthHandler) GetMe(c *gin.Context) { +func (h *Handler) GetMe(c *gin.Context) { userID, _ := c.Get("userID") username, _ := c.Get("username") role, _ := c.Get("role") @@ -497,7 +498,7 @@ type avoidTGEntry struct { // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/tg-selection [get] -func (h *AuthHandler) GetTGSelection(c *gin.Context) { +func (h *Handler) GetTGSelection(c *gin.Context) { userIDVal, _ := c.Get("userID") userID, _ := userIDVal.(int64) @@ -549,7 +550,7 @@ func (h *AuthHandler) GetTGSelection(c *gin.Context) { // @Failure 401 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /auth/tg-selection [put] -func (h *AuthHandler) PutTGSelection(c *gin.Context) { +func (h *Handler) PutTGSelection(c *gin.Context) { userIDVal, _ := c.Get("userID") userID, _ := userIDVal.(int64) @@ -583,3 +584,21 @@ func (h *AuthHandler) PutTGSelection(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } + +// PostDocsSession handles POST /api/admin/docs/session. +// It mints a short-lived HTTP-only cookie so Swagger UI can be opened in a new +// browser tab without exposing the JWT. Kept in the auth package because it is +// fundamentally a session-cookie-minting endpoint. +// +// @Summary Create Swagger docs session cookie +// @Description Issues a short-lived HTTP-only cookie used to access /api/admin/docs. +// @Tags Admin +// @Produce json +// @Success 200 {object} object{ok=bool} +// @Security BearerAuth +// @Router /admin/docs/session [post] +func PostDocsSession(c *gin.Context) { + secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + auth.SetSwaggerCookie(c, secure) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/backend/internal/api/bookmarks.go b/backend/internal/handler/bookmarks/bookmarks.go similarity index 92% rename from backend/internal/api/bookmarks.go rename to backend/internal/handler/bookmarks/bookmarks.go index 260034d..0f4056f 100644 --- a/backend/internal/api/bookmarks.go +++ b/backend/internal/handler/bookmarks/bookmarks.go @@ -1,4 +1,5 @@ -package api +// Package bookmarks provides per-user call bookmark endpoints. +package bookmarks import ( "database/sql" @@ -10,12 +11,19 @@ import ( "github.com/gin-gonic/gin" "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/shared" ) -type BookmarkHandler struct { +// Handler serves bookmark endpoints. +type Handler struct { queries *db.Queries } +// New constructs a Handler. +func New(queries *db.Queries) *Handler { + return &Handler{queries: queries} +} + // ToggleBookmarkRequest is the request body for POST /api/bookmarks. type ToggleBookmarkRequest struct { CallID int64 `json:"callId" example:"42"` @@ -34,7 +42,7 @@ type BookmarkIDsResponse struct { // BookmarkCallsResponse is returned by GET /api/bookmarks/calls. type BookmarkCallsResponse struct { - Calls []CallSearchResult `json:"calls"` + Calls []shared.CallSearchResult `json:"calls"` } // @name BookmarkCallsResponse // PostToggleBookmark handles POST /api/bookmarks — toggles a bookmark for the authenticated user. @@ -51,7 +59,7 @@ type BookmarkCallsResponse struct { // @Failure 401 {object} ErrorResponse "Authentication required" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /bookmarks [post] -func (h *BookmarkHandler) PostToggleBookmark(c *gin.Context) { +func (h *Handler) PostToggleBookmark(c *gin.Context) { var req struct { CallID int64 `json:"callId"` } @@ -144,7 +152,7 @@ func (h *BookmarkHandler) PostToggleBookmark(c *gin.Context) { // @Failure 401 {object} ErrorResponse "Authentication required" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /bookmarks [get] -func (h *BookmarkHandler) GetBookmarkIDs(c *gin.Context) { +func (h *Handler) GetBookmarkIDs(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(int64) @@ -170,7 +178,7 @@ func (h *BookmarkHandler) GetBookmarkIDs(c *gin.Context) { // @Failure 401 {object} ErrorResponse "Authentication required" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /bookmarks/calls [get] -func (h *BookmarkHandler) GetBookmarkCalls(c *gin.Context) { +func (h *Handler) GetBookmarkCalls(c *gin.Context) { uid, _ := c.Get("userID") userID := uid.(int64) @@ -180,9 +188,9 @@ func (h *BookmarkHandler) GetBookmarkCalls(c *gin.Context) { return } - results := make([]CallSearchResult, 0, len(rows)) + results := make([]shared.CallSearchResult, 0, len(rows)) for _, row := range rows { - r := CallSearchResult{ + r := shared.CallSearchResult{ ID: row.ID, AudioName: row.AudioName, AudioType: row.AudioType, diff --git a/backend/internal/api/calls.go b/backend/internal/handler/calls/calls.go similarity index 84% rename from backend/internal/api/calls.go rename to backend/internal/handler/calls/calls.go index 2ba0fe1..035a3e5 100644 --- a/backend/internal/api/calls.go +++ b/backend/internal/handler/calls/calls.go @@ -1,5 +1,6 @@ -// Package api — call upload (POST /api/call-upload, /api/trunk-recorder-call-upload). -package api +// Package calls — call upload (POST /api/call-upload, /api/trunk-recorder-call-upload) +// plus the authenticated call search, audio, and transcript endpoints. +package calls import ( "context" @@ -18,9 +19,9 @@ import ( "github.com/gin-gonic/gin" "github.com/openscanner/openscanner/internal/audio" - "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/db" "github.com/openscanner/openscanner/internal/downstream" + "github.com/openscanner/openscanner/internal/handler/shared" "github.com/openscanner/openscanner/internal/ws" ) @@ -28,9 +29,13 @@ const ( defaultCallRatePerMin = 60 maxCallRatePerMin = 600 rateWindowDuration = time.Minute - shareRatePerMin = 10 ) +// DownstreamNotifier sends call events to downstream pushers. +type DownstreamNotifier interface { + Notify(event downstream.CallEvent) +} + // apiKeyLimiter is a per-API-key sliding-window rate limiter. type apiKeyLimiter struct { mu sync.Mutex @@ -54,33 +59,31 @@ func (l *apiKeyLimiter) allow() bool { return true } -// CallHandler handles call upload endpoints. -type CallHandler struct { - queries *db.Queries - processor *audio.Processor - hub *ws.Hub - dsNotifier DownstreamNotifier - transcriber audio.Transcriber // nil when transcription is disabled - mu sync.Mutex - limiters map[int64]*apiKeyLimiter - shareMu sync.Mutex - shareLimiters map[int64]*apiKeyLimiter +// Handler handles call upload and archive endpoints. +type Handler struct { + queries *db.Queries + processor *audio.Processor + hub *ws.Hub + dsNotifier DownstreamNotifier + transcriber audio.Transcriber // nil when transcription is disabled + + mu sync.Mutex + limiters map[int64]*apiKeyLimiter } -// NewCallHandler creates a CallHandler. -func NewCallHandler(queries *db.Queries, processor *audio.Processor, hub *ws.Hub, dsNotifier DownstreamNotifier, transcriber audio.Transcriber) *CallHandler { - return &CallHandler{ - queries: queries, - processor: processor, - hub: hub, - dsNotifier: dsNotifier, - transcriber: transcriber, - limiters: make(map[int64]*apiKeyLimiter), - shareLimiters: make(map[int64]*apiKeyLimiter), +// New creates a call Handler. +func New(queries *db.Queries, processor *audio.Processor, hub *ws.Hub, dsNotifier DownstreamNotifier, transcriber audio.Transcriber) *Handler { + return &Handler{ + queries: queries, + processor: processor, + hub: hub, + dsNotifier: dsNotifier, + transcriber: transcriber, + limiters: make(map[int64]*apiKeyLimiter), } } -func (h *CallHandler) getLimiter(apiKeyID int64, rateLimit int) *apiKeyLimiter { +func (h *Handler) getLimiter(apiKeyID int64, rateLimit int) *apiKeyLimiter { h.mu.Lock() defer h.mu.Unlock() @@ -113,102 +116,6 @@ func (h *CallHandler) getLimiter(apiKeyID int64, rateLimit int) *apiKeyLimiter { return l } -// getShareLimiter returns a per-user rate limiter for share creation. -func (h *CallHandler) getShareLimiter(userID int64) *apiKeyLimiter { - h.shareMu.Lock() - defer h.shareMu.Unlock() - - if len(h.shareLimiters) > 100 { - now := time.Now() - for id, l := range h.shareLimiters { - l.mu.Lock() - stale := now.Sub(l.windowStart) >= 2*rateWindowDuration - l.mu.Unlock() - if stale { - delete(h.shareLimiters, id) - } - } - } - - l, ok := h.shareLimiters[userID] - if !ok { - l = &apiKeyLimiter{ - windowStart: time.Now(), - rateLimit: shareRatePerMin, - } - h.shareLimiters[userID] = l - } - return l -} - -// systemGrant mirrors ws.systemGrant for grant-based filtering in REST handlers. -type systemGrant struct { - ID int64 `json:"id"` - Talkgroups []int64 `json:"talkgroups,omitempty"` -} - -// loadUserGrants returns the parsed grants for the authenticated user. Returns -// nil (allow-all) for admins, unauthenticated users, or users with no grants. -func (h *CallHandler) loadUserGrants(c *gin.Context) []systemGrant { - role, _ := c.Get("role") - roleStr, _ := role.(string) - if roleStr == auth.RoleAdmin { - return nil - } - userIDVal, exists := c.Get("userID") - if !exists { - return nil - } - uid, _ := userIDVal.(int64) - user, err := h.queries.GetUser(c.Request.Context(), uid) - if err != nil { - return nil - } - if !user.SystemsJson.Valid || user.SystemsJson.String == "" { - return nil - } - var grants []systemGrant - if err := json.Unmarshal([]byte(user.SystemsJson.String), &grants); err != nil { - slog.Warn("failed to parse user grants", "user_id", uid, "error", err) - return nil - } - if len(grants) == 0 { - return nil - } - return grants -} - -// isGranted checks whether a call with the given system/talkgroup passes the -// grant filter. A nil grant list means everything is allowed. -func isGranted(grants []systemGrant, systemID, talkgroupID int64) bool { - if grants == nil { - return true - } - for _, g := range grants { - if g.ID != systemID { - continue - } - if len(g.Talkgroups) == 0 { - return true - } - for _, tg := range g.Talkgroups { - if tg == talkgroupID { - return true - } - } - } - return false -} - -// getSettingValue fetches a setting value from the DB, returning "" on error. -func (h *CallHandler) getSettingValue(c *gin.Context, key string) string { - s, err := h.queries.GetSetting(c.Request.Context(), key) - if err != nil { - return "" - } - return s.Value -} - // PostCallUpload handles POST /api/call-upload and /api/trunk-recorder-call-upload. // // @Summary Upload a call recording @@ -244,7 +151,7 @@ func (h *CallHandler) getSettingValue(c *gin.Context, key string) string { // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /call-upload [post] // @Router /trunk-recorder-call-upload [post] -func (h *CallHandler) PostCallUpload(c *gin.Context) { +func (h *Handler) PostCallUpload(c *gin.Context) { slog.Debug("call-upload: request received", "ip", c.ClientIP()) // Retrieve API key ID injected by APIKeyAuth middleware. apiKeyIDVal, exists := c.Get("apiKeyID") @@ -267,7 +174,7 @@ func (h *CallHandler) PostCallUpload(c *gin.Context) { apiKeyRateOverride = true } } - if rStr := h.getSettingValue(c, "apiKeyCallRate"); rStr != "" { + if rStr := shared.GetSettingValue(c, h.queries, "apiKeyCallRate"); rStr != "" { if r, err := strconv.Atoi(rStr); err == nil && r > 0 && !apiKeyRateOverride { rateLimit = r } @@ -449,7 +356,7 @@ func (h *CallHandler) PostCallUpload(c *gin.Context) { } ctx := c.Request.Context() - autoPopulateSystems := h.getSettingValue(c, "autoPopulateSystems") == "true" + autoPopulateSystems := shared.GetSettingValue(c, h.queries, "autoPopulateSystems") == "true" slog.Debug("call-upload: resolving system and talkgroup", "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) @@ -519,12 +426,12 @@ func (h *CallHandler) PostCallUpload(c *gin.Context) { // Resolve group from talkgroupGroup (e.g. SDRTrunk sends this). var groupID sql.NullInt64 if talkgroupGroup != "" { - groupID = resolveGroupID(ctx, h.queries, talkgroupGroup) + groupID = shared.ResolveGroupID(ctx, h.queries, talkgroupGroup) } // Resolve tag from talkgroupTag (e.g. "Law Dispatch", "Fire-Tac"). var tagID sql.NullInt64 if talkgroupTag != "" { - tagID = resolveTagID(ctx, h.queries, talkgroupTag) + tagID = shared.ResolveTagID(ctx, h.queries, talkgroupTag) } newID, cerr := h.queries.CreateTalkgroup(ctx, db.CreateTalkgroupParams{ SystemID: system.ID, @@ -551,10 +458,10 @@ func (h *CallHandler) PostCallUpload(c *gin.Context) { talkgroup.Name = sql.NullString{String: talkgroupName, Valid: true} } if !talkgroup.GroupID.Valid && talkgroupGroup != "" { - talkgroup.GroupID = resolveGroupID(ctx, h.queries, talkgroupGroup) + talkgroup.GroupID = shared.ResolveGroupID(ctx, h.queries, talkgroupGroup) } if !talkgroup.TagID.Valid && talkgroupTag != "" { - talkgroup.TagID = resolveTagID(ctx, h.queries, talkgroupTag) + talkgroup.TagID = shared.ResolveTagID(ctx, h.queries, talkgroupTag) } if uerr := h.queries.UpdateTalkgroup(ctx, db.UpdateTalkgroupParams{ ID: talkgroup.ID, @@ -577,9 +484,9 @@ func (h *CallHandler) PostCallUpload(c *gin.Context) { } // Duplicate detection (system.ID and talkgroup.ID are the FK values in calls). - if h.getSettingValue(c, "disableDuplicateDetection") != "true" { + if shared.GetSettingValue(c, h.queries, "disableDuplicateDetection") != "true" { windowMs := int64(2000) - if v := h.getSettingValue(c, "duplicateDetectionTimeFrame"); v != "" { + if v := shared.GetSettingValue(c, h.queries, "duplicateDetectionTimeFrame"); v != "" { if wm, err := strconv.ParseInt(v, 10, 64); err == nil { windowMs = wm } @@ -604,14 +511,14 @@ func (h *CallHandler) PostCallUpload(c *gin.Context) { // Resolve audio conversion mode from settings. convMode := audio.ConversionDisabled - if mStr := h.getSettingValue(c, "audioConversion"); mStr != "" { + if mStr := shared.GetSettingValue(c, h.queries, "audioConversion"); mStr != "" { if m, err := strconv.Atoi(mStr); err == nil { convMode = audio.ConversionMode(m) } } // Resolve encoding preset from settings. - convPreset := audio.ParseEncodingPreset(h.getSettingValue(c, "audioEncodingPreset")) + convPreset := audio.ParseEncodingPreset(shared.GetSettingValue(c, h.queries, "audioEncodingPreset")) // Store audio file (conversion handled inside Processor.Store). relPath, err := h.processor.Store(ctx, fh, convMode, convPreset) @@ -857,33 +764,6 @@ func (h *CallHandler) PostCallUpload(c *gin.Context) { } } -// CallSearchResult is a single call in the search response. -type CallSearchResult struct { - ID int64 `json:"id"` - AudioName string `json:"audioName"` - AudioType string `json:"audioType"` - DateTime int64 `json:"dateTime"` - SystemID int64 `json:"systemId"` - SystemLabel string `json:"systemLabel"` - TalkgroupID int64 `json:"talkgroupId"` - TalkgroupLabel string `json:"talkgroupLabel"` - TalkgroupName string `json:"talkgroupName"` - TalkgroupGroup string `json:"talkgroupGroup,omitempty"` - TalkgroupTag string `json:"talkgroupTag,omitempty"` - TalkgroupLed string `json:"talkgroupLed,omitempty"` - Frequency *int64 `json:"frequency,omitempty"` - Duration *int64 `json:"duration,omitempty"` - Source *int64 `json:"source,omitempty"` - Site string `json:"site,omitempty"` - Channel string `json:"channel,omitempty"` - Decoder string `json:"decoder,omitempty"` - ErrorCount *int64 `json:"errorCount,omitempty"` - SpikeCount *int64 `json:"spikeCount,omitempty"` - TalkerAlias string `json:"talkerAlias,omitempty"` - Transcript string `json:"transcript,omitempty"` - Bookmarked bool `json:"bookmarked"` -} // @name CallSearchResult - // GetCallAudio handles GET /api/calls/:id/audio. // // @Summary Get call audio file @@ -898,7 +778,7 @@ type CallSearchResult struct { // @Failure 404 {object} ErrorResponse "Call or audio not found" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /calls/{id}/audio [get] -func (h *CallHandler) GetCallAudio(c *gin.Context) { +func (h *Handler) GetCallAudio(c *gin.Context) { ctx := c.Request.Context() id, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || id <= 0 { @@ -909,7 +789,7 @@ func (h *CallHandler) GetCallAudio(c *gin.Context) { // Require authentication or publicAccess for direct audio access. // Anonymous users must use /api/shared/:token/audio for shared calls. _, hasUser := c.Get("userID") - if !hasUser && h.getSettingValue(c, "publicAccess") != "true" { + if !hasUser && shared.GetSettingValue(c, h.queries, "publicAccess") != "true" { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } @@ -926,7 +806,7 @@ func (h *CallHandler) GetCallAudio(c *gin.Context) { } // Enforce per-user grants for non-admin listeners. - if grants := h.loadUserGrants(c); !isGranted(grants, call.SystemID, call.TalkgroupID.Int64) { + if grants := shared.LoadUserGrants(c, h.queries); !shared.IsGranted(grants, call.SystemID, call.TalkgroupID.Int64) { c.JSON(http.StatusNotFound, gin.H{"error": "call not found"}) return } @@ -977,17 +857,11 @@ func (h *CallHandler) GetCallAudio(c *gin.Context) { filename = "call" } - c.Header("Content-Disposition", contentDisposition("inline", filename)) + c.Header("Content-Disposition", shared.ContentDisposition("inline", filename)) c.Header("Content-Type", contentType) http.ServeContent(c.Writer, c.Request, filename, fi.ModTime(), f) } -// CallSearchResponse is the response for GET /api/calls. -type CallSearchResponse struct { - Calls []CallSearchResult `json:"calls"` - Total int64 `json:"total"` -} // @name CallSearchResponse - // GetCalls handles GET /api/calls — paginated call archive search. // // @Summary Search calls @@ -1014,7 +888,7 @@ type CallSearchResponse struct { // @Failure 400 {object} ErrorResponse "Invalid query parameter" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /calls [get] -func (h *CallHandler) GetCalls(c *gin.Context) { +func (h *Handler) GetCalls(c *gin.Context) { ctx := c.Request.Context() parseCSVInt64 := func(raw string) ([]int64, error) { @@ -1121,7 +995,7 @@ func (h *CallHandler) GetCalls(c *gin.Context) { } } if len(groupLabels) > 0 && len(groupIDs) == 0 { - c.JSON(http.StatusOK, CallSearchResponse{Calls: []CallSearchResult{}, Total: 0}) + c.JSON(http.StatusOK, shared.CallSearchResponse{Calls: []shared.CallSearchResult{}, Total: 0}) return } @@ -1133,7 +1007,7 @@ func (h *CallHandler) GetCalls(c *gin.Context) { } } if len(tagLabels) > 0 && len(tagIDs) == 0 { - c.JSON(http.StatusOK, CallSearchResponse{Calls: []CallSearchResult{}, Total: 0}) + c.JSON(http.StatusOK, shared.CallSearchResponse{Calls: []shared.CallSearchResult{}, Total: 0}) return } @@ -1255,11 +1129,11 @@ func (h *CallHandler) GetCalls(c *gin.Context) { // Enforce per-user grants — filter out calls the listener is not // authorised to see. Admins and unauthenticated public-access users // have nil grants (allow-all). - grants := h.loadUserGrants(c) + grants := shared.LoadUserGrants(c, h.queries) if grants != nil { allowed := calls[:0] for _, call := range calls { - if isGranted(grants, call.SystemID, call.TalkgroupID.Int64) { + if shared.IsGranted(grants, call.SystemID, call.TalkgroupID.Int64) { allowed = append(allowed, call) } } @@ -1293,9 +1167,9 @@ func (h *CallHandler) GetCalls(c *gin.Context) { tagCache := make(map[int64]string) // Build response with joined labels and transcripts. - results := make([]CallSearchResult, 0, len(calls)) + results := make([]shared.CallSearchResult, 0, len(calls)) for _, call := range calls { - r := CallSearchResult{ + r := shared.CallSearchResult{ ID: call.ID, AudioName: call.AudioName, AudioType: call.AudioType, @@ -1409,54 +1283,80 @@ func (h *CallHandler) GetCalls(c *gin.Context) { results = append(results, r) } - c.JSON(http.StatusOK, CallSearchResponse{ + c.JSON(http.StatusOK, shared.CallSearchResponse{ Calls: results, Total: total, }) } -// resolveGroupID looks up an existing group by label or creates one if it -// doesn't exist. Returns a valid sql.NullInt64 with the group's DB ID, or -// an invalid NullInt64 if the operation fails. -func resolveGroupID(ctx context.Context, q db.Querier, label string) sql.NullInt64 { - g, err := q.GetGroupByLabel(ctx, label) - if err == nil { - return sql.NullInt64{Int64: g.ID, Valid: true} - } - if !errors.Is(err, sql.ErrNoRows) { - slog.Warn("failed to look up group by label", "label", label, "error", err) - return sql.NullInt64{} +// transcriptResponse is the JSON shape returned by GetCallTranscript. +type transcriptResponse struct { + Text string `json:"text"` + Segments []audio.TranscriptionSegment `json:"segments"` + Language string `json:"language"` + Model string `json:"model"` +} // @name TranscriptResponse + +// GetCallTranscript handles GET /api/calls/:id/transcript. +// Returns the transcription for a call if one exists. +// +// @Summary Get call transcript +// @Description Returns the transcription text, segments, language and model for a call. Authentication is optional when the publicAccess setting is enabled; otherwise a valid JWT is required. +// @Tags Calls +// @Produce json +// @Security BearerAuth +// @Param id path int true "Call ID" +// @Success 200 {object} transcriptResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /calls/{id}/transcript [get] +func (h *Handler) GetCallTranscript(c *gin.Context) { + ctx := c.Request.Context() + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid call id"}) + return } - newID, cerr := q.CreateGroup(ctx, label) - if cerr != nil { - slog.Warn("failed to auto-create group", "label", label, "error", cerr) - return sql.NullInt64{} + + // Require authentication or publicAccess. + _, hasUser := c.Get("userID") + if !hasUser && shared.GetSettingValue(c, h.queries, "publicAccess") != "true" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return } - slog.Info("auto-populated group from upload", "label", label, "db_id", newID) - return sql.NullInt64{Int64: newID, Valid: true} -} -// resolveTagID looks up an existing tag by label or creates one if it -// doesn't exist. Returns a valid sql.NullInt64 with the tag's DB ID, or -// an invalid NullInt64 if the operation fails. -func resolveTagID(ctx context.Context, q db.Querier, label string) sql.NullInt64 { - t, err := q.GetTagByLabel(ctx, label) - if err == nil { - return sql.NullInt64{Int64: t.ID, Valid: true} + trx, err := h.queries.GetTranscriptionByCallID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "transcript not found"}) + return + } + slog.Error("failed to get transcript", "call_id", id, "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return } - if !errors.Is(err, sql.ErrNoRows) { - slog.Warn("failed to look up tag by label", "label", label, "error", err) - return sql.NullInt64{} + + var segments []audio.TranscriptionSegment + if trx.Segments.Valid && trx.Segments.String != "" { + if err := json.Unmarshal([]byte(trx.Segments.String), &segments); err != nil { + slog.Warn("failed to parse transcript segments", "call_id", id, "error", err) + } } - newID, cerr := q.CreateTag(ctx, label) - if cerr != nil { - slog.Warn("failed to auto-create tag", "label", label, "error", cerr) - return sql.NullInt64{} + if segments == nil { + segments = []audio.TranscriptionSegment{} } - slog.Info("auto-populated tag from upload", "label", label, "db_id", newID) - return sql.NullInt64{Int64: newID, Valid: true} + + c.JSON(http.StatusOK, transcriptResponse{ + Text: trx.Text, + Segments: segments, + Language: trx.Language.String, + Model: trx.Model.String, + }) } +// --- helpers --- + // needsBackfill returns true if at least one talkgroup field is empty and a // corresponding value was provided in the upload metadata. func needsBackfill(tg db.Talkgroup, label, name, tag, group string) bool { @@ -1616,69 +1516,3 @@ func aggregateErrorSpikeCounts(raw string) (sql.NullInt64, sql.NullInt64) { return sql.NullInt64{Int64: totalErrors, Valid: true}, sql.NullInt64{Int64: totalSpikes, Valid: true} } - -// transcriptResponse is the JSON shape returned by GetCallTranscript. -type transcriptResponse struct { - Text string `json:"text"` - Segments []audio.TranscriptionSegment `json:"segments"` - Language string `json:"language"` - Model string `json:"model"` -} // @name TranscriptResponse - -// GetCallTranscript handles GET /api/calls/:id/transcript. -// Returns the transcription for a call if one exists. -// -// @Summary Get call transcript -// @Description Returns the transcription text, segments, language and model for a call. Authentication is optional when the publicAccess setting is enabled; otherwise a valid JWT is required. -// @Tags Calls -// @Produce json -// @Security BearerAuth -// @Param id path int true "Call ID" -// @Success 200 {object} transcriptResponse -// @Failure 400 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse -// @Failure 500 {object} ErrorResponse -// @Router /calls/{id}/transcript [get] -func (h *CallHandler) GetCallTranscript(c *gin.Context) { - ctx := c.Request.Context() - id, err := strconv.ParseInt(c.Param("id"), 10, 64) - if err != nil || id <= 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid call id"}) - return - } - - // Require authentication or publicAccess. - _, hasUser := c.Get("userID") - if !hasUser && h.getSettingValue(c, "publicAccess") != "true" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - - trx, err := h.queries.GetTranscriptionByCallID(ctx, id) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - c.JSON(http.StatusNotFound, gin.H{"error": "transcript not found"}) - return - } - slog.Error("failed to get transcript", "call_id", id, "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - - var segments []audio.TranscriptionSegment - if trx.Segments.Valid && trx.Segments.String != "" { - if err := json.Unmarshal([]byte(trx.Segments.String), &segments); err != nil { - slog.Warn("failed to parse transcript segments", "call_id", id, "error", err) - } - } - if segments == nil { - segments = []audio.TranscriptionSegment{} - } - - c.JSON(http.StatusOK, transcriptResponse{ - Text: trx.Text, - Segments: segments, - Language: trx.Language.String, - Model: trx.Model.String, - }) -} diff --git a/backend/internal/api/calls_limiter_internal_test.go b/backend/internal/handler/calls/limiter_internal_test.go similarity index 76% rename from backend/internal/api/calls_limiter_internal_test.go rename to backend/internal/handler/calls/limiter_internal_test.go index 7964d8f..6f4bd21 100644 --- a/backend/internal/api/calls_limiter_internal_test.go +++ b/backend/internal/handler/calls/limiter_internal_test.go @@ -1,4 +1,4 @@ -package api +package calls import ( "testing" @@ -18,7 +18,7 @@ import ( // triggers the sweep, which should purge every stale entry except the // freshly-inserted one. func TestCallHandler_Limiter_CleansUpStaleEntries(t *testing.T) { - h := NewCallHandler(&db.Queries{}, (*audio.Processor)(nil), (*ws.Hub)(nil), nil, nil) + h := New(&db.Queries{}, (*audio.Processor)(nil), (*ws.Hub)(nil), nil, nil) // Seed 101 entries with a stale windowStart (> 2*rateWindowDuration ago). staleStart := time.Now().Add(-3 * rateWindowDuration) @@ -50,7 +50,7 @@ func TestCallHandler_Limiter_CleansUpStaleEntries(t *testing.T) { } func TestCallHandler_Limiter_KeepsFreshEntriesDuringSweep(t *testing.T) { - h := NewCallHandler(&db.Queries{}, (*audio.Processor)(nil), (*ws.Hub)(nil), nil, nil) + h := New(&db.Queries{}, (*audio.Processor)(nil), (*ws.Hub)(nil), nil, nil) now := time.Now() // 90 stale + 15 fresh = 105 total → > 100 triggers sweep. @@ -74,21 +74,3 @@ func TestCallHandler_Limiter_KeepsFreshEntriesDuringSweep(t *testing.T) { t.Fatalf("post-sweep size = %d, want 16 (15 fresh + 1 new)", got) } } - -func TestCallHandler_ShareLimiter_CleansUpStaleEntries(t *testing.T) { - h := NewCallHandler(&db.Queries{}, (*audio.Processor)(nil), (*ws.Hub)(nil), nil, nil) - - staleStart := time.Now().Add(-3 * rateWindowDuration) - for i := int64(1); i <= 101; i++ { - h.shareLimiters[i] = &apiKeyLimiter{ - windowStart: staleStart, - rateLimit: shareRatePerMin, - } - } - - _ = h.getShareLimiter(9999) - - if got := len(h.shareLimiters); got != 1 { - t.Fatalf("post-sweep shareLimiters size = %d, want 1", got) - } -} diff --git a/backend/internal/handler/health/health.go b/backend/internal/handler/health/health.go new file mode 100644 index 0000000..add6ce3 --- /dev/null +++ b/backend/internal/handler/health/health.go @@ -0,0 +1,33 @@ +// Package health provides the GET /api/health endpoint. +package health + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Handler serves the health-check endpoint. +type Handler struct { + version string +} + +// New constructs a Handler. +func New(version string) *Handler { + return &Handler{version: version} +} + +// Get godoc +// +// @Summary Health check +// @Description Returns server status and version for readiness probes and Docker HEALTHCHECK. +// @Tags Health +// @Produce json +// @Success 200 {object} object{status=string,version=string} "Server is healthy" +// @Router /health [get] +func (h *Handler) Get(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "version": h.version, + }) +} diff --git a/backend/internal/api/admin_test.go b/backend/internal/handler/routes/admin_test.go similarity index 98% rename from backend/internal/api/admin_test.go rename to backend/internal/handler/routes/admin_test.go index 152a53c..e6b302b 100644 --- a/backend/internal/api/admin_test.go +++ b/backend/internal/handler/routes/admin_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "bytes" @@ -11,7 +11,7 @@ import ( "testing" "github.com/gin-gonic/gin" - "github.com/openscanner/openscanner/internal/api" + "github.com/openscanner/openscanner/internal/handler/routes" "github.com/openscanner/openscanner/internal/audio" "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/db" @@ -31,7 +31,7 @@ func newAdminTestEngine(t *testing.T) (*gin.Engine, *db.Queries) { router := gin.New() rl := auth.NewRateLimiter(context.Background()) processor := audio.NewProcessor(t.TempDir(), nil) - api.RegisterRoutes(router, api.Deps{ + routes.RegisterRoutes(router, routes.Deps{ Queries: queries, RateLimiter: rl, Processor: processor, diff --git a/backend/internal/api/auth_test.go b/backend/internal/handler/routes/auth_test.go similarity index 99% rename from backend/internal/api/auth_test.go rename to backend/internal/handler/routes/auth_test.go index 9ded864..baafcae 100644 --- a/backend/internal/api/auth_test.go +++ b/backend/internal/handler/routes/auth_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "bytes" diff --git a/backend/internal/api/bookmarks_test.go b/backend/internal/handler/routes/bookmarks_test.go similarity index 99% rename from backend/internal/api/bookmarks_test.go rename to backend/internal/handler/routes/bookmarks_test.go index 1578490..538d252 100644 --- a/backend/internal/api/bookmarks_test.go +++ b/backend/internal/handler/routes/bookmarks_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "bytes" diff --git a/backend/internal/api/calls_test.go b/backend/internal/handler/routes/calls_test.go similarity index 99% rename from backend/internal/api/calls_test.go rename to backend/internal/handler/routes/calls_test.go index 24ede35..d45f6a4 100644 --- a/backend/internal/api/calls_test.go +++ b/backend/internal/handler/routes/calls_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "bytes" @@ -12,7 +12,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/openscanner/openscanner/internal/api" + "github.com/openscanner/openscanner/internal/handler/routes" "github.com/openscanner/openscanner/internal/audio" "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/db" @@ -32,7 +32,7 @@ func newTestEngineWithCalls(t *testing.T) (*gin.Engine, *db.Queries) { router := gin.New() rl := auth.NewRateLimiter(context.Background()) - api.RegisterRoutes(router, api.Deps{ + routes.RegisterRoutes(router, routes.Deps{ Queries: queries, RateLimiter: rl, Processor: proc, diff --git a/backend/internal/api/listener_ws_alias_test.go b/backend/internal/handler/routes/listener_ws_alias_test.go similarity index 97% rename from backend/internal/api/listener_ws_alias_test.go rename to backend/internal/handler/routes/listener_ws_alias_test.go index 8fb4121..f4fb04a 100644 --- a/backend/internal/api/listener_ws_alias_test.go +++ b/backend/internal/handler/routes/listener_ws_alias_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "testing" diff --git a/backend/internal/api/radioreference_test.go b/backend/internal/handler/routes/radioreference_test.go similarity index 99% rename from backend/internal/api/radioreference_test.go rename to backend/internal/handler/routes/radioreference_test.go index 9125d1d..612a186 100644 --- a/backend/internal/api/radioreference_test.go +++ b/backend/internal/handler/routes/radioreference_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "bytes" diff --git a/backend/internal/api/refresh_test.go b/backend/internal/handler/routes/refresh_test.go similarity index 99% rename from backend/internal/api/refresh_test.go rename to backend/internal/handler/routes/refresh_test.go index 4382c8b..bf01650 100644 --- a/backend/internal/api/refresh_test.go +++ b/backend/internal/handler/routes/refresh_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "bytes" diff --git a/backend/internal/api/routes.go b/backend/internal/handler/routes/routes.go similarity index 66% rename from backend/internal/api/routes.go rename to backend/internal/handler/routes/routes.go index 722dc4c..98868a1 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/handler/routes/routes.go @@ -1,5 +1,9 @@ -// Package api contains Gin route handlers for OpenScanner. -package api +// Package routes wires all OpenScanner HTTP and WebSocket routes onto a Gin engine. +// +// It owns the top-level route registration and middleware ordering, and delegates +// per-feature handling to the handler subpackages (auth, calls, bookmarks, share, +// setup, health, and admin/*). +package routes import ( "database/sql" @@ -18,6 +22,15 @@ import ( "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/db" "github.com/openscanner/openscanner/internal/downstream" + authhandler "github.com/openscanner/openscanner/internal/handler/auth" + "github.com/openscanner/openscanner/internal/handler/admin/imports" + "github.com/openscanner/openscanner/internal/handler/admin/radioreference" + "github.com/openscanner/openscanner/internal/handler/admin/transcriptions" + "github.com/openscanner/openscanner/internal/handler/bookmarks" + "github.com/openscanner/openscanner/internal/handler/calls" + "github.com/openscanner/openscanner/internal/handler/health" + "github.com/openscanner/openscanner/internal/handler/setup" + "github.com/openscanner/openscanner/internal/handler/share" "github.com/openscanner/openscanner/internal/middleware" "github.com/openscanner/openscanner/internal/static" "github.com/openscanner/openscanner/internal/ws" @@ -57,18 +70,15 @@ type Deps struct { // RegisterRoutes wires all API routes onto the Gin engine. func RegisterRoutes(r *gin.Engine, deps Deps) { - setupHandler := NewSetupHandler(deps.Queries) - authHandler := NewAuthHandler(deps.Queries, deps.RateLimiter, deps.Hub) - callHandler := NewCallHandler(deps.Queries, deps.Processor, deps.Hub, deps.DownstreamNotifier, deps.Transcriber) - bookmarkHandler := &BookmarkHandler{queries: deps.Queries} - recordingsDir := "." - if deps.Processor != nil { - recordingsDir = deps.Processor.RecordingsDir() - } - adminHandler := NewAdminHandler(deps.Queries, deps.Hub, deps.SQLDB, deps.DirMonitorReloader, deps.DownstreamReloader, recordingsDir) - adminHandler.ffmpegAvailable = deps.FFmpegAvailable - adminHandler.fdkAACAvailable = deps.FDKAACAvailable - adminHandler.whisperAvailable = deps.WhisperAvailable + healthHandler := health.New(deps.Version) + setupHandler := setup.New(deps.Queries) + authH := authhandler.New(deps.Queries, deps.RateLimiter, deps.Hub) + callHandler := calls.New(deps.Queries, deps.Processor, deps.Hub, deps.DownstreamNotifier, deps.Transcriber) + shareHandler := share.New(deps.Queries, deps.Processor) + bookmarkHandler := bookmarks.New(deps.Queries) + importsHandler := imports.New(deps.Queries, deps.Hub) + rrHandler := radioreference.New(deps.Queries) + transcriptionsHandler := transcriptions.New(deps.Queries, deps.WhisperAvailable) // Global middleware applied to every request. r.Use(middleware.RequestID()) @@ -78,24 +88,24 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { api := r.Group("/api") // Health check — unauthenticated. - RegisterHealth(api, deps.Version) + api.GET("/health", healthHandler.Get) // First-run setup — unauthenticated. api.GET("/setup/status", setupHandler.GetSetupStatus) api.POST("/setup", middleware.MaxBodySize(1<<20), setupHandler.PostSetup) // Auth — login and refresh are unauthenticated; the rest require a valid JWT. - api.POST("/auth/login", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authHandler.PostLogin) - api.POST("/auth/refresh", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authHandler.PostRefresh) + api.POST("/auth/login", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostLogin) + api.POST("/auth/refresh", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostRefresh) authRequired := api.Group("/auth") authRequired.Use(middleware.JWTAuth()) { - authRequired.POST("/logout", authHandler.PostLogout) - authRequired.PUT("/password", authHandler.PutPassword) - authRequired.GET("/me", authHandler.GetMe) - authRequired.GET("/tg-selection", authHandler.GetTGSelection) - authRequired.PUT("/tg-selection", authHandler.PutTGSelection) + authRequired.POST("/logout", authH.PostLogout) + authRequired.PUT("/password", authH.PutPassword) + authRequired.GET("/me", authH.GetMe) + authRequired.GET("/tg-selection", authH.GetTGSelection) + authRequired.PUT("/tg-selection", authH.PutTGSelection) } // Call search — public access with optional auth for bookmarks. @@ -106,21 +116,21 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { // Shared calls — token-based public access (no auth required). // Rate-limited to 30 req/min per IP to prevent bandwidth exhaustion. sharedRateLimit := middleware.RateLimitByIP(30) - api.GET("/shared/:token", sharedRateLimit, callHandler.GetSharedCallByToken) - api.GET("/shared/:token/audio", sharedRateLimit, callHandler.GetSharedCallAudio) + api.GET("/shared/:token", sharedRateLimit, shareHandler.GetSharedCallByToken) + api.GET("/shared/:token/audio", sharedRateLimit, shareHandler.GetSharedCallAudio) // Share management — JWT required. - api.POST("/calls/:id/share", middleware.JWTAuth(), callHandler.PostShareCall) - api.DELETE("/calls/:id/share", middleware.JWTAuth(), callHandler.DeleteShareCall) - api.GET("/calls/:id/share", middleware.JWTAuth(), callHandler.GetCallShare) + api.POST("/calls/:id/share", middleware.JWTAuth(), shareHandler.PostShareCall) + api.DELETE("/calls/:id/share", middleware.JWTAuth(), shareHandler.DeleteShareCall) + api.GET("/calls/:id/share", middleware.JWTAuth(), shareHandler.GetCallShare) // Bookmarks — JWT required. - bookmarks := api.Group("/bookmarks") - bookmarks.Use(middleware.JWTAuth()) + bookmarksGroup := api.Group("/bookmarks") + bookmarksGroup.Use(middleware.JWTAuth()) { - bookmarks.GET("", bookmarkHandler.GetBookmarkIDs) - bookmarks.GET("/calls", bookmarkHandler.GetBookmarkCalls) - bookmarks.POST("", bookmarkHandler.PostToggleBookmark) + bookmarksGroup.GET("", bookmarkHandler.GetBookmarkIDs) + bookmarksGroup.GET("/calls", bookmarkHandler.GetBookmarkCalls) + bookmarksGroup.POST("", bookmarkHandler.PostToggleBookmark) } // Call upload — API key auth. @@ -134,26 +144,24 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { } // Admin routes — JWT + admin role required. - // Most admin operations are handled via WebSocket (ADM_REQ/ADM_RES). - // Only file-upload endpoints remain on REST (WebSocket can't handle multipart). admin := api.Group("/admin") admin.Use(middleware.JWTAuth(), middleware.RequireAdmin(), middleware.MaxBodySize(2<<20)) // 2 MiB JSON body limit { // Import (file uploads — must stay REST) - admin.POST("/import/talkgroups", adminHandler.ImportTalkgroups) - admin.POST("/import/units", adminHandler.ImportUnits) - admin.POST("/import/groups", adminHandler.ImportGroups) - admin.POST("/import/tags", adminHandler.ImportTags) + admin.POST("/import/talkgroups", importsHandler.ImportTalkgroups) + admin.POST("/import/units", importsHandler.ImportUnits) + admin.POST("/import/groups", importsHandler.ImportGroups) + admin.POST("/import/tags", importsHandler.ImportTags) // RadioReference CSV preview (file upload — must stay REST) - admin.POST("/radioreference/preview/csv", adminHandler.RadioReferencePreviewCSV) + admin.POST("/radioreference/preview/csv", rrHandler.PreviewCSV) // Transcription status - admin.GET("/transcriptions/status", adminHandler.GetTranscriptionStatus) + admin.GET("/transcriptions/status", transcriptionsHandler.GetStatus) // Swagger: issue a short-lived HTTP-only cookie so Swagger UI // can be opened in a new browser tab without exposing the JWT. - admin.POST("/docs/session", postDocsSession) + admin.POST("/docs/session", authhandler.PostDocsSession) } // Swagger API documentation — protected by the HTTP-only cookie @@ -215,18 +223,3 @@ func serveFrontend(r *gin.Engine) { fileServer.ServeHTTP(c.Writer, c.Request) }) } - -// postDocsSession handles POST /api/admin/docs/session. -// -// @Summary Create Swagger docs session cookie -// @Description Issues a short-lived HTTP-only cookie used to access /api/admin/docs. -// @Tags Admin -// @Produce json -// @Success 200 {object} object{ok=bool} -// @Security BearerAuth -// @Router /admin/docs/session [post] -func postDocsSession(c *gin.Context) { - secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" - auth.SetSwaggerCookie(c, secure) - c.JSON(http.StatusOK, gin.H{"ok": true}) -} diff --git a/backend/internal/api/setup_test.go b/backend/internal/handler/routes/setup_test.go similarity index 99% rename from backend/internal/api/setup_test.go rename to backend/internal/handler/routes/setup_test.go index 1665910..2dba3ed 100644 --- a/backend/internal/api/setup_test.go +++ b/backend/internal/handler/routes/setup_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "bytes" diff --git a/backend/internal/api/share_test.go b/backend/internal/handler/routes/share_test.go similarity index 97% rename from backend/internal/api/share_test.go rename to backend/internal/handler/routes/share_test.go index 54330ae..7552f1f 100644 --- a/backend/internal/api/share_test.go +++ b/backend/internal/handler/routes/share_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "context" @@ -11,8 +11,8 @@ import ( "time" "github.com/google/uuid" - "github.com/openscanner/openscanner/internal/api" "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/share" ) // seedCallWithSystem creates a system, talkgroup, and call in the DB and @@ -85,7 +85,7 @@ func TestGetSharedCallByToken_Success(t *testing.T) { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } - var resp api.ShareResponse + var resp share.ShareResponse if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatalf("decode response: %v", err) } diff --git a/backend/internal/api/testhelpers_test.go b/backend/internal/handler/routes/testhelpers_test.go similarity index 93% rename from backend/internal/api/testhelpers_test.go rename to backend/internal/handler/routes/testhelpers_test.go index 465828b..1ff2fa7 100644 --- a/backend/internal/api/testhelpers_test.go +++ b/backend/internal/handler/routes/testhelpers_test.go @@ -1,4 +1,4 @@ -package api_test +package routes_test import ( "context" @@ -7,7 +7,7 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/openscanner/openscanner/internal/api" + "github.com/openscanner/openscanner/internal/handler/routes" "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/db" "github.com/openscanner/openscanner/internal/logging" @@ -38,7 +38,7 @@ func newTestEngine(t *testing.T) (*gin.Engine, *db.Queries) { router := gin.New() rl := auth.NewRateLimiter(context.Background()) - api.RegisterRoutes(router, api.Deps{ + routes.RegisterRoutes(router, routes.Deps{ Queries: queries, RateLimiter: rl, Version: "test", diff --git a/backend/internal/api/setup.go b/backend/internal/handler/setup/setup.go similarity index 91% rename from backend/internal/api/setup.go rename to backend/internal/handler/setup/setup.go index 7494018..db56d1b 100644 --- a/backend/internal/api/setup.go +++ b/backend/internal/handler/setup/setup.go @@ -1,5 +1,5 @@ -// Package api — first-run setup endpoints (POST /api/setup, GET /api/setup/status). -package api +// Package setup provides first-run setup endpoints (POST /api/setup, GET /api/setup/status). +package setup import ( "database/sql" @@ -14,15 +14,15 @@ import ( "github.com/openscanner/openscanner/internal/db" ) -// SetupHandler holds dependencies for first-run setup endpoints. -type SetupHandler struct { +// Handler holds dependencies for first-run setup endpoints. +type Handler struct { queries *db.Queries mu sync.Mutex // guards the check-then-create in PostSetup (TOCTOU prevention) } -// NewSetupHandler constructs a SetupHandler. -func NewSetupHandler(queries *db.Queries) *SetupHandler { - return &SetupHandler{queries: queries} +// New constructs a Handler. +func New(queries *db.Queries) *Handler { + return &Handler{queries: queries} } type setupStatusResponse struct { @@ -39,7 +39,7 @@ type setupStatusResponse struct { // @Success 200 {object} setupStatusResponse // @Failure 500 {object} ErrorResponse // @Router /setup/status [get] -func (h *SetupHandler) GetSetupStatus(c *gin.Context) { +func (h *Handler) GetSetupStatus(c *gin.Context) { ctx := c.Request.Context() requestID, _ := c.Get("requestID") @@ -84,7 +84,7 @@ type setupRequest struct { // @Failure 409 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /setup [post] -func (h *SetupHandler) PostSetup(c *gin.Context) { +func (h *Handler) PostSetup(c *gin.Context) { // Serialise concurrent setup requests to prevent TOCTOU race (OWASP A01). h.mu.Lock() defer h.mu.Unlock() diff --git a/backend/internal/handler/share/limiter_internal_test.go b/backend/internal/handler/share/limiter_internal_test.go new file mode 100644 index 0000000..c2e590a --- /dev/null +++ b/backend/internal/handler/share/limiter_internal_test.go @@ -0,0 +1,28 @@ +package share + +import ( + "testing" + "time" + + "github.com/openscanner/openscanner/internal/db" +) + +// TestShareLimiter_CleansUpStaleEntries verifies that getShareLimiter +// sweeps stale entries once the map grows past the threshold (>100). +func TestShareLimiter_CleansUpStaleEntries(t *testing.T) { + h := New(&db.Queries{}, nil) + + staleStart := time.Now().Add(-3 * rateWindowDuration) + for i := int64(1); i <= 101; i++ { + h.limiters[i] = &shareLimiter{ + windowStart: staleStart, + rateLimit: shareRatePerMin, + } + } + + _ = h.getShareLimiter(9999) + + if got := len(h.limiters); got != 1 { + t.Fatalf("post-sweep limiters size = %d, want 1", got) + } +} diff --git a/backend/internal/api/share.go b/backend/internal/handler/share/share.go similarity index 82% rename from backend/internal/api/share.go rename to backend/internal/handler/share/share.go index 0deb287..b30f6cf 100644 --- a/backend/internal/api/share.go +++ b/backend/internal/handler/share/share.go @@ -1,4 +1,5 @@ -package api +// Package share provides endpoints for shareable call links. +package share import ( "database/sql" @@ -10,13 +11,89 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/openscanner/openscanner/internal/audio" "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/shared" ) +const ( + rateWindowDuration = time.Minute + shareRatePerMin = 10 +) + +// shareLimiter is a per-user sliding-window rate limiter for share creation. +type shareLimiter struct { + mu sync.Mutex + windowStart time.Time + count int + rateLimit int +} + +func (l *shareLimiter) allow() bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + if now.Sub(l.windowStart) >= rateWindowDuration { + l.windowStart = now + l.count = 0 + } + if l.count >= l.rateLimit { + return false + } + l.count++ + return true +} + +// Handler handles shareable link endpoints. +type Handler struct { + queries *db.Queries + processor *audio.Processor + + mu sync.Mutex + limiters map[int64]*shareLimiter +} + +// New constructs a share Handler. +func New(queries *db.Queries, processor *audio.Processor) *Handler { + return &Handler{ + queries: queries, + processor: processor, + limiters: make(map[int64]*shareLimiter), + } +} + +func (h *Handler) getShareLimiter(userID int64) *shareLimiter { + h.mu.Lock() + defer h.mu.Unlock() + + if len(h.limiters) > 100 { + now := time.Now() + for id, l := range h.limiters { + l.mu.Lock() + stale := now.Sub(l.windowStart) >= 2*rateWindowDuration + l.mu.Unlock() + if stale { + delete(h.limiters, id) + } + } + } + + l, ok := h.limiters[userID] + if !ok { + l = &shareLimiter{ + windowStart: time.Now(), + rateLimit: shareRatePerMin, + } + h.limiters[userID] = l + } + return l +} + // ShareResponse is the JSON payload for a shared call viewed via token. type ShareResponse struct { Token string `json:"token"` @@ -54,10 +131,10 @@ type ShareCreateResponse struct { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /calls/{id}/share [post] -func (h *CallHandler) PostShareCall(c *gin.Context) { +func (h *Handler) PostShareCall(c *gin.Context) { ctx := c.Request.Context() - if h.getSettingValue(c, "shareableLinks") != "true" { + if shared.GetSettingValue(c, h.queries, "shareableLinks") != "true" { c.JSON(http.StatusForbidden, gin.H{"error": "sharing is disabled"}) return } @@ -105,7 +182,7 @@ func (h *CallHandler) PostShareCall(c *gin.Context) { // Enforce per-user grants — restricted listeners cannot share calls // outside their authorised scope. - if grants := h.loadUserGrants(c); !isGranted(grants, call.SystemID, call.TalkgroupID.Int64) { + if grants := shared.LoadUserGrants(c, h.queries); !shared.IsGranted(grants, call.SystemID, call.TalkgroupID.Int64) { c.JSON(http.StatusNotFound, gin.H{"error": "call not found"}) return } @@ -114,7 +191,7 @@ func (h *CallHandler) PostShareCall(c *gin.Context) { // Compute expires_at from the global sharedLinkExpiry setting (stored as days). var expiresAt sql.NullInt64 - if expStr := h.getSettingValue(c, "sharedLinkExpiry"); expStr != "" && expStr != "0" { + if expStr := shared.GetSettingValue(c, h.queries, "sharedLinkExpiry"); expStr != "" && expStr != "0" { if expDays, err := strconv.ParseInt(expStr, 10, 64); err == nil && expDays > 0 { expiresAt = sql.NullInt64{Int64: time.Now().Unix() + expDays*86400, Valid: true} } @@ -153,7 +230,7 @@ func (h *CallHandler) PostShareCall(c *gin.Context) { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /calls/{id}/share [delete] -func (h *CallHandler) DeleteShareCall(c *gin.Context) { +func (h *Handler) DeleteShareCall(c *gin.Context) { ctx := c.Request.Context() id, err := strconv.ParseInt(c.Param("id"), 10, 64) @@ -194,13 +271,13 @@ func (h *CallHandler) DeleteShareCall(c *gin.Context) { // isSharedLinkExpired checks if a shared link has expired. // It checks the explicit expires_at first, then falls back to the global // sharedLinkExpiry setting (days) applied to created_at. Returns false if no expiry is set. -func (h *CallHandler) isSharedLinkExpired(c *gin.Context, expiresAt sql.NullInt64, createdAt int64) bool { +func (h *Handler) isSharedLinkExpired(c *gin.Context, expiresAt sql.NullInt64, createdAt int64) bool { now := time.Now().Unix() if expiresAt.Valid && expiresAt.Int64 > 0 { return now > expiresAt.Int64 } // Fallback: global setting (days) applied to creation time. - if expStr := h.getSettingValue(c, "sharedLinkExpiry"); expStr != "" && expStr != "0" { + if expStr := shared.GetSettingValue(c, h.queries, "sharedLinkExpiry"); expStr != "" && expStr != "0" { if expDays, err := strconv.ParseInt(expStr, 10, 64); err == nil && expDays > 0 { return now > createdAt+expDays*86400 } @@ -221,7 +298,7 @@ func (h *CallHandler) isSharedLinkExpired(c *gin.Context, expiresAt sql.NullInt6 // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /shared/{token} [get] -func (h *CallHandler) GetSharedCallByToken(c *gin.Context) { +func (h *Handler) GetSharedCallByToken(c *gin.Context) { ctx := c.Request.Context() token := c.Param("token") @@ -280,7 +357,7 @@ func (h *CallHandler) GetSharedCallByToken(c *gin.Context) { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /shared/{token}/audio [get] -func (h *CallHandler) GetSharedCallAudio(c *gin.Context) { +func (h *Handler) GetSharedCallAudio(c *gin.Context) { token := c.Param("token") if token == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token required"}) @@ -349,7 +426,7 @@ func (h *CallHandler) GetSharedCallAudio(c *gin.Context) { filename = filepath.Base(sl.AudioPath) } - c.Header("Content-Disposition", contentDisposition("inline", filename)) + c.Header("Content-Disposition", shared.ContentDisposition("inline", filename)) c.Header("Content-Type", contentType) http.ServeContent(c.Writer, c.Request, filename, fi.ModTime(), f) } @@ -368,10 +445,10 @@ func (h *CallHandler) GetSharedCallAudio(c *gin.Context) { // @Failure 404 {object} ErrorResponse // @Failure 500 {object} ErrorResponse // @Router /calls/{id}/share [get] -func (h *CallHandler) GetCallShare(c *gin.Context) { +func (h *Handler) GetCallShare(c *gin.Context) { ctx := c.Request.Context() - if h.getSettingValue(c, "shareableLinks") != "true" { + if shared.GetSettingValue(c, h.queries, "shareableLinks") != "true" { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } diff --git a/backend/internal/handler/shared/call_search.go b/backend/internal/handler/shared/call_search.go new file mode 100644 index 0000000..19b807e --- /dev/null +++ b/backend/internal/handler/shared/call_search.go @@ -0,0 +1,34 @@ +package shared + +// CallSearchResult is a single call in the search response. +type CallSearchResult struct { + ID int64 `json:"id"` + AudioName string `json:"audioName"` + AudioType string `json:"audioType"` + DateTime int64 `json:"dateTime"` + SystemID int64 `json:"systemId"` + SystemLabel string `json:"systemLabel"` + TalkgroupID int64 `json:"talkgroupId"` + TalkgroupLabel string `json:"talkgroupLabel"` + TalkgroupName string `json:"talkgroupName"` + TalkgroupGroup string `json:"talkgroupGroup,omitempty"` + TalkgroupTag string `json:"talkgroupTag,omitempty"` + TalkgroupLed string `json:"talkgroupLed,omitempty"` + Frequency *int64 `json:"frequency,omitempty"` + Duration *int64 `json:"duration,omitempty"` + Source *int64 `json:"source,omitempty"` + Site string `json:"site,omitempty"` + Channel string `json:"channel,omitempty"` + Decoder string `json:"decoder,omitempty"` + ErrorCount *int64 `json:"errorCount,omitempty"` + SpikeCount *int64 `json:"spikeCount,omitempty"` + TalkerAlias string `json:"talkerAlias,omitempty"` + Transcript string `json:"transcript,omitempty"` + Bookmarked bool `json:"bookmarked"` +} // @name CallSearchResult + +// CallSearchResponse is the response for GET /api/calls. +type CallSearchResponse struct { + Calls []CallSearchResult `json:"calls"` + Total int64 `json:"total"` +} // @name CallSearchResponse diff --git a/backend/internal/api/content_disposition.go b/backend/internal/handler/shared/content_disposition.go similarity index 87% rename from backend/internal/api/content_disposition.go rename to backend/internal/handler/shared/content_disposition.go index 7afa39e..0cf70a1 100644 --- a/backend/internal/api/content_disposition.go +++ b/backend/internal/handler/shared/content_disposition.go @@ -1,4 +1,4 @@ -package api +package shared import ( "fmt" @@ -6,11 +6,11 @@ import ( "strings" ) -// contentDispositionAttachment builds an RFC 6266 Content-Disposition header +// ContentDisposition builds an RFC 6266 Content-Disposition header // value with both a legacy filename= token (ASCII-only, sanitised) and the // percent-encoded filename*=UTF-8'' token for non-ASCII / unsafe characters. // The caller supplies the disposition type (e.g. "inline" or "attachment"). -func contentDisposition(dispType, filename string) string { +func ContentDisposition(dispType, filename string) string { if filename == "" { filename = "file" } diff --git a/backend/internal/api/content_disposition_test.go b/backend/internal/handler/shared/content_disposition_test.go similarity index 97% rename from backend/internal/api/content_disposition_test.go rename to backend/internal/handler/shared/content_disposition_test.go index 1a840f2..4994af5 100644 --- a/backend/internal/api/content_disposition_test.go +++ b/backend/internal/handler/shared/content_disposition_test.go @@ -1,4 +1,4 @@ -package api +package shared import ( "strings" @@ -76,7 +76,7 @@ func TestContentDisposition(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := contentDisposition(tc.disposition, tc.filename) + got := ContentDisposition(tc.disposition, tc.filename) if !strings.HasPrefix(got, tc.disposition+"; ") { t.Errorf("header should begin with %q; got %q", tc.disposition+"; ", got) diff --git a/backend/internal/api/swagger_models.go b/backend/internal/handler/shared/dto.go similarity index 97% rename from backend/internal/api/swagger_models.go rename to backend/internal/handler/shared/dto.go index d9a9b22..129a642 100644 --- a/backend/internal/api/swagger_models.go +++ b/backend/internal/handler/shared/dto.go @@ -1,4 +1,5 @@ -package api +// Package shared contains DTOs and helpers reused across handler subpackages. +package shared // Swagger model types — clean mirrors of db types for Swagger documentation. // These are referenced via .swaggo replace directives and never used at runtime. @@ -123,6 +124,7 @@ type swagWebhook struct { //nolint:unused } // @name Webhook var ( + _ ErrorResponse _ swagGroup _ swagTag _ swagSetting diff --git a/backend/internal/handler/shared/grants.go b/backend/internal/handler/shared/grants.go new file mode 100644 index 0000000..5124df1 --- /dev/null +++ b/backend/internal/handler/shared/grants.go @@ -0,0 +1,69 @@ +package shared + +import ( + "encoding/json" + "log/slog" + + "github.com/gin-gonic/gin" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" +) + +// SystemGrant mirrors ws.systemGrant for grant-based filtering in REST handlers. +type SystemGrant struct { + ID int64 `json:"id"` + Talkgroups []int64 `json:"talkgroups,omitempty"` +} + +// LoadUserGrants returns the parsed grants for the authenticated user. Returns +// nil (allow-all) for admins, unauthenticated users, or users with no grants. +func LoadUserGrants(c *gin.Context, queries *db.Queries) []SystemGrant { + role, _ := c.Get("role") + roleStr, _ := role.(string) + if roleStr == auth.RoleAdmin { + return nil + } + userIDVal, exists := c.Get("userID") + if !exists { + return nil + } + uid, _ := userIDVal.(int64) + user, err := queries.GetUser(c.Request.Context(), uid) + if err != nil { + return nil + } + if !user.SystemsJson.Valid || user.SystemsJson.String == "" { + return nil + } + var grants []SystemGrant + if err := json.Unmarshal([]byte(user.SystemsJson.String), &grants); err != nil { + slog.Warn("failed to parse user grants", "user_id", uid, "error", err) + return nil + } + if len(grants) == 0 { + return nil + } + return grants +} + +// IsGranted checks whether a call with the given system/talkgroup passes the +// grant filter. A nil grant list means everything is allowed. +func IsGranted(grants []SystemGrant, systemID, talkgroupID int64) bool { + if grants == nil { + return true + } + for _, g := range grants { + if g.ID != systemID { + continue + } + if len(g.Talkgroups) == 0 { + return true + } + for _, tg := range g.Talkgroups { + if tg == talkgroupID { + return true + } + } + } + return false +} diff --git a/backend/internal/handler/shared/resolve.go b/backend/internal/handler/shared/resolve.go new file mode 100644 index 0000000..1c26f9e --- /dev/null +++ b/backend/internal/handler/shared/resolve.go @@ -0,0 +1,52 @@ +package shared + +import ( + "context" + "database/sql" + "errors" + "log/slog" + + "github.com/openscanner/openscanner/internal/db" +) + +// ResolveGroupID looks up an existing group by label or creates one if it +// doesn't exist. Returns a valid sql.NullInt64 with the group's DB ID, or +// an invalid NullInt64 if the operation fails. +func ResolveGroupID(ctx context.Context, q db.Querier, label string) sql.NullInt64 { + g, err := q.GetGroupByLabel(ctx, label) + if err == nil { + return sql.NullInt64{Int64: g.ID, Valid: true} + } + if !errors.Is(err, sql.ErrNoRows) { + slog.Warn("failed to look up group by label", "label", label, "error", err) + return sql.NullInt64{} + } + newID, cerr := q.CreateGroup(ctx, label) + if cerr != nil { + slog.Warn("failed to auto-create group", "label", label, "error", cerr) + return sql.NullInt64{} + } + slog.Info("auto-populated group from upload", "label", label, "db_id", newID) + return sql.NullInt64{Int64: newID, Valid: true} +} + +// ResolveTagID looks up an existing tag by label or creates one if it +// doesn't exist. Returns a valid sql.NullInt64 with the tag's DB ID, or +// an invalid NullInt64 if the operation fails. +func ResolveTagID(ctx context.Context, q db.Querier, label string) sql.NullInt64 { + t, err := q.GetTagByLabel(ctx, label) + if err == nil { + return sql.NullInt64{Int64: t.ID, Valid: true} + } + if !errors.Is(err, sql.ErrNoRows) { + slog.Warn("failed to look up tag by label", "label", label, "error", err) + return sql.NullInt64{} + } + newID, cerr := q.CreateTag(ctx, label) + if cerr != nil { + slog.Warn("failed to auto-create tag", "label", label, "error", cerr) + return sql.NullInt64{} + } + slog.Info("auto-populated tag from upload", "label", label, "db_id", newID) + return sql.NullInt64{Int64: newID, Valid: true} +} diff --git a/backend/internal/handler/shared/settings.go b/backend/internal/handler/shared/settings.go new file mode 100644 index 0000000..7909440 --- /dev/null +++ b/backend/internal/handler/shared/settings.go @@ -0,0 +1,18 @@ +package shared + +import ( + "github.com/gin-gonic/gin" + "github.com/openscanner/openscanner/internal/db" +) + +// GetSettingValue fetches a setting value from the DB, returning "" on error. +func GetSettingValue(c *gin.Context, queries *db.Queries, key string) string { + s, err := queries.GetSetting(c.Request.Context(), key) + if err != nil { + return "" + } + return s.Value +} + +// MaxImportRows is the CSV import safety limit. +const MaxImportRows = 100_000