diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f5dcb8..61d2715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. +- Backend file-level cleanup: `internal/handler/calls/calls.go` (~1500 LOC) + split into `upload.go`, `audio.go`, `search.go`, `transcript.go`, and a + slim `calls.go` retaining the `Handler` struct and constructor; + `internal/middleware/middleware.go` split into `cors.go`, `auth.go`, + `logging.go`, `limits.go`. Same package, same exports, no behaviour + change. - 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/internal/handler/calls/audio.go b/backend/internal/handler/calls/audio.go new file mode 100644 index 0000000..de088c5 --- /dev/null +++ b/backend/internal/handler/calls/audio.go @@ -0,0 +1,113 @@ +package calls + +import ( + "database/sql" + "errors" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/openscanner/openscanner/internal/handler/shared" +) + +// GetCallAudio handles GET /api/calls/:id/audio. +// +// @Summary Get call audio file +// @Description Stream the audio file for a specific call. Authentication is optional when the publicAccess setting is enabled; otherwise a valid JWT is required. +// @Tags Calls +// @Security BearerAuth +// @Produce application/octet-stream +// @Param id path int true "Call ID" +// @Success 200 {file} binary "Audio file" +// @Failure 400 {object} ErrorResponse "Invalid call ID" +// @Failure 401 {object} ErrorResponse "Authentication required" +// @Failure 404 {object} ErrorResponse "Call or audio not found" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /calls/{id}/audio [get] +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 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid call id"}) + return + } + + // 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 && shared.GetSettingValue(c, h.queries, "publicAccess") != "true" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + call, err := h.queries.GetCall(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "call not found"}) + return + } + slog.Error("failed to get call audio metadata", "id", id, "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + + // Enforce per-user grants for non-admin listeners. + 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 + } + + recordingsDir := h.processor.RecordingsDir() + relPath := filepath.Clean(call.AudioPath) + if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { + slog.Warn("rejected unsafe audio path", "id", id, "path", call.AudioPath) + c.JSON(http.StatusNotFound, gin.H{"error": "audio not found"}) + return + } + + // Open the file scoped to recordingsDir via os.Root so traversal and + // symlink escapes are impossible regardless of what's in the DB row. + root, err := os.OpenRoot(recordingsDir) + if err != nil { + slog.Error("failed to open recordings root", "id", id, "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + defer root.Close() + + f, err := root.Open(relPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + c.JSON(http.StatusNotFound, gin.H{"error": "audio file not found"}) + return + } + slog.Error("failed to open call audio file", "id", id, "path", relPath, "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + slog.Error("failed to stat call audio file", "id", id, "path", relPath, "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + + contentType := call.AudioType + if contentType == "" { + contentType = "application/octet-stream" + } + filename := call.AudioName + if filename == "" { + filename = "call" + } + + c.Header("Content-Disposition", shared.ContentDisposition("inline", filename)) + c.Header("Content-Type", contentType) + http.ServeContent(c.Writer, c.Request, filename, fi.ModTime(), f) +} diff --git a/backend/internal/handler/calls/calls.go b/backend/internal/handler/calls/calls.go index 035a3e5..3db1129 100644 --- a/backend/internal/handler/calls/calls.go +++ b/backend/internal/handler/calls/calls.go @@ -3,25 +3,12 @@ package calls import ( - "context" - "database/sql" - "encoding/json" - "errors" - "io" - "log/slog" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" "sync" "time" - "github.com/gin-gonic/gin" "github.com/openscanner/openscanner/internal/audio" "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" ) @@ -115,1404 +102,3 @@ func (h *Handler) getLimiter(apiKeyID int64, rateLimit int) *apiKeyLimiter { } return l } - -// PostCallUpload handles POST /api/call-upload and /api/trunk-recorder-call-upload. -// -// @Summary Upload a call recording -// @Description Ingest a radio call with audio and metadata. Requires a valid API key. -// @Tags Upload -// @Accept multipart/form-data -// @Produce json -// @Security APIKeyAuth -// @Param audio formData file true "Audio file" -// @Param dateTime formData int true "Unix timestamp of the call" -// @Param systemId formData int true "Radio system ID" -// @Param talkgroupId formData int true "Talkgroup ID" -// @Param source formData int false "Source unit ID" -// @Param frequency formData int false "Frequency in Hz" -// @Param duration formData number false "Call duration in seconds" -// @Param talkgroupLabel formData string false "Talkgroup label for auto-populate" -// @Param talkgroupTag formData string false "Talkgroup tag name" -// @Param talkgroupGroup formData string false "Talkgroup group name" -// @Param talkgroupName formData string false "Talkgroup display name" -// @Param systemLabel formData string false "System label" -// @Param patches formData string false "JSON array of patched talkgroup IDs" -// @Param audioName formData string false "Original audio file name" -// @Param audioType formData string false "Audio MIME type" -// @Param site formData string false "Site identifier" -// @Param channel formData string false "Channel identifier" -// @Param decoder formData string false "Decoder software name" -// @Param errorCount formData int false "Decoding error count" -// @Param spikeCount formData int false "Signal spike count" -// @Success 200 {object} object{id=int64} "Call ingested successfully" -// @Failure 400 {object} ErrorResponse "Bad request" -// @Failure 401 {object} ErrorResponse "API key required" -// @Failure 429 {object} ErrorResponse "Rate limit exceeded" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /call-upload [post] -// @Router /trunk-recorder-call-upload [post] -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") - if !exists { - c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"}) - return - } - apiKeyID, ok := apiKeyIDVal.(int64) - if !ok { - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - - // Per-API-key rate limiting. - rateLimit := defaultCallRatePerMin - apiKeyRateOverride := false - if apiKeyRateVal, ok := c.Get("apiKeyCallRate"); ok { - if apiKeyRate, ok := apiKeyRateVal.(int64); ok && apiKeyRate > 0 { - rateLimit = int(apiKeyRate) - apiKeyRateOverride = true - } - } - if rStr := shared.GetSettingValue(c, h.queries, "apiKeyCallRate"); rStr != "" { - if r, err := strconv.Atoi(rStr); err == nil && r > 0 && !apiKeyRateOverride { - rateLimit = r - } - } - if rateLimit > maxCallRatePerMin { - rateLimit = maxCallRatePerMin - } - if !h.getLimiter(apiKeyID, rateLimit).allow() { - slog.Warn("call upload rate limit exceeded", "api_key_id", apiKeyID) - c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"}) - return - } - - slog.Debug("call-upload: rate limit passed", "api_key_id", apiKeyID) - - // SDRTrunk and other rdio-scanner-compatible clients may send a POST with - // partial data to verify the API key. rdio-scanner responds with plain-text - // "Incomplete call data: " (status 417) which SDRTrunk treats as a - // successful connection test. We replicate that behavior: parse all fields - // first, then return the same message format for missing required fields. - dateTimeStr := c.PostForm("dateTime") - systemIDStr := c.PostForm("systemId") - if systemIDStr == "" { - systemIDStr = c.PostForm("system") - } - talkgroupIDStr := c.PostForm("talkgroupId") - if talkgroupIDStr == "" { - talkgroupIDStr = c.PostForm("talkgroup") - } - _, audioErr := c.FormFile("audio") - - // Check for test=1 explicitly (Trunk Recorder). - if c.PostForm("test") == "1" { - c.String(http.StatusOK, "Incomplete call data: no talkgroup\n") - return - } - - // rdio-scanner's IsValid() checks all fields WITHOUT early returns and - // overwrites the error each time, so the LAST failing check wins. - // SDRTrunk sends system= but no audio/dateTime/talkgroup, so the last - // error is always "no talkgroup" — which SDRTrunk explicitly checks for. - // We replicate this behavior: collect the last error, then return it. - var incompleteReason string - if audioErr != nil { - incompleteReason = "no audio" - } - if dateTimeStr == "" { - incompleteReason = "no datetime" - } - if systemIDStr == "" { - incompleteReason = "no system" - } - if talkgroupIDStr == "" { - incompleteReason = "no talkgroup" - } - if incompleteReason != "" { - slog.Warn("call-upload: incomplete data", - "reason", incompleteReason, - "api_key_id", apiKeyID, - ) - c.String(http.StatusExpectationFailed, "Incomplete call data: %s\n", incompleteReason) - return - } - - // Parse dateTime. - // Try unix timestamp first (Trunk Recorder, SDRTrunk), then ISO 8601 (voxcall). - var dateTimeUnix int64 - if n, err := strconv.ParseInt(dateTimeStr, 10, 64); err == nil { - dateTimeUnix = n - } else if t, err := time.Parse(time.RFC3339Nano, dateTimeStr); err == nil { - dateTimeUnix = t.Unix() - } else if t, err := time.Parse(time.RFC3339, dateTimeStr); err == nil { - dateTimeUnix = t.Unix() - } else { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid dateTime: expected unix timestamp or ISO 8601"}) - return - } - callTime := time.Unix(dateTimeUnix, 0) - - // Trunk Recorder's rdioscanner_uploader plugin sends "system" and - // "talkgroup" while our canonical field names are "systemId" and - // "talkgroupId". Accept both for backward compatibility. - // (Already parsed above for the connectivity check.) - systemIDRaw, err := strconv.ParseInt(systemIDStr, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid systemId"}) - return - } - - talkgroupIDRaw, err := strconv.ParseInt(talkgroupIDStr, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid talkgroupId"}) - return - } - - // Parse optional fields. - var frequency, duration, source sql.NullInt64 - if v := c.PostForm("frequency"); v != "" { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - frequency = sql.NullInt64{Int64: n, Valid: true} - } - } - if v := c.PostForm("duration"); v != "" { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - duration = sql.NullInt64{Int64: n, Valid: true} - } - } - if v := c.PostForm("source"); v != "" { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - source = sql.NullInt64{Int64: n, Valid: true} - } - } - - var errorCount, spikeCount sql.NullInt64 - if v := c.PostForm("errorCount"); v != "" { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - errorCount = sql.NullInt64{Int64: n, Valid: true} - } - } - if v := c.PostForm("spikeCount"); v != "" { - if n, err := strconv.ParseInt(v, 10, 64); err == nil { - spikeCount = sql.NullInt64{Int64: n, Valid: true} - } - } - - var sourcesJSON, frequenciesJSON, patchesJSON sql.NullString - if v := c.PostForm("sources"); v != "" { - sourcesJSON = sql.NullString{String: v, Valid: true} - } - if v := c.PostForm("frequencies"); v != "" { - frequenciesJSON = sql.NullString{String: v, Valid: true} - } - if v := c.PostForm("patches"); v != "" { - patchesJSON = sql.NullString{String: v, Valid: true} - } - - // Trunk-recorder's rdio-scanner uploader embeds unit IDs inside the - // "sources" JSON array rather than sending a top-level "source" field. - // Extract the first source unit ID when not explicitly provided. - if !source.Valid && sourcesJSON.Valid { - source = extractPrimarySource(sourcesJSON.String) - } - - // Similarly, error and spike counts are per-segment inside the - // "frequencies" JSON array. Aggregate them when no top-level values - // were provided. - if !errorCount.Valid && !spikeCount.Valid && frequenciesJSON.Valid { - errorCount, spikeCount = aggregateErrorSpikeCounts(frequenciesJSON.String) - } - - // Optional call metadata fields. - var siteCol, channelCol, decoderCol sql.NullString - if v := c.PostForm("site"); v != "" { - siteCol = sql.NullString{String: v, Valid: true} - } - if v := c.PostForm("channel"); v != "" { - channelCol = sql.NullString{String: v, Valid: true} - } - if v := c.PostForm("decoder"); v != "" { - decoderCol = sql.NullString{String: v, Valid: true} - } - - // Optional talkgroup metadata for auto-populate / backfill. - talkgroupLabel := c.PostForm("talkgroupLabel") - talkgroupTag := c.PostForm("talkgroupTag") - talkgroupGroup := c.PostForm("talkgroupGroup") - talkgroupName := c.PostForm("talkgroupName") - - var talkerAliasCol sql.NullString - if v := c.PostForm("talkerAlias"); v != "" { - talkerAliasCol = sql.NullString{String: v, Valid: true} - } - - // Trunk-recorder embeds OTA aliases in the sources JSON "tag" field - // rather than sending a top-level "talkerAlias". Extract from the - // first source entry when not explicitly provided. - if !talkerAliasCol.Valid && sourcesJSON.Valid { - talkerAliasCol = extractPrimarySourceTag(sourcesJSON.String) - } - - ctx := c.Request.Context() - autoPopulateSystems := shared.GetSettingValue(c, h.queries, "autoPopulateSystems") == "true" - - slog.Debug("call-upload: resolving system and talkgroup", - "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) - - // Resolve system by its radio system_id. - system, err := h.queries.GetSystemBySystemID(ctx, systemIDRaw) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - slog.Error("failed to query system", "system_id", systemIDRaw, "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - if !autoPopulateSystems { - c.JSON(http.StatusBadRequest, gin.H{"error": "system not found"}) - return - } - label := strconv.FormatInt(systemIDRaw, 10) - // SDRTrunk and other uploaders send systemLabel with a human-readable name. - if sl := c.PostForm("systemLabel"); sl != "" { - label = sl - } - newID, cerr := h.queries.CreateSystem(ctx, db.CreateSystemParams{ - SystemID: systemIDRaw, - Label: label, - AutoPopulateTalkgroups: 1, - }) - if cerr != nil { - slog.Error("failed to auto-create system", "system_id", systemIDRaw, "error", cerr) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - slog.Info("auto-populated system", "system_id", systemIDRaw, "label", label, "db_id", newID) - system = db.System{ID: newID, SystemID: systemIDRaw, Label: label, AutoPopulateTalkgroups: 1} - h.hub.BroadcastCFG(ctx) - } - - // Blacklist check: reject calls to blacklisted talkgroups. - if isBlacklistedTG(system.BlacklistsJson, talkgroupIDRaw) { - slog.Info("call upload: talkgroup is blacklisted", - "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) - c.JSON(http.StatusOK, gin.H{"message": "blacklisted"}) - return - } - - // Resolve talkgroup by system DB ID + radio talkgroup ID. - talkgroup, err := h.queries.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ - SystemID: system.ID, - TalkgroupID: talkgroupIDRaw, - }) - if err != nil { - if !errors.Is(err, sql.ErrNoRows) { - slog.Error("failed to query talkgroup", "system_id", system.ID, "talkgroup_id", talkgroupIDRaw, "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - if system.AutoPopulateTalkgroups == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "talkgroup not found"}) - return - } - var tgLabel, tgName sql.NullString - if talkgroupLabel != "" { - tgLabel = sql.NullString{String: talkgroupLabel, Valid: true} - } - if talkgroupName != "" { - tgName = sql.NullString{String: talkgroupName, Valid: true} - } - // Resolve group from talkgroupGroup (e.g. SDRTrunk sends this). - var groupID sql.NullInt64 - if 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 = shared.ResolveTagID(ctx, h.queries, talkgroupTag) - } - newID, cerr := h.queries.CreateTalkgroup(ctx, db.CreateTalkgroupParams{ - SystemID: system.ID, - TalkgroupID: talkgroupIDRaw, - Label: tgLabel, - Name: tgName, - GroupID: groupID, - TagID: tagID, - }) - if cerr != nil { - slog.Error("failed to auto-create talkgroup", "talkgroup_id", talkgroupIDRaw, "error", cerr) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - slog.Info("auto-populated talkgroup", "system_id", system.SystemID, "talkgroup_id", talkgroupIDRaw, "label", tgLabel.String, "db_id", newID) - talkgroup = db.Talkgroup{ID: newID, SystemID: system.ID, TalkgroupID: talkgroupIDRaw, Label: tgLabel, Name: tgName, GroupID: groupID, TagID: tagID} - h.hub.BroadcastCFG(ctx) - } else if needsBackfill(talkgroup, talkgroupLabel, talkgroupName, talkgroupTag, talkgroupGroup) { - // Existing talkgroup has empty fields — backfill from upload metadata. - if !talkgroup.Label.Valid && talkgroupLabel != "" { - talkgroup.Label = sql.NullString{String: talkgroupLabel, Valid: true} - } - if !talkgroup.Name.Valid && talkgroupName != "" { - talkgroup.Name = sql.NullString{String: talkgroupName, Valid: true} - } - if !talkgroup.GroupID.Valid && talkgroupGroup != "" { - talkgroup.GroupID = shared.ResolveGroupID(ctx, h.queries, talkgroupGroup) - } - if !talkgroup.TagID.Valid && talkgroupTag != "" { - talkgroup.TagID = shared.ResolveTagID(ctx, h.queries, talkgroupTag) - } - if uerr := h.queries.UpdateTalkgroup(ctx, db.UpdateTalkgroupParams{ - ID: talkgroup.ID, - TalkgroupID: talkgroup.TalkgroupID, - Label: talkgroup.Label, - Name: talkgroup.Name, - Frequency: talkgroup.Frequency, - Led: talkgroup.Led, - GroupID: talkgroup.GroupID, - TagID: talkgroup.TagID, - Order: talkgroup.Order, - }); uerr != nil { - slog.Warn("failed to backfill talkgroup from upload", - "talkgroup_id", talkgroup.TalkgroupID, "error", uerr) - } else { - slog.Info("backfilled talkgroup from upload", - "talkgroup_id", talkgroup.TalkgroupID) - h.hub.BroadcastCFG(ctx) - } - } - - // Duplicate detection (system.ID and talkgroup.ID are the FK values in calls). - if shared.GetSettingValue(c, h.queries, "disableDuplicateDetection") != "true" { - windowMs := int64(2000) - if v := shared.GetSettingValue(c, h.queries, "duplicateDetectionTimeFrame"); v != "" { - if wm, err := strconv.ParseInt(v, 10, 64); err == nil { - windowMs = wm - } - } - dup, derr := audio.IsDuplicate(ctx, h.queries, system.ID, talkgroup.ID, callTime, windowMs) - if derr != nil { - slog.Error("duplicate detection failed", "error", derr) - // Non-fatal: proceed with ingest. - } else if dup { - slog.Info("duplicate call rejected", "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) - c.JSON(http.StatusOK, gin.H{"message": "duplicate call rejected"}) - return - } - } - - // Get uploaded audio file. - fh, err := c.FormFile("audio") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "audio file is required"}) - return - } - - // Resolve audio conversion mode from settings. - convMode := audio.ConversionDisabled - 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(shared.GetSettingValue(c, h.queries, "audioEncodingPreset")) - - // Store audio file (conversion handled inside Processor.Store). - relPath, err := h.processor.Store(ctx, fh, convMode, convPreset) - if err != nil { - slog.Error("failed to store audio file", - "system_id", systemIDRaw, - "talkgroup_id", talkgroupIDRaw, - "error", err, - ) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store audio"}) - return - } - - slog.Debug("call-upload: audio stored", "path", relPath, "mode", convMode) - - // If the recorder didn't supply a duration, probe the stored file. - if !duration.Valid { - absPath := filepath.Join(h.processor.RecordingsDir(), relPath) - if d := audio.ProbeDuration(ctx, absPath); d > 0 { - duration = sql.NullInt64{Int64: d, Valid: true} - } - } - - // Determine audio MIME type. - // When conversion is enabled the output format depends on the encoding - // preset (M4A for AAC presets, MP3 for MP3 presets). - // Otherwise validate the client-supplied Content-Type against an allowlist - // to prevent attacker-controlled MIME types from reaching the database. - var audioType string - if convMode != audio.ConversionDisabled { - audioType = audio.OutputMIME(convPreset) - } else { - switch fh.Header.Get("Content-Type") { - case "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", - "audio/ogg", "audio/aac", "audio/m4a", "audio/mp4", - "audio/x-m4a", "audio/opus": - audioType = fh.Header.Get("Content-Type") - default: - audioType = "application/octet-stream" - } - } - - // Insert call record. - callID, err := h.queries.CreateCall(ctx, db.CreateCallParams{ - AudioPath: relPath, - AudioName: filepath.Base(relPath), - AudioType: audioType, - DateTime: dateTimeUnix, - Frequency: frequency, - Duration: duration, - Source: source, - SourcesJson: sourcesJSON, - FrequenciesJson: frequenciesJSON, - PatchesJson: patchesJSON, - SystemID: system.ID, - TalkgroupID: sql.NullInt64{Int64: talkgroup.ID, Valid: true}, - Site: siteCol, - Channel: channelCol, - Decoder: decoderCol, - ErrorCount: errorCount, - SpikeCount: spikeCount, - TalkerAlias: talkerAliasCol, - }) - if err != nil { - slog.Error("failed to insert call", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - - slog.Debug("call-upload: db record inserted", - "call_id", callID, - "system_id", systemIDRaw, - "talkgroup_id", talkgroupIDRaw, - "audio_path", relPath, - ) - - // Extract unit tags from sources JSON and upsert into units table. - // Sources format: [{"pos":0,"src":12345,"tag":"Unit Name"}, ...] - if sourcesJSON.Valid { - upsertUnitsFromSources(ctx, h.queries, system.ID, sourcesJSON.String) - } - - // Map talkerAlias to the source unit as a label (e.g. P25 radios broadcasting a name). - if source.Valid && talkerAliasCol.Valid { - if err := h.queries.UpsertUnit(ctx, db.UpsertUnitParams{ - SystemID: system.ID, - UnitID: source.Int64, - Label: sql.NullString{String: talkerAliasCol.String, Valid: true}, - }); err != nil { - slog.Warn("failed to upsert unit from talkerAlias", - "unit_id", source.Int64, "talkerAlias", talkerAliasCol.String, "error", err) - } - } - - // Broadcast to WebSocket listeners. - if h.hub != nil { - // Read audio file for inline embedding in the CAL JSON frame. - // Use os.Root so the read is scoped to RecordingsDir and cannot - // follow a traversal sequence or symlink out of the directory, - // regardless of what relPath contains. - const maxBroadcastAudioBytes = 20 << 20 // 20 MiB - var audioBytes []byte - if root, rootErr := os.OpenRoot(h.processor.RecordingsDir()); rootErr != nil { - slog.Warn("failed to open recordings root for WS broadcast", "error", rootErr) - } else { - if fi, statErr := root.Stat(relPath); statErr != nil { - slog.Warn("failed to stat audio for WS broadcast", "path", relPath, "error", statErr) - } else if fi.Size() > maxBroadcastAudioBytes { - slog.Warn("audio file too large for inline WS broadcast, sending metadata only", - "path", relPath, "size_bytes", fi.Size(), "max_bytes", maxBroadcastAudioBytes) - } else if f, openErr := root.Open(relPath); openErr != nil { - slog.Warn("failed to open audio for WS broadcast", "path", relPath, "error", openErr) - } else { - readBytes, readErr := io.ReadAll(io.LimitReader(f, maxBroadcastAudioBytes)) - f.Close() - if readErr != nil { - slog.Warn("failed to read audio for WS broadcast", "path", relPath, "error", readErr) - } else { - audioBytes = readBytes - } - } - root.Close() - } - - calPayload := map[string]any{ - "id": callID, - "audioName": filepath.Base(relPath), - "audioType": audioType, - "dateTime": dateTimeUnix, - "systemId": system.SystemID, - "system": system.ID, - "talkgroupId": talkgroup.TalkgroupID, - "talkgroup": talkgroup.ID, - } - if frequency.Valid { - calPayload["frequency"] = frequency.Int64 - } - if duration.Valid { - calPayload["duration"] = duration.Int64 - } - if source.Valid { - calPayload["source"] = source.Int64 - } - if siteCol.Valid { - calPayload["site"] = siteCol.String - } - if channelCol.Valid { - calPayload["channel"] = channelCol.String - } - if decoderCol.Valid { - calPayload["decoder"] = decoderCol.String - } - if errorCount.Valid { - calPayload["errorCount"] = errorCount.Int64 - } - if spikeCount.Valid { - calPayload["spikeCount"] = spikeCount.Int64 - } - if talkerAliasCol.Valid { - calPayload["talkerAlias"] = talkerAliasCol.String - } - if sourcesJSON.Valid { - calPayload["sources"] = sourcesJSON.String - } - if frequenciesJSON.Valid { - calPayload["frequencies"] = frequenciesJSON.String - } - calMsg, err := ws.NewCALMessage(calPayload, audioBytes) - if err != nil { - slog.Error("failed to build CAL message", "error", err) - } else { - h.hub.BroadcastCAL(calMsg, func(cl *ws.Client) bool { - return cl.CanReceive(system.ID, talkgroup.ID) - }) - slog.Debug("call-upload: ws broadcast sent", "call_id", callID) - } - } - - logAttrs := []any{ - "call_id", callID, - "system_id", systemIDRaw, - "talkgroup_id", talkgroupIDRaw, - "audio_path", relPath, - "api_key_id", apiKeyID, - } - if duration.Valid { - logAttrs = append(logAttrs, "duration_ms", duration.Int64) - } - slog.Info("call-upload: complete", logAttrs...) - - c.JSON(http.StatusOK, gin.H{"id": callID, "message": "Call imported successfully."}) - - // Notify downstream pushers (non-blocking, after response is sent). - if h.dsNotifier != nil { - // Resolve labels for downstream consumers. - var groupLabel, tagLabel string - if talkgroup.GroupID.Valid { - if g, err := h.queries.GetGroup(ctx, talkgroup.GroupID.Int64); err == nil { - groupLabel = g.Label - } - } - if talkgroup.TagID.Valid { - if t, err := h.queries.GetTag(ctx, talkgroup.TagID.Int64); err == nil { - tagLabel = t.Label - } - } - - h.dsNotifier.Notify(downstream.CallEvent{ - CallID: callID, - AudioPath: relPath, - AudioName: filepath.Base(relPath), - AudioType: audioType, - DateTime: dateTimeUnix, - SystemID: system.SystemID, - System: system.ID, - TalkgroupID: talkgroup.TalkgroupID, - Talkgroup: talkgroup.ID, - Frequency: frequency.Int64, - Duration: duration.Int64, - Source: source.Int64, - Sources: sourcesJSON.String, - Frequencies: frequenciesJSON.String, - Patches: patchesJSON.String, - SystemLabel: system.Label, - TalkgroupLabel: talkgroup.Label.String, - TalkgroupName: talkgroup.Name.String, - TalkgroupGroup: groupLabel, - TalkgroupTag: tagLabel, - TalkerAlias: talkerAliasCol.String, - }) - slog.Debug("call-upload: downstream notify queued", "call_id", callID) - } - - // Enqueue transcription (non-blocking, after response is sent). - if h.transcriber != nil { - absPath := filepath.Join(h.processor.RecordingsDir(), relPath) - if err := h.transcriber.Submit(ctx, audio.TranscriptionJob{ - CallID: callID, - AudioPath: absPath, - }); err != nil { - slog.Warn("call-upload: failed to enqueue transcription", "call_id", callID, "error", err) - } - } -} - -// GetCallAudio handles GET /api/calls/:id/audio. -// -// @Summary Get call audio file -// @Description Stream the audio file for a specific call. Authentication is optional when the publicAccess setting is enabled; otherwise a valid JWT is required. -// @Tags Calls -// @Security BearerAuth -// @Produce application/octet-stream -// @Param id path int true "Call ID" -// @Success 200 {file} binary "Audio file" -// @Failure 400 {object} ErrorResponse "Invalid call ID" -// @Failure 401 {object} ErrorResponse "Authentication required" -// @Failure 404 {object} ErrorResponse "Call or audio not found" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /calls/{id}/audio [get] -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 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid call id"}) - return - } - - // 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 && shared.GetSettingValue(c, h.queries, "publicAccess") != "true" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) - return - } - - call, err := h.queries.GetCall(ctx, id) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - c.JSON(http.StatusNotFound, gin.H{"error": "call not found"}) - return - } - slog.Error("failed to get call audio metadata", "id", id, "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - - // Enforce per-user grants for non-admin listeners. - 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 - } - - recordingsDir := h.processor.RecordingsDir() - relPath := filepath.Clean(call.AudioPath) - if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { - slog.Warn("rejected unsafe audio path", "id", id, "path", call.AudioPath) - c.JSON(http.StatusNotFound, gin.H{"error": "audio not found"}) - return - } - - // Open the file scoped to recordingsDir via os.Root so traversal and - // symlink escapes are impossible regardless of what's in the DB row. - root, err := os.OpenRoot(recordingsDir) - if err != nil { - slog.Error("failed to open recordings root", "id", id, "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - defer root.Close() - - f, err := root.Open(relPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - c.JSON(http.StatusNotFound, gin.H{"error": "audio file not found"}) - return - } - slog.Error("failed to open call audio file", "id", id, "path", relPath, "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - defer f.Close() - - fi, err := f.Stat() - if err != nil { - slog.Error("failed to stat call audio file", "id", id, "path", relPath, "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - - contentType := call.AudioType - if contentType == "" { - contentType = "application/octet-stream" - } - filename := call.AudioName - if filename == "" { - filename = "call" - } - - c.Header("Content-Disposition", shared.ContentDisposition("inline", filename)) - c.Header("Content-Type", contentType) - http.ServeContent(c.Writer, c.Request, filename, fi.ModTime(), f) -} - -// GetCalls handles GET /api/calls — paginated call archive search. -// -// @Summary Search calls -// @Description Paginated search of the call archive with optional filters. Authentication is optional when the publicAccess setting is enabled; otherwise a valid JWT is required. -// @Tags Calls -// @Security BearerAuth -// @Produce json -// @Param system_ids query string false "CSV system DB IDs (e.g. 1,2,3)" -// @Param talkgroup_ids query string false "CSV talkgroup DB IDs (e.g. 10,11)" -// @Param groups query string false "CSV group labels (e.g. Police,Fire)" -// @Param tags query string false "CSV tag labels (e.g. Law,EMS)" -// @Param system_id query int false "Legacy single system DB ID" -// @Param talkgroup_id query int false "Legacy single talkgroup DB ID" -// @Param group query string false "Legacy single group label" -// @Param tag query string false "Legacy single tag label" -// @Param date_from query int false "Unix timestamp lower bound" -// @Param date_to query int false "Unix timestamp upper bound" -// @Param sort query string false "Sort order: asc or desc" Enums(asc, desc) default(desc) -// @Param page query int false "Page number (1-based)" default(1) -// @Param limit query int false "Results per page (max 100)" default(25) -// @Param bookmarked_only query bool false "Show only bookmarked calls" -// @Param transcript query string false "Filter by transcript text (partial match)" -// @Success 200 {object} CallSearchResponse "Paginated call results" -// @Failure 400 {object} ErrorResponse "Invalid query parameter" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /calls [get] -func (h *Handler) GetCalls(c *gin.Context) { - ctx := c.Request.Context() - - parseCSVInt64 := func(raw string) ([]int64, error) { - if strings.TrimSpace(raw) == "" { - return nil, nil - } - parts := strings.Split(raw, ",") - vals := make([]int64, 0, len(parts)) - seen := make(map[int64]struct{}) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - n, err := strconv.ParseInt(part, 10, 64) - if err != nil { - return nil, err - } - if _, ok := seen[n]; ok { - continue - } - seen[n] = struct{}{} - vals = append(vals, n) - } - if len(vals) == 0 { - return nil, nil - } - return vals, nil - } - - parseCSVStrings := func(raw string) []string { - if strings.TrimSpace(raw) == "" { - return nil - } - parts := strings.Split(raw, ",") - vals := make([]string, 0, len(parts)) - seen := make(map[string]struct{}) - for _, part := range parts { - v := strings.TrimSpace(part) - if v == "" { - continue - } - if _, ok := seen[v]; ok { - continue - } - seen[v] = struct{}{} - vals = append(vals, v) - } - if len(vals) == 0 { - return nil - } - return vals - } - - toCSVFilter := func(vals []int64) interface{} { - if len(vals) == 0 { - return nil - } - parts := make([]string, 0, len(vals)) - for _, v := range vals { - parts = append(parts, strconv.FormatInt(v, 10)) - } - return strings.Join(parts, ",") - } - - // Parse multi-select IDs (new CSV params) with single-select fallback. - rawSystemIDs := c.Query("system_ids") - if rawSystemIDs == "" { - rawSystemIDs = c.Query("system_id") - } - rawTalkgroupIDs := c.Query("talkgroup_ids") - if rawTalkgroupIDs == "" { - rawTalkgroupIDs = c.Query("talkgroup_id") - } - - systemIDs, err := parseCSVInt64(rawSystemIDs) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid system_ids"}) - return - } - talkgroupIDs, err := parseCSVInt64(rawTalkgroupIDs) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid talkgroup_ids"}) - return - } - - // Parse multi-select labels (new CSV params) with single-select fallback. - rawGroups := c.Query("groups") - if rawGroups == "" { - rawGroups = c.Query("group") - } - rawTags := c.Query("tags") - if rawTags == "" { - rawTags = c.Query("tag") - } - groupLabels := parseCSVStrings(rawGroups) - tagLabels := parseCSVStrings(rawTags) - - groupIDs := make([]int64, 0, len(groupLabels)) - for _, label := range groupLabels { - g, err := h.queries.GetGroupByLabel(ctx, label) - if err == nil { - groupIDs = append(groupIDs, g.ID) - } - } - if len(groupLabels) > 0 && len(groupIDs) == 0 { - c.JSON(http.StatusOK, shared.CallSearchResponse{Calls: []shared.CallSearchResult{}, Total: 0}) - return - } - - tagIDs := make([]int64, 0, len(tagLabels)) - for _, label := range tagLabels { - t, err := h.queries.GetTagByLabel(ctx, label) - if err == nil { - tagIDs = append(tagIDs, t.ID) - } - } - if len(tagLabels) > 0 && len(tagIDs) == 0 { - c.JSON(http.StatusOK, shared.CallSearchResponse{Calls: []shared.CallSearchResult{}, Total: 0}) - return - } - - systemIDsCSV := toCSVFilter(systemIDs) - talkgroupIDsCSV := toCSVFilter(talkgroupIDs) - groupIDsCSV := toCSVFilter(groupIDs) - tagIDsCSV := toCSVFilter(tagIDs) - - var transcript interface{} - if v := c.Query("transcript"); v != "" { - transcript = v - } - - var dateFrom, dateTo interface{} - if v := c.Query("date_from"); v != "" { - n, err := strconv.ParseInt(v, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date_from"}) - return - } - dateFrom = n - } - if v := c.Query("date_to"); v != "" { - n, err := strconv.ParseInt(v, 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date_to"}) - return - } - dateTo = n - } - - page := int64(1) - if v := c.Query("page"); v != "" { - n, err := strconv.ParseInt(v, 10, 64) - if err != nil || n < 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"}) - return - } - page = n - } - - limit := int64(25) - if v := c.Query("limit"); v != "" { - n, err := strconv.ParseInt(v, 10, 64) - if err != nil || n < 1 { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"}) - return - } - if n > 100 { - n = 100 - } - limit = n - } - - sortOrder := "desc" - if v := c.Query("sort"); v != "" { - v = strings.ToLower(v) - if v != "asc" && v != "desc" { - c.JSON(http.StatusBadRequest, gin.H{"error": "sort must be asc or desc"}) - return - } - sortOrder = v - } - - offset := (page - 1) * limit - - // Resolve bookmarked_only filter: requires authenticated user. - var bookmarkUserID interface{} - if c.Query("bookmarked_only") == "true" { - if userIDVal, exists := c.Get("userID"); exists { - if uid, ok := userIDVal.(int64); ok { - bookmarkUserID = uid - } - } - } - - // Count total matching calls. - total, err := h.queries.CountCallsFiltered(ctx, db.CountCallsFilteredParams{ - SystemIdsCsv: systemIDsCSV, - TalkgroupIdsCsv: talkgroupIDsCSV, - GroupIdsCsv: groupIDsCSV, - TagIdsCsv: tagIDsCSV, - DateFrom: dateFrom, - DateTo: dateTo, - BookmarkUserID: bookmarkUserID, - Transcript: transcript, - }) - if err != nil { - slog.Error("failed to count calls", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - - // Fetch calls page. - var calls []db.Call - listParams := db.ListCallsParams{ - SystemIdsCsv: systemIDsCSV, - TalkgroupIdsCsv: talkgroupIDsCSV, - GroupIdsCsv: groupIDsCSV, - TagIdsCsv: tagIDsCSV, - DateFrom: dateFrom, - DateTo: dateTo, - BookmarkUserID: bookmarkUserID, - Transcript: transcript, - PageOffset: sql.NullInt64{Int64: offset, Valid: true}, - PageSize: sql.NullInt64{Int64: limit, Valid: true}, - } - if sortOrder == "asc" { - calls, err = h.queries.ListCallsAsc(ctx, db.ListCallsAscParams(listParams)) - } else { - calls, err = h.queries.ListCalls(ctx, listParams) - } - if err != nil { - slog.Error("failed to list calls", "error", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) - return - } - - // 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 := shared.LoadUserGrants(c, h.queries) - if grants != nil { - allowed := calls[:0] - for _, call := range calls { - if shared.IsGranted(grants, call.SystemID, call.TalkgroupID.Int64) { - allowed = append(allowed, call) - } - } - calls = allowed - // Adjust total to reflect grant-scoped count. The SQL count does not - // know about grants, so cap it to the filtered result set size when - // the filter actually removed rows. This is an approximation; an - // exact count would require SQL-level grant filtering. - if int64(len(calls)) < limit { - total = offset + int64(len(calls)) - } - } - - // Build set of bookmarked call IDs for authenticated users. - bookmarkedIDs := make(map[int64]bool) - if userIDVal, exists := c.Get("userID"); exists { - if uid, ok := userIDVal.(int64); ok { - bookmarks, berr := h.queries.ListBookmarksByUser(ctx, sql.NullInt64{Int64: uid, Valid: true}) - if berr == nil { - for _, bm := range bookmarks { - bookmarkedIDs[bm.CallID] = true - } - } - } - } - - // Pre-cache lookups to avoid N+1 queries. - systemCache := make(map[int64]db.System) - tgCache := make(map[int64]db.Talkgroup) - groupCache := make(map[int64]string) - tagCache := make(map[int64]string) - - // Build response with joined labels and transcripts. - results := make([]shared.CallSearchResult, 0, len(calls)) - for _, call := range calls { - r := shared.CallSearchResult{ - ID: call.ID, - AudioName: call.AudioName, - AudioType: call.AudioType, - DateTime: call.DateTime, - SystemID: call.SystemID, - } - - if call.Frequency.Valid { - r.Frequency = &call.Frequency.Int64 - } - if call.Duration.Valid { - r.Duration = &call.Duration.Int64 - } - if call.Source.Valid { - r.Source = &call.Source.Int64 - } - if call.ErrorCount.Valid { - r.ErrorCount = &call.ErrorCount.Int64 - } - if call.SpikeCount.Valid { - r.SpikeCount = &call.SpikeCount.Int64 - } - if call.Site.Valid { - r.Site = call.Site.String - } - if call.Channel.Valid { - r.Channel = call.Channel.String - } - if call.Decoder.Valid { - r.Decoder = call.Decoder.String - } - if call.TalkerAlias.Valid { - r.TalkerAlias = call.TalkerAlias.String - } - - // Join system label (cached). - sys, ok := systemCache[call.SystemID] - if !ok { - var serr error - sys, serr = h.queries.GetSystem(ctx, call.SystemID) - if serr == nil { - systemCache[call.SystemID] = sys - } - } - if ok || systemCache[call.SystemID].ID != 0 { - r.SystemID = sys.SystemID - r.SystemLabel = sys.Label - } - - // Join talkgroup details (cached). - if call.TalkgroupID.Valid { - tg, ok := tgCache[call.TalkgroupID.Int64] - if !ok { - var terr error - tg, terr = h.queries.GetTalkgroup(ctx, call.TalkgroupID.Int64) - if terr == nil { - tgCache[call.TalkgroupID.Int64] = tg - } - } - if ok || tgCache[call.TalkgroupID.Int64].ID != 0 { - r.TalkgroupID = tg.TalkgroupID - if tg.Label.Valid { - r.TalkgroupLabel = tg.Label.String - } - if tg.Name.Valid { - r.TalkgroupName = tg.Name.String - } - if tg.Led.Valid { - r.TalkgroupLed = tg.Led.String - } - // Resolve group label (cached). - if tg.GroupID.Valid { - grpLabel, ok := groupCache[tg.GroupID.Int64] - if !ok { - grp, gerr := h.queries.GetGroup(ctx, tg.GroupID.Int64) - if gerr == nil { - groupCache[tg.GroupID.Int64] = grp.Label - grpLabel = grp.Label - } - } - if ok || grpLabel != "" { - r.TalkgroupGroup = grpLabel - } - } - // Resolve tag label (cached). - if tg.TagID.Valid { - tagLabel, ok := tagCache[tg.TagID.Int64] - if !ok { - tag, tgerr := h.queries.GetTag(ctx, tg.TagID.Int64) - if tgerr == nil { - tagCache[tg.TagID.Int64] = tag.Label - tagLabel = tag.Label - } - } - if ok || tagLabel != "" { - r.TalkgroupTag = tagLabel - } - } - } - } - - // Join transcript. - trn, terr := h.queries.GetTranscriptionByCallID(ctx, call.ID) - if terr == nil { - r.Transcript = trn.Text - } - - // Bookmark status. - r.Bookmarked = bookmarkedIDs[call.ID] - - results = append(results, r) - } - - c.JSON(http.StatusOK, shared.CallSearchResponse{ - Calls: results, - Total: total, - }) -} - -// 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 - } - - // 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 - } - - 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, - }) -} - -// --- 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 { - if !tg.Label.Valid && label != "" { - return true - } - if !tg.Name.Valid && name != "" { - return true - } - if !tg.TagID.Valid && tag != "" { - return true - } - if !tg.GroupID.Valid && group != "" { - return true - } - return false -} - -// isBlacklistedTG checks whether a talkgroup ID appears in a system's blacklist. -// The blacklist is a JSON array of integers stored in blacklists_json. -func isBlacklistedTG(blacklistsJSON sql.NullString, talkgroupID int64) bool { - if !blacklistsJSON.Valid || strings.TrimSpace(blacklistsJSON.String) == "" { - return false - } - var ids []int64 - if err := json.Unmarshal([]byte(blacklistsJSON.String), &ids); err != nil { - slog.Warn("failed to parse blacklists_json", "error", err) - return false - } - for _, id := range ids { - if id == talkgroupID { - return true - } - } - return false -} - -// upsertUnitsFromSources parses the sources JSON array and upserts any units -// that include a "tag" (label) into the units table. -// Sources format: [{"pos":0,"src":12345,"tag":"Unit Name"}, ...] -// Entries without "src" or "tag" are silently skipped. -func upsertUnitsFromSources(ctx context.Context, q *db.Queries, systemDBID int64, raw string) { - var sources []map[string]any - if err := json.Unmarshal([]byte(raw), &sources); err != nil { - return - } - for _, entry := range sources { - srcVal, ok := entry["src"] - if !ok { - continue - } - srcFloat, ok := srcVal.(float64) - if !ok || srcFloat <= 0 { - continue - } - tagVal, ok := entry["tag"] - if !ok { - continue - } - tag, ok := tagVal.(string) - if !ok || tag == "" { - continue - } - if err := q.UpsertUnit(ctx, db.UpsertUnitParams{ - SystemID: systemDBID, - UnitID: int64(srcFloat), - Label: sql.NullString{String: tag, Valid: true}, - }); err != nil { - slog.Warn("failed to upsert unit from sources", - "unit_id", int64(srcFloat), "tag", tag, "error", err) - } - } -} - -// extractPrimarySource returns the "src" value from the first entry in a -// sources JSON array. Trunk-recorder sends unit IDs only inside this array -// (e.g. [{"pos":0,"src":12345}, ...]) and does not set a top-level "source". -func extractPrimarySource(raw string) sql.NullInt64 { - var sources []map[string]any - if err := json.Unmarshal([]byte(raw), &sources); err != nil || len(sources) == 0 { - return sql.NullInt64{} - } - srcVal, ok := sources[0]["src"] - if !ok { - return sql.NullInt64{} - } - srcFloat, ok := srcVal.(float64) - if !ok || srcFloat <= 0 { - return sql.NullInt64{} - } - return sql.NullInt64{Int64: int64(srcFloat), Valid: true} -} - -// extractPrimarySourceTag returns the "tag" value from the first source entry -// that has a non-empty tag. Trunk-recorder sends OTA aliases (talker alias) -// inside the sources JSON rather than as a top-level "talkerAlias" field. -func extractPrimarySourceTag(raw string) sql.NullString { - var sources []map[string]any - if err := json.Unmarshal([]byte(raw), &sources); err != nil { - return sql.NullString{} - } - for _, entry := range sources { - tagVal, ok := entry["tag"] - if !ok { - continue - } - tag, ok := tagVal.(string) - if !ok || tag == "" { - continue - } - return sql.NullString{String: tag, Valid: true} - } - return sql.NullString{} -} - -// aggregateErrorSpikeCounts sums errorCount and spikeCount from all entries -// in a frequencies JSON array. Trunk-recorder sends per-segment values inside -// this array (e.g. [{"errorCount":2,"spikeCount":0}, ...]) rather than -// providing aggregate top-level fields. -func aggregateErrorSpikeCounts(raw string) (sql.NullInt64, sql.NullInt64) { - var freqs []map[string]any - if err := json.Unmarshal([]byte(raw), &freqs); err != nil || len(freqs) == 0 { - return sql.NullInt64{}, sql.NullInt64{} - } - var totalErrors, totalSpikes int64 - var found bool - for _, entry := range freqs { - if v, ok := entry["errorCount"]; ok { - if f, ok := v.(float64); ok { - totalErrors += int64(f) - found = true - } - } - // trunk-recorder also uses "error_count" in its call JSON. - if v, ok := entry["error_count"]; ok { - if f, ok := v.(float64); ok { - totalErrors += int64(f) - found = true - } - } - if v, ok := entry["spikeCount"]; ok { - if f, ok := v.(float64); ok { - totalSpikes += int64(f) - found = true - } - } - if v, ok := entry["spike_count"]; ok { - if f, ok := v.(float64); ok { - totalSpikes += int64(f) - found = true - } - } - } - if !found { - return sql.NullInt64{}, sql.NullInt64{} - } - return sql.NullInt64{Int64: totalErrors, Valid: true}, - sql.NullInt64{Int64: totalSpikes, Valid: true} -} diff --git a/backend/internal/handler/calls/search.go b/backend/internal/handler/calls/search.go new file mode 100644 index 0000000..56a3c5f --- /dev/null +++ b/backend/internal/handler/calls/search.go @@ -0,0 +1,440 @@ +package calls + +import ( + "database/sql" + "log/slog" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/shared" +) + +// GetCalls handles GET /api/calls — paginated call archive search. +// +// @Summary Search calls +// @Description Paginated search of the call archive with optional filters. Authentication is optional when the publicAccess setting is enabled; otherwise a valid JWT is required. +// @Tags Calls +// @Security BearerAuth +// @Produce json +// @Param system_ids query string false "CSV system DB IDs (e.g. 1,2,3)" +// @Param talkgroup_ids query string false "CSV talkgroup DB IDs (e.g. 10,11)" +// @Param groups query string false "CSV group labels (e.g. Police,Fire)" +// @Param tags query string false "CSV tag labels (e.g. Law,EMS)" +// @Param system_id query int false "Legacy single system DB ID" +// @Param talkgroup_id query int false "Legacy single talkgroup DB ID" +// @Param group query string false "Legacy single group label" +// @Param tag query string false "Legacy single tag label" +// @Param date_from query int false "Unix timestamp lower bound" +// @Param date_to query int false "Unix timestamp upper bound" +// @Param sort query string false "Sort order: asc or desc" Enums(asc, desc) default(desc) +// @Param page query int false "Page number (1-based)" default(1) +// @Param limit query int false "Results per page (max 100)" default(25) +// @Param bookmarked_only query bool false "Show only bookmarked calls" +// @Param transcript query string false "Filter by transcript text (partial match)" +// @Success 200 {object} CallSearchResponse "Paginated call results" +// @Failure 400 {object} ErrorResponse "Invalid query parameter" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /calls [get] +func (h *Handler) GetCalls(c *gin.Context) { + ctx := c.Request.Context() + + parseCSVInt64 := func(raw string) ([]int64, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + parts := strings.Split(raw, ",") + vals := make([]int64, 0, len(parts)) + seen := make(map[int64]struct{}) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return nil, err + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + vals = append(vals, n) + } + if len(vals) == 0 { + return nil, nil + } + return vals, nil + } + + parseCSVStrings := func(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + vals := make([]string, 0, len(parts)) + seen := make(map[string]struct{}) + for _, part := range parts { + v := strings.TrimSpace(part) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + vals = append(vals, v) + } + if len(vals) == 0 { + return nil + } + return vals + } + + toCSVFilter := func(vals []int64) interface{} { + if len(vals) == 0 { + return nil + } + parts := make([]string, 0, len(vals)) + for _, v := range vals { + parts = append(parts, strconv.FormatInt(v, 10)) + } + return strings.Join(parts, ",") + } + + // Parse multi-select IDs (new CSV params) with single-select fallback. + rawSystemIDs := c.Query("system_ids") + if rawSystemIDs == "" { + rawSystemIDs = c.Query("system_id") + } + rawTalkgroupIDs := c.Query("talkgroup_ids") + if rawTalkgroupIDs == "" { + rawTalkgroupIDs = c.Query("talkgroup_id") + } + + systemIDs, err := parseCSVInt64(rawSystemIDs) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid system_ids"}) + return + } + talkgroupIDs, err := parseCSVInt64(rawTalkgroupIDs) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid talkgroup_ids"}) + return + } + + // Parse multi-select labels (new CSV params) with single-select fallback. + rawGroups := c.Query("groups") + if rawGroups == "" { + rawGroups = c.Query("group") + } + rawTags := c.Query("tags") + if rawTags == "" { + rawTags = c.Query("tag") + } + groupLabels := parseCSVStrings(rawGroups) + tagLabels := parseCSVStrings(rawTags) + + groupIDs := make([]int64, 0, len(groupLabels)) + for _, label := range groupLabels { + g, err := h.queries.GetGroupByLabel(ctx, label) + if err == nil { + groupIDs = append(groupIDs, g.ID) + } + } + if len(groupLabels) > 0 && len(groupIDs) == 0 { + c.JSON(http.StatusOK, shared.CallSearchResponse{Calls: []shared.CallSearchResult{}, Total: 0}) + return + } + + tagIDs := make([]int64, 0, len(tagLabels)) + for _, label := range tagLabels { + t, err := h.queries.GetTagByLabel(ctx, label) + if err == nil { + tagIDs = append(tagIDs, t.ID) + } + } + if len(tagLabels) > 0 && len(tagIDs) == 0 { + c.JSON(http.StatusOK, shared.CallSearchResponse{Calls: []shared.CallSearchResult{}, Total: 0}) + return + } + + systemIDsCSV := toCSVFilter(systemIDs) + talkgroupIDsCSV := toCSVFilter(talkgroupIDs) + groupIDsCSV := toCSVFilter(groupIDs) + tagIDsCSV := toCSVFilter(tagIDs) + + var transcript interface{} + if v := c.Query("transcript"); v != "" { + transcript = v + } + + var dateFrom, dateTo interface{} + if v := c.Query("date_from"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date_from"}) + return + } + dateFrom = n + } + if v := c.Query("date_to"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid date_to"}) + return + } + dateTo = n + } + + page := int64(1) + if v := c.Query("page"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"}) + return + } + page = n + } + + limit := int64(25) + if v := c.Query("limit"); v != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"}) + return + } + if n > 100 { + n = 100 + } + limit = n + } + + sortOrder := "desc" + if v := c.Query("sort"); v != "" { + v = strings.ToLower(v) + if v != "asc" && v != "desc" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sort must be asc or desc"}) + return + } + sortOrder = v + } + + offset := (page - 1) * limit + + // Resolve bookmarked_only filter: requires authenticated user. + var bookmarkUserID interface{} + if c.Query("bookmarked_only") == "true" { + if userIDVal, exists := c.Get("userID"); exists { + if uid, ok := userIDVal.(int64); ok { + bookmarkUserID = uid + } + } + } + + // Count total matching calls. + total, err := h.queries.CountCallsFiltered(ctx, db.CountCallsFilteredParams{ + SystemIdsCsv: systemIDsCSV, + TalkgroupIdsCsv: talkgroupIDsCSV, + GroupIdsCsv: groupIDsCSV, + TagIdsCsv: tagIDsCSV, + DateFrom: dateFrom, + DateTo: dateTo, + BookmarkUserID: bookmarkUserID, + Transcript: transcript, + }) + if err != nil { + slog.Error("failed to count calls", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + + // Fetch calls page. + var calls []db.Call + listParams := db.ListCallsParams{ + SystemIdsCsv: systemIDsCSV, + TalkgroupIdsCsv: talkgroupIDsCSV, + GroupIdsCsv: groupIDsCSV, + TagIdsCsv: tagIDsCSV, + DateFrom: dateFrom, + DateTo: dateTo, + BookmarkUserID: bookmarkUserID, + Transcript: transcript, + PageOffset: sql.NullInt64{Int64: offset, Valid: true}, + PageSize: sql.NullInt64{Int64: limit, Valid: true}, + } + if sortOrder == "asc" { + calls, err = h.queries.ListCallsAsc(ctx, db.ListCallsAscParams(listParams)) + } else { + calls, err = h.queries.ListCalls(ctx, listParams) + } + if err != nil { + slog.Error("failed to list calls", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + + // 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 := shared.LoadUserGrants(c, h.queries) + if grants != nil { + allowed := calls[:0] + for _, call := range calls { + if shared.IsGranted(grants, call.SystemID, call.TalkgroupID.Int64) { + allowed = append(allowed, call) + } + } + calls = allowed + // Adjust total to reflect grant-scoped count. The SQL count does not + // know about grants, so cap it to the filtered result set size when + // the filter actually removed rows. This is an approximation; an + // exact count would require SQL-level grant filtering. + if int64(len(calls)) < limit { + total = offset + int64(len(calls)) + } + } + + // Build set of bookmarked call IDs for authenticated users. + bookmarkedIDs := make(map[int64]bool) + if userIDVal, exists := c.Get("userID"); exists { + if uid, ok := userIDVal.(int64); ok { + bookmarks, berr := h.queries.ListBookmarksByUser(ctx, sql.NullInt64{Int64: uid, Valid: true}) + if berr == nil { + for _, bm := range bookmarks { + bookmarkedIDs[bm.CallID] = true + } + } + } + } + + // Pre-cache lookups to avoid N+1 queries. + systemCache := make(map[int64]db.System) + tgCache := make(map[int64]db.Talkgroup) + groupCache := make(map[int64]string) + tagCache := make(map[int64]string) + + // Build response with joined labels and transcripts. + results := make([]shared.CallSearchResult, 0, len(calls)) + for _, call := range calls { + r := shared.CallSearchResult{ + ID: call.ID, + AudioName: call.AudioName, + AudioType: call.AudioType, + DateTime: call.DateTime, + SystemID: call.SystemID, + } + + if call.Frequency.Valid { + r.Frequency = &call.Frequency.Int64 + } + if call.Duration.Valid { + r.Duration = &call.Duration.Int64 + } + if call.Source.Valid { + r.Source = &call.Source.Int64 + } + if call.ErrorCount.Valid { + r.ErrorCount = &call.ErrorCount.Int64 + } + if call.SpikeCount.Valid { + r.SpikeCount = &call.SpikeCount.Int64 + } + if call.Site.Valid { + r.Site = call.Site.String + } + if call.Channel.Valid { + r.Channel = call.Channel.String + } + if call.Decoder.Valid { + r.Decoder = call.Decoder.String + } + if call.TalkerAlias.Valid { + r.TalkerAlias = call.TalkerAlias.String + } + + // Join system label (cached). + sys, ok := systemCache[call.SystemID] + if !ok { + var serr error + sys, serr = h.queries.GetSystem(ctx, call.SystemID) + if serr == nil { + systemCache[call.SystemID] = sys + } + } + if ok || systemCache[call.SystemID].ID != 0 { + r.SystemID = sys.SystemID + r.SystemLabel = sys.Label + } + + // Join talkgroup details (cached). + if call.TalkgroupID.Valid { + tg, ok := tgCache[call.TalkgroupID.Int64] + if !ok { + var terr error + tg, terr = h.queries.GetTalkgroup(ctx, call.TalkgroupID.Int64) + if terr == nil { + tgCache[call.TalkgroupID.Int64] = tg + } + } + if ok || tgCache[call.TalkgroupID.Int64].ID != 0 { + r.TalkgroupID = tg.TalkgroupID + if tg.Label.Valid { + r.TalkgroupLabel = tg.Label.String + } + if tg.Name.Valid { + r.TalkgroupName = tg.Name.String + } + if tg.Led.Valid { + r.TalkgroupLed = tg.Led.String + } + // Resolve group label (cached). + if tg.GroupID.Valid { + grpLabel, ok := groupCache[tg.GroupID.Int64] + if !ok { + grp, gerr := h.queries.GetGroup(ctx, tg.GroupID.Int64) + if gerr == nil { + groupCache[tg.GroupID.Int64] = grp.Label + grpLabel = grp.Label + } + } + if ok || grpLabel != "" { + r.TalkgroupGroup = grpLabel + } + } + // Resolve tag label (cached). + if tg.TagID.Valid { + tagLabel, ok := tagCache[tg.TagID.Int64] + if !ok { + tag, tgerr := h.queries.GetTag(ctx, tg.TagID.Int64) + if tgerr == nil { + tagCache[tg.TagID.Int64] = tag.Label + tagLabel = tag.Label + } + } + if ok || tagLabel != "" { + r.TalkgroupTag = tagLabel + } + } + } + } + + // Join transcript. + trn, terr := h.queries.GetTranscriptionByCallID(ctx, call.ID) + if terr == nil { + r.Transcript = trn.Text + } + + // Bookmark status. + r.Bookmarked = bookmarkedIDs[call.ID] + + results = append(results, r) + } + + c.JSON(http.StatusOK, shared.CallSearchResponse{ + Calls: results, + Total: total, + }) +} diff --git a/backend/internal/handler/calls/transcript.go b/backend/internal/handler/calls/transcript.go new file mode 100644 index 0000000..2ee1063 --- /dev/null +++ b/backend/internal/handler/calls/transcript.go @@ -0,0 +1,80 @@ +package calls + +import ( + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/handler/shared" +) + +// 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 + } + + // 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 + } + + 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/handler/calls/upload.go b/backend/internal/handler/calls/upload.go new file mode 100644 index 0000000..ba47212 --- /dev/null +++ b/backend/internal/handler/calls/upload.go @@ -0,0 +1,833 @@ +package calls + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/openscanner/openscanner/internal/audio" + "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" +) + +// PostCallUpload handles POST /api/call-upload and /api/trunk-recorder-call-upload. +// +// @Summary Upload a call recording +// @Description Ingest a radio call with audio and metadata. Requires a valid API key. +// @Tags Upload +// @Accept multipart/form-data +// @Produce json +// @Security APIKeyAuth +// @Param audio formData file true "Audio file" +// @Param dateTime formData int true "Unix timestamp of the call" +// @Param systemId formData int true "Radio system ID" +// @Param talkgroupId formData int true "Talkgroup ID" +// @Param source formData int false "Source unit ID" +// @Param frequency formData int false "Frequency in Hz" +// @Param duration formData number false "Call duration in seconds" +// @Param talkgroupLabel formData string false "Talkgroup label for auto-populate" +// @Param talkgroupTag formData string false "Talkgroup tag name" +// @Param talkgroupGroup formData string false "Talkgroup group name" +// @Param talkgroupName formData string false "Talkgroup display name" +// @Param systemLabel formData string false "System label" +// @Param patches formData string false "JSON array of patched talkgroup IDs" +// @Param audioName formData string false "Original audio file name" +// @Param audioType formData string false "Audio MIME type" +// @Param site formData string false "Site identifier" +// @Param channel formData string false "Channel identifier" +// @Param decoder formData string false "Decoder software name" +// @Param errorCount formData int false "Decoding error count" +// @Param spikeCount formData int false "Signal spike count" +// @Success 200 {object} object{id=int64} "Call ingested successfully" +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 401 {object} ErrorResponse "API key required" +// @Failure 429 {object} ErrorResponse "Rate limit exceeded" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /call-upload [post] +// @Router /trunk-recorder-call-upload [post] +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") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "API key required"}) + return + } + apiKeyID, ok := apiKeyIDVal.(int64) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + + // Per-API-key rate limiting. + rateLimit := defaultCallRatePerMin + apiKeyRateOverride := false + if apiKeyRateVal, ok := c.Get("apiKeyCallRate"); ok { + if apiKeyRate, ok := apiKeyRateVal.(int64); ok && apiKeyRate > 0 { + rateLimit = int(apiKeyRate) + apiKeyRateOverride = true + } + } + if rStr := shared.GetSettingValue(c, h.queries, "apiKeyCallRate"); rStr != "" { + if r, err := strconv.Atoi(rStr); err == nil && r > 0 && !apiKeyRateOverride { + rateLimit = r + } + } + if rateLimit > maxCallRatePerMin { + rateLimit = maxCallRatePerMin + } + if !h.getLimiter(apiKeyID, rateLimit).allow() { + slog.Warn("call upload rate limit exceeded", "api_key_id", apiKeyID) + c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"}) + return + } + + slog.Debug("call-upload: rate limit passed", "api_key_id", apiKeyID) + + // SDRTrunk and other rdio-scanner-compatible clients may send a POST with + // partial data to verify the API key. rdio-scanner responds with plain-text + // "Incomplete call data: " (status 417) which SDRTrunk treats as a + // successful connection test. We replicate that behavior: parse all fields + // first, then return the same message format for missing required fields. + dateTimeStr := c.PostForm("dateTime") + systemIDStr := c.PostForm("systemId") + if systemIDStr == "" { + systemIDStr = c.PostForm("system") + } + talkgroupIDStr := c.PostForm("talkgroupId") + if talkgroupIDStr == "" { + talkgroupIDStr = c.PostForm("talkgroup") + } + _, audioErr := c.FormFile("audio") + + // Check for test=1 explicitly (Trunk Recorder). + if c.PostForm("test") == "1" { + c.String(http.StatusOK, "Incomplete call data: no talkgroup\n") + return + } + + // rdio-scanner's IsValid() checks all fields WITHOUT early returns and + // overwrites the error each time, so the LAST failing check wins. + // SDRTrunk sends system= but no audio/dateTime/talkgroup, so the last + // error is always "no talkgroup" — which SDRTrunk explicitly checks for. + // We replicate this behavior: collect the last error, then return it. + var incompleteReason string + if audioErr != nil { + incompleteReason = "no audio" + } + if dateTimeStr == "" { + incompleteReason = "no datetime" + } + if systemIDStr == "" { + incompleteReason = "no system" + } + if talkgroupIDStr == "" { + incompleteReason = "no talkgroup" + } + if incompleteReason != "" { + slog.Warn("call-upload: incomplete data", + "reason", incompleteReason, + "api_key_id", apiKeyID, + ) + c.String(http.StatusExpectationFailed, "Incomplete call data: %s\n", incompleteReason) + return + } + + // Parse dateTime. + // Try unix timestamp first (Trunk Recorder, SDRTrunk), then ISO 8601 (voxcall). + var dateTimeUnix int64 + if n, err := strconv.ParseInt(dateTimeStr, 10, 64); err == nil { + dateTimeUnix = n + } else if t, err := time.Parse(time.RFC3339Nano, dateTimeStr); err == nil { + dateTimeUnix = t.Unix() + } else if t, err := time.Parse(time.RFC3339, dateTimeStr); err == nil { + dateTimeUnix = t.Unix() + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid dateTime: expected unix timestamp or ISO 8601"}) + return + } + callTime := time.Unix(dateTimeUnix, 0) + + // Trunk Recorder's rdioscanner_uploader plugin sends "system" and + // "talkgroup" while our canonical field names are "systemId" and + // "talkgroupId". Accept both for backward compatibility. + // (Already parsed above for the connectivity check.) + systemIDRaw, err := strconv.ParseInt(systemIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid systemId"}) + return + } + + talkgroupIDRaw, err := strconv.ParseInt(talkgroupIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid talkgroupId"}) + return + } + + // Parse optional fields. + var frequency, duration, source sql.NullInt64 + if v := c.PostForm("frequency"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + frequency = sql.NullInt64{Int64: n, Valid: true} + } + } + if v := c.PostForm("duration"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + duration = sql.NullInt64{Int64: n, Valid: true} + } + } + if v := c.PostForm("source"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + source = sql.NullInt64{Int64: n, Valid: true} + } + } + + var errorCount, spikeCount sql.NullInt64 + if v := c.PostForm("errorCount"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + errorCount = sql.NullInt64{Int64: n, Valid: true} + } + } + if v := c.PostForm("spikeCount"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + spikeCount = sql.NullInt64{Int64: n, Valid: true} + } + } + + var sourcesJSON, frequenciesJSON, patchesJSON sql.NullString + if v := c.PostForm("sources"); v != "" { + sourcesJSON = sql.NullString{String: v, Valid: true} + } + if v := c.PostForm("frequencies"); v != "" { + frequenciesJSON = sql.NullString{String: v, Valid: true} + } + if v := c.PostForm("patches"); v != "" { + patchesJSON = sql.NullString{String: v, Valid: true} + } + + // Trunk-recorder's rdio-scanner uploader embeds unit IDs inside the + // "sources" JSON array rather than sending a top-level "source" field. + // Extract the first source unit ID when not explicitly provided. + if !source.Valid && sourcesJSON.Valid { + source = extractPrimarySource(sourcesJSON.String) + } + + // Similarly, error and spike counts are per-segment inside the + // "frequencies" JSON array. Aggregate them when no top-level values + // were provided. + if !errorCount.Valid && !spikeCount.Valid && frequenciesJSON.Valid { + errorCount, spikeCount = aggregateErrorSpikeCounts(frequenciesJSON.String) + } + + // Optional call metadata fields. + var siteCol, channelCol, decoderCol sql.NullString + if v := c.PostForm("site"); v != "" { + siteCol = sql.NullString{String: v, Valid: true} + } + if v := c.PostForm("channel"); v != "" { + channelCol = sql.NullString{String: v, Valid: true} + } + if v := c.PostForm("decoder"); v != "" { + decoderCol = sql.NullString{String: v, Valid: true} + } + + // Optional talkgroup metadata for auto-populate / backfill. + talkgroupLabel := c.PostForm("talkgroupLabel") + talkgroupTag := c.PostForm("talkgroupTag") + talkgroupGroup := c.PostForm("talkgroupGroup") + talkgroupName := c.PostForm("talkgroupName") + + var talkerAliasCol sql.NullString + if v := c.PostForm("talkerAlias"); v != "" { + talkerAliasCol = sql.NullString{String: v, Valid: true} + } + + // Trunk-recorder embeds OTA aliases in the sources JSON "tag" field + // rather than sending a top-level "talkerAlias". Extract from the + // first source entry when not explicitly provided. + if !talkerAliasCol.Valid && sourcesJSON.Valid { + talkerAliasCol = extractPrimarySourceTag(sourcesJSON.String) + } + + ctx := c.Request.Context() + autoPopulateSystems := shared.GetSettingValue(c, h.queries, "autoPopulateSystems") == "true" + + slog.Debug("call-upload: resolving system and talkgroup", + "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) + + // Resolve system by its radio system_id. + system, err := h.queries.GetSystemBySystemID(ctx, systemIDRaw) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + slog.Error("failed to query system", "system_id", systemIDRaw, "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + if !autoPopulateSystems { + c.JSON(http.StatusBadRequest, gin.H{"error": "system not found"}) + return + } + label := strconv.FormatInt(systemIDRaw, 10) + // SDRTrunk and other uploaders send systemLabel with a human-readable name. + if sl := c.PostForm("systemLabel"); sl != "" { + label = sl + } + newID, cerr := h.queries.CreateSystem(ctx, db.CreateSystemParams{ + SystemID: systemIDRaw, + Label: label, + AutoPopulateTalkgroups: 1, + }) + if cerr != nil { + slog.Error("failed to auto-create system", "system_id", systemIDRaw, "error", cerr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + slog.Info("auto-populated system", "system_id", systemIDRaw, "label", label, "db_id", newID) + system = db.System{ID: newID, SystemID: systemIDRaw, Label: label, AutoPopulateTalkgroups: 1} + h.hub.BroadcastCFG(ctx) + } + + // Blacklist check: reject calls to blacklisted talkgroups. + if isBlacklistedTG(system.BlacklistsJson, talkgroupIDRaw) { + slog.Info("call upload: talkgroup is blacklisted", + "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) + c.JSON(http.StatusOK, gin.H{"message": "blacklisted"}) + return + } + + // Resolve talkgroup by system DB ID + radio talkgroup ID. + talkgroup, err := h.queries.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ + SystemID: system.ID, + TalkgroupID: talkgroupIDRaw, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + slog.Error("failed to query talkgroup", "system_id", system.ID, "talkgroup_id", talkgroupIDRaw, "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + if system.AutoPopulateTalkgroups == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "talkgroup not found"}) + return + } + var tgLabel, tgName sql.NullString + if talkgroupLabel != "" { + tgLabel = sql.NullString{String: talkgroupLabel, Valid: true} + } + if talkgroupName != "" { + tgName = sql.NullString{String: talkgroupName, Valid: true} + } + // Resolve group from talkgroupGroup (e.g. SDRTrunk sends this). + var groupID sql.NullInt64 + if 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 = shared.ResolveTagID(ctx, h.queries, talkgroupTag) + } + newID, cerr := h.queries.CreateTalkgroup(ctx, db.CreateTalkgroupParams{ + SystemID: system.ID, + TalkgroupID: talkgroupIDRaw, + Label: tgLabel, + Name: tgName, + GroupID: groupID, + TagID: tagID, + }) + if cerr != nil { + slog.Error("failed to auto-create talkgroup", "talkgroup_id", talkgroupIDRaw, "error", cerr) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + slog.Info("auto-populated talkgroup", "system_id", system.SystemID, "talkgroup_id", talkgroupIDRaw, "label", tgLabel.String, "db_id", newID) + talkgroup = db.Talkgroup{ID: newID, SystemID: system.ID, TalkgroupID: talkgroupIDRaw, Label: tgLabel, Name: tgName, GroupID: groupID, TagID: tagID} + h.hub.BroadcastCFG(ctx) + } else if needsBackfill(talkgroup, talkgroupLabel, talkgroupName, talkgroupTag, talkgroupGroup) { + // Existing talkgroup has empty fields — backfill from upload metadata. + if !talkgroup.Label.Valid && talkgroupLabel != "" { + talkgroup.Label = sql.NullString{String: talkgroupLabel, Valid: true} + } + if !talkgroup.Name.Valid && talkgroupName != "" { + talkgroup.Name = sql.NullString{String: talkgroupName, Valid: true} + } + if !talkgroup.GroupID.Valid && talkgroupGroup != "" { + talkgroup.GroupID = shared.ResolveGroupID(ctx, h.queries, talkgroupGroup) + } + if !talkgroup.TagID.Valid && talkgroupTag != "" { + talkgroup.TagID = shared.ResolveTagID(ctx, h.queries, talkgroupTag) + } + if uerr := h.queries.UpdateTalkgroup(ctx, db.UpdateTalkgroupParams{ + ID: talkgroup.ID, + TalkgroupID: talkgroup.TalkgroupID, + Label: talkgroup.Label, + Name: talkgroup.Name, + Frequency: talkgroup.Frequency, + Led: talkgroup.Led, + GroupID: talkgroup.GroupID, + TagID: talkgroup.TagID, + Order: talkgroup.Order, + }); uerr != nil { + slog.Warn("failed to backfill talkgroup from upload", + "talkgroup_id", talkgroup.TalkgroupID, "error", uerr) + } else { + slog.Info("backfilled talkgroup from upload", + "talkgroup_id", talkgroup.TalkgroupID) + h.hub.BroadcastCFG(ctx) + } + } + + // Duplicate detection (system.ID and talkgroup.ID are the FK values in calls). + if shared.GetSettingValue(c, h.queries, "disableDuplicateDetection") != "true" { + windowMs := int64(2000) + if v := shared.GetSettingValue(c, h.queries, "duplicateDetectionTimeFrame"); v != "" { + if wm, err := strconv.ParseInt(v, 10, 64); err == nil { + windowMs = wm + } + } + dup, derr := audio.IsDuplicate(ctx, h.queries, system.ID, talkgroup.ID, callTime, windowMs) + if derr != nil { + slog.Error("duplicate detection failed", "error", derr) + // Non-fatal: proceed with ingest. + } else if dup { + slog.Info("duplicate call rejected", "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) + c.JSON(http.StatusOK, gin.H{"message": "duplicate call rejected"}) + return + } + } + + // Get uploaded audio file. + fh, err := c.FormFile("audio") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "audio file is required"}) + return + } + + // Resolve audio conversion mode from settings. + convMode := audio.ConversionDisabled + 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(shared.GetSettingValue(c, h.queries, "audioEncodingPreset")) + + // Store audio file (conversion handled inside Processor.Store). + relPath, err := h.processor.Store(ctx, fh, convMode, convPreset) + if err != nil { + slog.Error("failed to store audio file", + "system_id", systemIDRaw, + "talkgroup_id", talkgroupIDRaw, + "error", err, + ) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store audio"}) + return + } + + slog.Debug("call-upload: audio stored", "path", relPath, "mode", convMode) + + // If the recorder didn't supply a duration, probe the stored file. + if !duration.Valid { + absPath := filepath.Join(h.processor.RecordingsDir(), relPath) + if d := audio.ProbeDuration(ctx, absPath); d > 0 { + duration = sql.NullInt64{Int64: d, Valid: true} + } + } + + // Determine audio MIME type. + // When conversion is enabled the output format depends on the encoding + // preset (M4A for AAC presets, MP3 for MP3 presets). + // Otherwise validate the client-supplied Content-Type against an allowlist + // to prevent attacker-controlled MIME types from reaching the database. + var audioType string + if convMode != audio.ConversionDisabled { + audioType = audio.OutputMIME(convPreset) + } else { + switch fh.Header.Get("Content-Type") { + case "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", + "audio/ogg", "audio/aac", "audio/m4a", "audio/mp4", + "audio/x-m4a", "audio/opus": + audioType = fh.Header.Get("Content-Type") + default: + audioType = "application/octet-stream" + } + } + + // Insert call record. + callID, err := h.queries.CreateCall(ctx, db.CreateCallParams{ + AudioPath: relPath, + AudioName: filepath.Base(relPath), + AudioType: audioType, + DateTime: dateTimeUnix, + Frequency: frequency, + Duration: duration, + Source: source, + SourcesJson: sourcesJSON, + FrequenciesJson: frequenciesJSON, + PatchesJson: patchesJSON, + SystemID: system.ID, + TalkgroupID: sql.NullInt64{Int64: talkgroup.ID, Valid: true}, + Site: siteCol, + Channel: channelCol, + Decoder: decoderCol, + ErrorCount: errorCount, + SpikeCount: spikeCount, + TalkerAlias: talkerAliasCol, + }) + if err != nil { + slog.Error("failed to insert call", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"}) + return + } + + slog.Debug("call-upload: db record inserted", + "call_id", callID, + "system_id", systemIDRaw, + "talkgroup_id", talkgroupIDRaw, + "audio_path", relPath, + ) + + // Extract unit tags from sources JSON and upsert into units table. + // Sources format: [{"pos":0,"src":12345,"tag":"Unit Name"}, ...] + if sourcesJSON.Valid { + upsertUnitsFromSources(ctx, h.queries, system.ID, sourcesJSON.String) + } + + // Map talkerAlias to the source unit as a label (e.g. P25 radios broadcasting a name). + if source.Valid && talkerAliasCol.Valid { + if err := h.queries.UpsertUnit(ctx, db.UpsertUnitParams{ + SystemID: system.ID, + UnitID: source.Int64, + Label: sql.NullString{String: talkerAliasCol.String, Valid: true}, + }); err != nil { + slog.Warn("failed to upsert unit from talkerAlias", + "unit_id", source.Int64, "talkerAlias", talkerAliasCol.String, "error", err) + } + } + + // Broadcast to WebSocket listeners. + if h.hub != nil { + // Read audio file for inline embedding in the CAL JSON frame. + // Use os.Root so the read is scoped to RecordingsDir and cannot + // follow a traversal sequence or symlink out of the directory, + // regardless of what relPath contains. + const maxBroadcastAudioBytes = 20 << 20 // 20 MiB + var audioBytes []byte + if root, rootErr := os.OpenRoot(h.processor.RecordingsDir()); rootErr != nil { + slog.Warn("failed to open recordings root for WS broadcast", "error", rootErr) + } else { + if fi, statErr := root.Stat(relPath); statErr != nil { + slog.Warn("failed to stat audio for WS broadcast", "path", relPath, "error", statErr) + } else if fi.Size() > maxBroadcastAudioBytes { + slog.Warn("audio file too large for inline WS broadcast, sending metadata only", + "path", relPath, "size_bytes", fi.Size(), "max_bytes", maxBroadcastAudioBytes) + } else if f, openErr := root.Open(relPath); openErr != nil { + slog.Warn("failed to open audio for WS broadcast", "path", relPath, "error", openErr) + } else { + readBytes, readErr := io.ReadAll(io.LimitReader(f, maxBroadcastAudioBytes)) + f.Close() + if readErr != nil { + slog.Warn("failed to read audio for WS broadcast", "path", relPath, "error", readErr) + } else { + audioBytes = readBytes + } + } + root.Close() + } + + calPayload := map[string]any{ + "id": callID, + "audioName": filepath.Base(relPath), + "audioType": audioType, + "dateTime": dateTimeUnix, + "systemId": system.SystemID, + "system": system.ID, + "talkgroupId": talkgroup.TalkgroupID, + "talkgroup": talkgroup.ID, + } + if frequency.Valid { + calPayload["frequency"] = frequency.Int64 + } + if duration.Valid { + calPayload["duration"] = duration.Int64 + } + if source.Valid { + calPayload["source"] = source.Int64 + } + if siteCol.Valid { + calPayload["site"] = siteCol.String + } + if channelCol.Valid { + calPayload["channel"] = channelCol.String + } + if decoderCol.Valid { + calPayload["decoder"] = decoderCol.String + } + if errorCount.Valid { + calPayload["errorCount"] = errorCount.Int64 + } + if spikeCount.Valid { + calPayload["spikeCount"] = spikeCount.Int64 + } + if talkerAliasCol.Valid { + calPayload["talkerAlias"] = talkerAliasCol.String + } + if sourcesJSON.Valid { + calPayload["sources"] = sourcesJSON.String + } + if frequenciesJSON.Valid { + calPayload["frequencies"] = frequenciesJSON.String + } + calMsg, err := ws.NewCALMessage(calPayload, audioBytes) + if err != nil { + slog.Error("failed to build CAL message", "error", err) + } else { + h.hub.BroadcastCAL(calMsg, func(cl *ws.Client) bool { + return cl.CanReceive(system.ID, talkgroup.ID) + }) + slog.Debug("call-upload: ws broadcast sent", "call_id", callID) + } + } + + logAttrs := []any{ + "call_id", callID, + "system_id", systemIDRaw, + "talkgroup_id", talkgroupIDRaw, + "audio_path", relPath, + "api_key_id", apiKeyID, + } + if duration.Valid { + logAttrs = append(logAttrs, "duration_ms", duration.Int64) + } + slog.Info("call-upload: complete", logAttrs...) + + c.JSON(http.StatusOK, gin.H{"id": callID, "message": "Call imported successfully."}) + + // Notify downstream pushers (non-blocking, after response is sent). + if h.dsNotifier != nil { + // Resolve labels for downstream consumers. + var groupLabel, tagLabel string + if talkgroup.GroupID.Valid { + if g, err := h.queries.GetGroup(ctx, talkgroup.GroupID.Int64); err == nil { + groupLabel = g.Label + } + } + if talkgroup.TagID.Valid { + if t, err := h.queries.GetTag(ctx, talkgroup.TagID.Int64); err == nil { + tagLabel = t.Label + } + } + + h.dsNotifier.Notify(downstream.CallEvent{ + CallID: callID, + AudioPath: relPath, + AudioName: filepath.Base(relPath), + AudioType: audioType, + DateTime: dateTimeUnix, + SystemID: system.SystemID, + System: system.ID, + TalkgroupID: talkgroup.TalkgroupID, + Talkgroup: talkgroup.ID, + Frequency: frequency.Int64, + Duration: duration.Int64, + Source: source.Int64, + Sources: sourcesJSON.String, + Frequencies: frequenciesJSON.String, + Patches: patchesJSON.String, + SystemLabel: system.Label, + TalkgroupLabel: talkgroup.Label.String, + TalkgroupName: talkgroup.Name.String, + TalkgroupGroup: groupLabel, + TalkgroupTag: tagLabel, + TalkerAlias: talkerAliasCol.String, + }) + slog.Debug("call-upload: downstream notify queued", "call_id", callID) + } + + // Enqueue transcription (non-blocking, after response is sent). + if h.transcriber != nil { + absPath := filepath.Join(h.processor.RecordingsDir(), relPath) + if err := h.transcriber.Submit(ctx, audio.TranscriptionJob{ + CallID: callID, + AudioPath: absPath, + }); err != nil { + slog.Warn("call-upload: failed to enqueue transcription", "call_id", callID, "error", err) + } + } +} + +// --- 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 { + if !tg.Label.Valid && label != "" { + return true + } + if !tg.Name.Valid && name != "" { + return true + } + if !tg.TagID.Valid && tag != "" { + return true + } + if !tg.GroupID.Valid && group != "" { + return true + } + return false +} + +// isBlacklistedTG checks whether a talkgroup ID appears in a system's blacklist. +// The blacklist is a JSON array of integers stored in blacklists_json. +func isBlacklistedTG(blacklistsJSON sql.NullString, talkgroupID int64) bool { + if !blacklistsJSON.Valid || strings.TrimSpace(blacklistsJSON.String) == "" { + return false + } + var ids []int64 + if err := json.Unmarshal([]byte(blacklistsJSON.String), &ids); err != nil { + slog.Warn("failed to parse blacklists_json", "error", err) + return false + } + for _, id := range ids { + if id == talkgroupID { + return true + } + } + return false +} + +// upsertUnitsFromSources parses the sources JSON array and upserts any units +// that include a "tag" (label) into the units table. +// Sources format: [{"pos":0,"src":12345,"tag":"Unit Name"}, ...] +// Entries without "src" or "tag" are silently skipped. +func upsertUnitsFromSources(ctx context.Context, q *db.Queries, systemDBID int64, raw string) { + var sources []map[string]any + if err := json.Unmarshal([]byte(raw), &sources); err != nil { + return + } + for _, entry := range sources { + srcVal, ok := entry["src"] + if !ok { + continue + } + srcFloat, ok := srcVal.(float64) + if !ok || srcFloat <= 0 { + continue + } + tagVal, ok := entry["tag"] + if !ok { + continue + } + tag, ok := tagVal.(string) + if !ok || tag == "" { + continue + } + if err := q.UpsertUnit(ctx, db.UpsertUnitParams{ + SystemID: systemDBID, + UnitID: int64(srcFloat), + Label: sql.NullString{String: tag, Valid: true}, + }); err != nil { + slog.Warn("failed to upsert unit from sources", + "unit_id", int64(srcFloat), "tag", tag, "error", err) + } + } +} + +// extractPrimarySource returns the "src" value from the first entry in a +// sources JSON array. Trunk-recorder sends unit IDs only inside this array +// (e.g. [{"pos":0,"src":12345}, ...]) and does not set a top-level "source". +func extractPrimarySource(raw string) sql.NullInt64 { + var sources []map[string]any + if err := json.Unmarshal([]byte(raw), &sources); err != nil || len(sources) == 0 { + return sql.NullInt64{} + } + srcVal, ok := sources[0]["src"] + if !ok { + return sql.NullInt64{} + } + srcFloat, ok := srcVal.(float64) + if !ok || srcFloat <= 0 { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: int64(srcFloat), Valid: true} +} + +// extractPrimarySourceTag returns the "tag" value from the first source entry +// that has a non-empty tag. Trunk-recorder sends OTA aliases (talker alias) +// inside the sources JSON rather than as a top-level "talkerAlias" field. +func extractPrimarySourceTag(raw string) sql.NullString { + var sources []map[string]any + if err := json.Unmarshal([]byte(raw), &sources); err != nil { + return sql.NullString{} + } + for _, entry := range sources { + tagVal, ok := entry["tag"] + if !ok { + continue + } + tag, ok := tagVal.(string) + if !ok || tag == "" { + continue + } + return sql.NullString{String: tag, Valid: true} + } + return sql.NullString{} +} + +// aggregateErrorSpikeCounts sums errorCount and spikeCount from all entries +// in a frequencies JSON array. Trunk-recorder sends per-segment values inside +// this array (e.g. [{"errorCount":2,"spikeCount":0}, ...]) rather than +// providing aggregate top-level fields. +func aggregateErrorSpikeCounts(raw string) (sql.NullInt64, sql.NullInt64) { + var freqs []map[string]any + if err := json.Unmarshal([]byte(raw), &freqs); err != nil || len(freqs) == 0 { + return sql.NullInt64{}, sql.NullInt64{} + } + var totalErrors, totalSpikes int64 + var found bool + for _, entry := range freqs { + if v, ok := entry["errorCount"]; ok { + if f, ok := v.(float64); ok { + totalErrors += int64(f) + found = true + } + } + // trunk-recorder also uses "error_count" in its call JSON. + if v, ok := entry["error_count"]; ok { + if f, ok := v.(float64); ok { + totalErrors += int64(f) + found = true + } + } + if v, ok := entry["spikeCount"]; ok { + if f, ok := v.(float64); ok { + totalSpikes += int64(f) + found = true + } + } + if v, ok := entry["spike_count"]; ok { + if f, ok := v.(float64); ok { + totalSpikes += int64(f) + found = true + } + } + } + if !found { + return sql.NullInt64{}, sql.NullInt64{} + } + return sql.NullInt64{Int64: totalErrors, Valid: true}, + sql.NullInt64{Int64: totalSpikes, Valid: true} +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..882c0ae --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,192 @@ +package middleware + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" +) + +// JWTAuth validates a Bearer JWT and stores userID, username, and role in the +// Gin context. Aborts with 401 if the token is missing or invalid. +func JWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + slog.Debug("middleware: jwt auth failed, no bearer header", "path", c.Request.URL.Path) + c.AbortWithStatusJSON(401, gin.H{"error": "authorization header required"}) + return + } + + tokenStr := strings.TrimPrefix(header, "Bearer ") + claims, err := auth.ParseToken(tokenStr) + if err != nil { + c.AbortWithStatusJSON(401, gin.H{"error": "invalid or expired token"}) + return + } + + if auth.Tokens.IsRevoked(claims.ID) { + c.AbortWithStatusJSON(401, gin.H{"error": "token has been revoked"}) + return + } + + // Check account expiration embedded in JWT claims (OWASP A01). + if claims.AccountExp > 0 && time.Now().Unix() > claims.AccountExp { + c.AbortWithStatusJSON(401, gin.H{"error": "account expired"}) + return + } + + slog.Debug("middleware: jwt auth success", "user_id", claims.UserID, "role", claims.Role) + c.Set("userID", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + c.Set("jti", claims.ID) + c.Next() + } +} + +// OptionalJWTAuth extracts user info from a Bearer JWT if present, but does not +// abort the request when the token is missing or invalid. Useful for endpoints +// that are publicly accessible but provide extra data to authenticated users. +func OptionalJWTAuth() gin.HandlerFunc { + return func(c *gin.Context) { + header := c.GetHeader("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + c.Next() + return + } + + tokenStr := strings.TrimPrefix(header, "Bearer ") + claims, err := auth.ParseToken(tokenStr) + if err != nil { + c.Next() + return + } + + if auth.Tokens.IsRevoked(claims.ID) { + c.Next() + return + } + + // Check account expiration embedded in JWT claims (OWASP A01). + if claims.AccountExp > 0 && time.Now().Unix() > claims.AccountExp { + c.Next() + return + } + + c.Set("userID", claims.UserID) + c.Set("username", claims.Username) + c.Set("role", claims.Role) + c.Set("jti", claims.ID) + c.Next() + } +} + +// RequireAdmin checks that the authenticated user has the admin role. +// Must be chained after JWTAuth. Aborts with 403 if the role is not admin. +func RequireAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get("role") + roleStr, _ := role.(string) + if roleStr != auth.RoleAdmin { + slog.Debug("middleware: admin check failed", "role", roleStr) + c.AbortWithStatusJSON(403, gin.H{"error": "admin access required"}) + return + } + slog.Debug("middleware: admin check passed") + c.Next() + } +} + +// APIKeyAuth reads the API key from the X-API-Key header, ?key= query param, +// or (for Trunk Recorder compatibility) a multipart "key" form field — in that +// order. It looks up the key in the database and sets "apiKeyID" in the Gin +// context. Aborts with 401 if the key is missing, not found, or disabled. +func APIKeyAuth(queries *db.Queries) gin.HandlerFunc { + return func(c *gin.Context) { + requestID, _ := c.Get("requestID") + + // Prefer header, then query string. Only fall back to PostForm + // (which parses the entire multipart body) when both are empty. + key := c.GetHeader("X-API-Key") + if key == "" { + key = c.Query("key") + } + if key == "" { + // Trunk Recorder's rdioscanner_uploader plugin sends the API key + // as a multipart form field named "key" rather than a header. + key = c.PostForm("key") + } + if key == "" { + slog.Warn("api key auth: missing X-API-Key header", + "request_id", requestID, + "ip", c.ClientIP(), + "path", c.Request.URL.Path, + ) + c.AbortWithStatusJSON(401, gin.H{"error": "API key required"}) + return + } + + // Reject implausibly long keys before hashing (defense-in-depth; real + // keys are 64 hex chars). Prevents CPU waste on attacker-controlled input. + if len(key) > 128 { + slog.Warn("api key auth: oversized key rejected", + "request_id", requestID, + "ip", c.ClientIP(), + "path", c.Request.URL.Path, + "length", len(key), + ) + c.AbortWithStatusJSON(401, gin.H{"error": "invalid API key"}) + return + } + + hashed := auth.HashAPIKey(key) + apiKey, err := queries.GetAPIKeyByKey(c.Request.Context(), hashed) + if err != nil { + slog.Warn("api key auth: invalid key", + "request_id", requestID, + "ip", c.ClientIP(), + "path", c.Request.URL.Path, + ) + c.AbortWithStatusJSON(401, gin.H{"error": "invalid API key"}) + return + } + if apiKey.Disabled != 0 { + slog.Warn("api key auth: disabled key used", + "request_id", requestID, + "ip", c.ClientIP(), + "path", c.Request.URL.Path, + "api_key_id", apiKey.ID, + ) + c.AbortWithStatusJSON(401, gin.H{"error": "API key is disabled"}) + return + } + + c.Set("apiKeyID", apiKey.ID) + if apiKey.CallRateLimit.Valid { + c.Set("apiKeyCallRate", apiKey.CallRateLimit.Int64) + } + slog.Debug("middleware: api key auth success", + "api_key_id", apiKey.ID, + "ident", apiKey.Ident.String, + "path", c.Request.URL.Path, + ) + c.Next() + } +} + +// SwaggerCookieAuth validates the short-lived docs session cookie. +func SwaggerCookieAuth() gin.HandlerFunc { + return func(c *gin.Context) { + value, err := c.Cookie(auth.SwaggerCookieName) + if err != nil || !auth.ValidateSwaggerCookie(value) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "swagger session required"}) + return + } + c.Next() + } +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..6b1afbe --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,69 @@ +package middleware + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +// CORS handles Cross-Origin Resource Sharing. +// In production the frontend is served from the same origin so cross-origin +// requests are rejected. The allowed origin is derived from the request's own +// Host header (same-origin only). Preflight requests are handled with 204. +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.GetHeader("Origin") + if origin == "" { + c.Next() + return + } + + // Default to same-origin: the Origin must match the Host. + host := c.Request.Host + // Build the expected origin from the request scheme + host. + scheme := "http" + if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { + scheme = "https" + } + expected := scheme + "://" + host + allowed := origin == expected + + isLocalhost := func(h string) bool { + h = strings.ToLower(h) + return h == "localhost" || h == "127.0.0.1" + } + + // Dev/local exception: allow localhost frontend origins on different ports + // when backend is also running on localhost. Only active when Gin is in + // debug mode; release builds enforce strict same-origin. + if !allowed && gin.Mode() == gin.DebugMode { + if u, err := url.Parse(origin); err == nil { + if reqHost, reqErr := url.Parse(scheme + "://" + host); reqErr == nil { + if isLocalhost(u.Hostname()) && isLocalhost(reqHost.Hostname()) { + allowed = true + } + } + } + } + + if !allowed { + c.AbortWithStatus(http.StatusForbidden) + return + } + + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key") + c.Header("Access-Control-Max-Age", "86400") + c.Header("Vary", "Origin") + + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} diff --git a/backend/internal/middleware/limits.go b/backend/internal/middleware/limits.go new file mode 100644 index 0000000..03ac14e --- /dev/null +++ b/backend/internal/middleware/limits.go @@ -0,0 +1,83 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/openscanner/openscanner/internal/auth" +) + +// RateLimit returns middleware that rejects requests with 429 if the client IP +// is locked out by the given rate limiter. +func RateLimit(rl *auth.RateLimiter) gin.HandlerFunc { + return func(c *gin.Context) { + if rl.IsLockedOut(c.ClientIP()) { + c.AbortWithStatusJSON(429, gin.H{"error": "too many failed attempts, try again later"}) + return + } + c.Next() + } +} + +// MaxBodySize limits the size of request bodies to prevent memory exhaustion. +// Applies to non-multipart requests only (multipart is limited by +// router.MaxMultipartMemory). +func MaxBodySize(maxBytes int64) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body != nil { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) + } + c.Next() + } +} + +// ipBucket is a per-IP sliding-window counter. +type ipBucket struct { + windowStart time.Time + count int +} + +// RateLimitByIP returns middleware that limits requests per IP per minute. +// Designed for unauthenticated, public endpoints (e.g. shared call access). +func RateLimitByIP(rpm int) gin.HandlerFunc { + var mu sync.Mutex + buckets := make(map[string]*ipBucket) + window := time.Minute + + return func(c *gin.Context) { + ip := c.ClientIP() + now := time.Now() + + mu.Lock() + + // Periodic cleanup: remove stale entries to bound memory. + if len(buckets) > 1000 { + for k, b := range buckets { + if now.Sub(b.windowStart) >= 2*window { + delete(buckets, k) + } + } + } + + b, ok := buckets[ip] + if !ok { + b = &ipBucket{windowStart: now} + buckets[ip] = b + } + if now.Sub(b.windowStart) >= window { + b.windowStart = now + b.count = 0 + } + if b.count >= rpm { + mu.Unlock() + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"}) + return + } + b.count++ + mu.Unlock() + + c.Next() + } +} diff --git a/backend/internal/middleware/logging.go b/backend/internal/middleware/logging.go new file mode 100644 index 0000000..0a096d1 --- /dev/null +++ b/backend/internal/middleware/logging.go @@ -0,0 +1,73 @@ +package middleware + +import ( + "log/slog" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func requestLogLevel(status int) string { + switch { + case status >= http.StatusInternalServerError: + return "error" + case status >= http.StatusBadRequest: + return "warn" + default: + return "info" + } +} + +// RequestID adds a UUID v4 X-Request-ID response header and stores it in the +// Gin context under the key "requestID". +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := uuid.New().String() + c.Set("requestID", requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} + +// Logger emits a structured slog line for every request including method, path, +// status code, latency, request ID, and client IP. +// Health check probes and CORS preflight requests are logged at Debug only so +// they don't drown out real traffic in normal operation. +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + latency := time.Since(start) + status := c.Writer.Status() + level := requestLogLevel(status) + + var slogLevel slog.Level + switch level { + case "error": + slogLevel = slog.LevelError + case "warn": + slogLevel = slog.LevelWarn + default: + slogLevel = slog.LevelInfo + } + + // Demote noisy low-signal endpoints to Debug when they succeed. + path := c.Request.URL.Path + if slogLevel == slog.LevelInfo && + (path == "/api/health" || c.Request.Method == http.MethodOptions) { + slogLevel = slog.LevelDebug + } + + requestID, _ := c.Get("requestID") + slog.Log(c.Request.Context(), slogLevel, "request", + "method", c.Request.Method, + "path", path, + "status", status, + "latency_ms", latency.Milliseconds(), + "request_id", requestID, + "ip", c.ClientIP(), + ) + } +} diff --git a/backend/internal/middleware/middleware.go b/backend/internal/middleware/middleware.go index 3be99b5..6e41c58 100644 --- a/backend/internal/middleware/middleware.go +++ b/backend/internal/middleware/middleware.go @@ -1,392 +1,2 @@ // Package middleware contains Gin middleware: JWT auth, API key auth, rate limiting, request ID (UUID v4), logging, CORS. package middleware - -import ( - "log/slog" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/openscanner/openscanner/internal/auth" - "github.com/openscanner/openscanner/internal/db" -) - -func requestLogLevel(status int) string { - switch { - case status >= http.StatusInternalServerError: - return "error" - case status >= http.StatusBadRequest: - return "warn" - default: - return "info" - } -} - -// RequestID adds a UUID v4 X-Request-ID response header and stores it in the -// Gin context under the key "requestID". -func RequestID() gin.HandlerFunc { - return func(c *gin.Context) { - requestID := uuid.New().String() - c.Set("requestID", requestID) - c.Header("X-Request-ID", requestID) - c.Next() - } -} - -// CORS handles Cross-Origin Resource Sharing. -// In production the frontend is served from the same origin so cross-origin -// requests are rejected. The allowed origin is derived from the request's own -// Host header (same-origin only). Preflight requests are handled with 204. -func CORS() gin.HandlerFunc { - return func(c *gin.Context) { - origin := c.GetHeader("Origin") - if origin == "" { - c.Next() - return - } - - // Default to same-origin: the Origin must match the Host. - host := c.Request.Host - // Build the expected origin from the request scheme + host. - scheme := "http" - if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" { - scheme = "https" - } - expected := scheme + "://" + host - allowed := origin == expected - - isLocalhost := func(h string) bool { - h = strings.ToLower(h) - return h == "localhost" || h == "127.0.0.1" - } - - // Dev/local exception: allow localhost frontend origins on different ports - // when backend is also running on localhost. Only active when Gin is in - // debug mode; release builds enforce strict same-origin. - if !allowed && gin.Mode() == gin.DebugMode { - if u, err := url.Parse(origin); err == nil { - if reqHost, reqErr := url.Parse(scheme + "://" + host); reqErr == nil { - if isLocalhost(u.Hostname()) && isLocalhost(reqHost.Hostname()) { - allowed = true - } - } - } - } - - if !allowed { - c.AbortWithStatus(http.StatusForbidden) - return - } - - c.Header("Access-Control-Allow-Origin", origin) - c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key") - c.Header("Access-Control-Max-Age", "86400") - c.Header("Vary", "Origin") - - if c.Request.Method == http.MethodOptions { - c.AbortWithStatus(http.StatusNoContent) - return - } - - c.Next() - } -} - -// Logger emits a structured slog line for every request including method, path, -// status code, latency, request ID, and client IP. -// Health check probes and CORS preflight requests are logged at Debug only so -// they don't drown out real traffic in normal operation. -func Logger() gin.HandlerFunc { - return func(c *gin.Context) { - start := time.Now() - c.Next() - latency := time.Since(start) - status := c.Writer.Status() - level := requestLogLevel(status) - - var slogLevel slog.Level - switch level { - case "error": - slogLevel = slog.LevelError - case "warn": - slogLevel = slog.LevelWarn - default: - slogLevel = slog.LevelInfo - } - - // Demote noisy low-signal endpoints to Debug when they succeed. - path := c.Request.URL.Path - if slogLevel == slog.LevelInfo && - (path == "/api/health" || c.Request.Method == http.MethodOptions) { - slogLevel = slog.LevelDebug - } - - requestID, _ := c.Get("requestID") - slog.Log(c.Request.Context(), slogLevel, "request", - "method", c.Request.Method, - "path", path, - "status", status, - "latency_ms", latency.Milliseconds(), - "request_id", requestID, - "ip", c.ClientIP(), - ) - } -} - -// JWTAuth validates a Bearer JWT and stores userID, username, and role in the -// Gin context. Aborts with 401 if the token is missing or invalid. -func JWTAuth() gin.HandlerFunc { - return func(c *gin.Context) { - header := c.GetHeader("Authorization") - if !strings.HasPrefix(header, "Bearer ") { - slog.Debug("middleware: jwt auth failed, no bearer header", "path", c.Request.URL.Path) - c.AbortWithStatusJSON(401, gin.H{"error": "authorization header required"}) - return - } - - tokenStr := strings.TrimPrefix(header, "Bearer ") - claims, err := auth.ParseToken(tokenStr) - if err != nil { - c.AbortWithStatusJSON(401, gin.H{"error": "invalid or expired token"}) - return - } - - if auth.Tokens.IsRevoked(claims.ID) { - c.AbortWithStatusJSON(401, gin.H{"error": "token has been revoked"}) - return - } - - // Check account expiration embedded in JWT claims (OWASP A01). - if claims.AccountExp > 0 && time.Now().Unix() > claims.AccountExp { - c.AbortWithStatusJSON(401, gin.H{"error": "account expired"}) - return - } - - slog.Debug("middleware: jwt auth success", "user_id", claims.UserID, "role", claims.Role) - c.Set("userID", claims.UserID) - c.Set("username", claims.Username) - c.Set("role", claims.Role) - c.Set("jti", claims.ID) - c.Next() - } -} - -// OptionalJWTAuth extracts user info from a Bearer JWT if present, but does not -// abort the request when the token is missing or invalid. Useful for endpoints -// that are publicly accessible but provide extra data to authenticated users. -func OptionalJWTAuth() gin.HandlerFunc { - return func(c *gin.Context) { - header := c.GetHeader("Authorization") - if !strings.HasPrefix(header, "Bearer ") { - c.Next() - return - } - - tokenStr := strings.TrimPrefix(header, "Bearer ") - claims, err := auth.ParseToken(tokenStr) - if err != nil { - c.Next() - return - } - - if auth.Tokens.IsRevoked(claims.ID) { - c.Next() - return - } - - // Check account expiration embedded in JWT claims (OWASP A01). - if claims.AccountExp > 0 && time.Now().Unix() > claims.AccountExp { - c.Next() - return - } - - c.Set("userID", claims.UserID) - c.Set("username", claims.Username) - c.Set("role", claims.Role) - c.Set("jti", claims.ID) - c.Next() - } -} - -// RequireAdmin checks that the authenticated user has the admin role. -// Must be chained after JWTAuth. Aborts with 403 if the role is not admin. -func RequireAdmin() gin.HandlerFunc { - return func(c *gin.Context) { - role, _ := c.Get("role") - roleStr, _ := role.(string) - if roleStr != auth.RoleAdmin { - slog.Debug("middleware: admin check failed", "role", roleStr) - c.AbortWithStatusJSON(403, gin.H{"error": "admin access required"}) - return - } - slog.Debug("middleware: admin check passed") - c.Next() - } -} - -// APIKeyAuth reads the API key from the X-API-Key header, ?key= query param, -// or (for Trunk Recorder compatibility) a multipart "key" form field — in that -// order. It looks up the key in the database and sets "apiKeyID" in the Gin -// context. Aborts with 401 if the key is missing, not found, or disabled. -func APIKeyAuth(queries *db.Queries) gin.HandlerFunc { - return func(c *gin.Context) { - requestID, _ := c.Get("requestID") - - // Prefer header, then query string. Only fall back to PostForm - // (which parses the entire multipart body) when both are empty. - key := c.GetHeader("X-API-Key") - if key == "" { - key = c.Query("key") - } - if key == "" { - // Trunk Recorder's rdioscanner_uploader plugin sends the API key - // as a multipart form field named "key" rather than a header. - key = c.PostForm("key") - } - if key == "" { - slog.Warn("api key auth: missing X-API-Key header", - "request_id", requestID, - "ip", c.ClientIP(), - "path", c.Request.URL.Path, - ) - c.AbortWithStatusJSON(401, gin.H{"error": "API key required"}) - return - } - - // Reject implausibly long keys before hashing (defense-in-depth; real - // keys are 64 hex chars). Prevents CPU waste on attacker-controlled input. - if len(key) > 128 { - slog.Warn("api key auth: oversized key rejected", - "request_id", requestID, - "ip", c.ClientIP(), - "path", c.Request.URL.Path, - "length", len(key), - ) - c.AbortWithStatusJSON(401, gin.H{"error": "invalid API key"}) - return - } - - hashed := auth.HashAPIKey(key) - apiKey, err := queries.GetAPIKeyByKey(c.Request.Context(), hashed) - if err != nil { - slog.Warn("api key auth: invalid key", - "request_id", requestID, - "ip", c.ClientIP(), - "path", c.Request.URL.Path, - ) - c.AbortWithStatusJSON(401, gin.H{"error": "invalid API key"}) - return - } - if apiKey.Disabled != 0 { - slog.Warn("api key auth: disabled key used", - "request_id", requestID, - "ip", c.ClientIP(), - "path", c.Request.URL.Path, - "api_key_id", apiKey.ID, - ) - c.AbortWithStatusJSON(401, gin.H{"error": "API key is disabled"}) - return - } - - c.Set("apiKeyID", apiKey.ID) - if apiKey.CallRateLimit.Valid { - c.Set("apiKeyCallRate", apiKey.CallRateLimit.Int64) - } - slog.Debug("middleware: api key auth success", - "api_key_id", apiKey.ID, - "ident", apiKey.Ident.String, - "path", c.Request.URL.Path, - ) - c.Next() - } -} - -// RateLimit returns middleware that rejects requests with 429 if the client IP -// is locked out by the given rate limiter. -func RateLimit(rl *auth.RateLimiter) gin.HandlerFunc { - return func(c *gin.Context) { - if rl.IsLockedOut(c.ClientIP()) { - c.AbortWithStatusJSON(429, gin.H{"error": "too many failed attempts, try again later"}) - return - } - c.Next() - } -} - -// MaxBodySize limits the size of request bodies to prevent memory exhaustion. -// Applies to non-multipart requests only (multipart is limited by -// router.MaxMultipartMemory). -func MaxBodySize(maxBytes int64) gin.HandlerFunc { - return func(c *gin.Context) { - if c.Request.Body != nil { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes) - } - c.Next() - } -} - -// SwaggerCookieAuth validates the short-lived docs session cookie. -func SwaggerCookieAuth() gin.HandlerFunc { - return func(c *gin.Context) { - value, err := c.Cookie(auth.SwaggerCookieName) - if err != nil || !auth.ValidateSwaggerCookie(value) { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "swagger session required"}) - return - } - c.Next() - } -} - -// ipBucket is a per-IP sliding-window counter. -type ipBucket struct { - windowStart time.Time - count int -} - -// RateLimitByIP returns middleware that limits requests per IP per minute. -// Designed for unauthenticated, public endpoints (e.g. shared call access). -func RateLimitByIP(rpm int) gin.HandlerFunc { - var mu sync.Mutex - buckets := make(map[string]*ipBucket) - window := time.Minute - - return func(c *gin.Context) { - ip := c.ClientIP() - now := time.Now() - - mu.Lock() - - // Periodic cleanup: remove stale entries to bound memory. - if len(buckets) > 1000 { - for k, b := range buckets { - if now.Sub(b.windowStart) >= 2*window { - delete(buckets, k) - } - } - } - - b, ok := buckets[ip] - if !ok { - b = &ipBucket{windowStart: now} - buckets[ip] = b - } - if now.Sub(b.windowStart) >= window { - b.windowStart = now - b.count = 0 - } - if b.count >= rpm { - mu.Unlock() - c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"}) - return - } - b.count++ - mu.Unlock() - - c.Next() - } -}