diff --git a/CHANGELOG.md b/CHANGELOG.md index 999fd03..4defae3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Native `/api/v1/*` REST surface alongside the existing legacy routes. All v1 responses use a structured error envelope (`{"error":{"code","message","details"}}`) with stable string codes (`validation_failed`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `unprocessable`, `rate_limited`, `internal`); 5xx envelopes include the request ID under `details.requestId`. +- v1 call-upload endpoint (`POST /api/v1/calls`) with native multipart field names (`systemId`, `talkgroupId`, `startedAt`, `frequencyHz`, `durationMs`, `unitId`) and RFC 3339 `startedAt` enforcement (unix timestamps no longer accepted on v1). Companion `POST /api/v1/calls/test` returns 204 on a valid API key. +- v1 listener endpoints: `GET/PUT /api/v1/listener/tg-selection` (renamed from `/api/auth/tg-selection`), `GET /api/v1/calls`, `GET /api/v1/calls/:id/audio`, `GET /api/v1/calls/:id/transcript`, share/bookmark endpoints, and unauthenticated `/api/v1/health`, `/api/v1/setup/*`, `/api/v1/auth/{login,refresh,logout,password,me}`. +- v1 admin endpoints under `/api/v1/admin/*` for talkgroup/unit/group/tag imports, RadioReference preview (path simplified — no `/csv` suffix), transcription status, and Swagger session bootstrap. + +### Changed + +- API-key authentication on `/api/v1/*` upload routes accepts only `Authorization: Bearer `; the legacy `X-API-Key` header, `?key=` query parameter, and `key=` form field continue to work on legacy routes only. JWT-shaped Bearer tokens on v1 API-key routes are rejected with `invalid_credentials`. + ## [1.2.1] — 2026-04-25 ### Security diff --git a/backend/internal/handler/calls/upload_v1.go b/backend/internal/handler/calls/upload_v1.go new file mode 100644 index 0000000..c6f1703 --- /dev/null +++ b/backend/internal/handler/calls/upload_v1.go @@ -0,0 +1,604 @@ +// Phase N-1 — native /api/v1/calls upload handler. +// +// Differs from the legacy /api/call-upload handler in three ways: +// +// 1. Multipart field names are the canonical native set per +// docs/plans/native-api-design-plan.md §5: systemId, talkgroupId, +// startedAt, frequencyHz, durationMs, unitId, talkerAlias, etc. +// The legacy aliases (system, talkgroup, dateTime, frequency, duration, +// source) are NOT accepted on v1. +// +// 2. startedAt MUST be RFC 3339; unix-timestamp values are rejected with +// a 400 validation_failed envelope. +// +// 3. All 4xx/5xx responses use the v1 error envelope (shared.WriteAPIError). +// The form-field `key=` auth transport is not honoured (Bearer only — +// enforced by APIKeyAuth + V1Marker). +// +// The post-validation ingest pipeline (system/talkgroup resolve, blacklist +// check, duplicate detection, audio store, DB insert, WS broadcast, +// downstream notify, transcription enqueue) is intentionally kept structurally +// equivalent to the legacy handler so a future refactor can extract a shared +// core without behavioural drift between the two paths. +package calls + +import ( + "database/sql" + "errors" + "log/slog" + "net/http" + "path/filepath" + "strconv" + "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" +) + +// PostCallUploadV1 handles POST /api/v1/calls — the native upload endpoint. +// +// @Summary Upload a call recording (native v1) +// @Description Ingest a radio call with audio and metadata using the native field names. Requires Authorization: Bearer . The startedAt field must be RFC 3339; unix timestamps are rejected. +// @Tags v1-Calls +// @Accept multipart/form-data +// @Produce json +// @Security BearerAPIKey +// @Param audio formData file true "Audio file" +// @Param startedAt formData string true "RFC 3339 start timestamp" +// @Param systemId formData int true "Radio system ID" +// @Param talkgroupId formData int true "Talkgroup ID" +// @Param unitId formData int false "Source unit ID" +// @Param frequencyHz formData int false "Frequency in Hz" +// @Param durationMs formData int false "Call duration in milliseconds" +// @Param talkgroupLabel formData string false "Talkgroup label" +// @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 talkerAlias formData string false "OTA talker alias" +// @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" +// @Param sources formData string false "JSON array of per-segment source units" +// @Param frequencies formData string false "JSON array of per-segment frequencies" +// @Param patches formData string false "JSON array of patched talkgroup IDs" +// @Success 200 {object} object{id=int64,message=string} "Call ingested" +// @Failure 400 {object} shared.APIErrorResponse "Validation failed" +// @Failure 401 {object} shared.APIErrorResponse "Invalid credentials" +// @Failure 422 {object} shared.APIErrorResponse "Unprocessable entity (system/talkgroup not configured)" +// @Failure 429 {object} shared.APIErrorResponse "Rate limit exceeded" +// @Failure 500 {object} shared.APIErrorResponse "Internal error" +// @Router /v1/calls [post] +func (h *Handler) PostCallUploadV1(c *gin.Context) { + apiKeyIDVal, exists := c.Get("apiKeyID") + if !exists { + shared.WriteAPIError(c, http.StatusUnauthorized, shared.CodeInvalidCredentials, "API key required", nil) + return + } + apiKeyID, ok := apiKeyIDVal.(int64) + if !ok { + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + + // Per-API-key rate limiting (mirrors the legacy handler). + 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("v1 call upload rate limit exceeded", "api_key_id", apiKeyID) + shared.WriteAPIError(c, http.StatusTooManyRequests, shared.CodeRateLimited, + "rate limit exceeded", map[string]any{"retryAfterSeconds": 60}) + return + } + + // Parse and validate native multipart fields. + startedAt := c.PostForm("startedAt") + if startedAt == "" { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "startedAt is required", map[string]any{"field": "startedAt"}) + return + } + // Native rejects unix timestamps explicitly: a value that parses as a + // pure int64 is reported back so the recorder operator can fix their + // integration before silently sending bad data. + if _, intErr := strconv.ParseInt(startedAt, 10, 64); intErr == nil { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "startedAt must be an RFC 3339 timestamp", + map[string]any{"field": "startedAt", "got": startedAt}) + return + } + var callTime time.Time + if t, err := time.Parse(time.RFC3339Nano, startedAt); err == nil { + callTime = t + } else if t, err := time.Parse(time.RFC3339, startedAt); err == nil { + callTime = t + } else { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "startedAt must be an RFC 3339 timestamp", + map[string]any{"field": "startedAt", "got": startedAt}) + return + } + dateTimeUnix := callTime.Unix() + + systemIDStr := c.PostForm("systemId") + if systemIDStr == "" { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "systemId is required", map[string]any{"field": "systemId"}) + return + } + systemIDRaw, err := strconv.ParseInt(systemIDStr, 10, 64) + if err != nil { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "systemId must be an integer", map[string]any{"field": "systemId"}) + return + } + + talkgroupIDStr := c.PostForm("talkgroupId") + if talkgroupIDStr == "" { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "talkgroupId is required", map[string]any{"field": "talkgroupId"}) + return + } + talkgroupIDRaw, err := strconv.ParseInt(talkgroupIDStr, 10, 64) + if err != nil { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "talkgroupId must be an integer", map[string]any{"field": "talkgroupId"}) + return + } + + fh, err := c.FormFile("audio") + if err != nil { + shared.WriteAPIError(c, http.StatusBadRequest, shared.CodeValidationFailed, + "audio file is required", map[string]any{"field": "audio"}) + return + } + + // Optional native fields. + var frequency, duration, source sql.NullInt64 + if v := c.PostForm("frequencyHz"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + frequency = sql.NullInt64{Int64: n, Valid: true} + } + } + if v := c.PostForm("durationMs"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + duration = sql.NullInt64{Int64: n, Valid: true} + } + } + if v := c.PostForm("unitId"); 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} + } + if !source.Valid && sourcesJSON.Valid { + source = extractPrimarySource(sourcesJSON.String) + } + if !errorCount.Valid && !spikeCount.Valid && frequenciesJSON.Valid { + errorCount, spikeCount = aggregateErrorSpikeCounts(frequenciesJSON.String) + } + + 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} + } + + 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} + } + if !talkerAliasCol.Valid && sourcesJSON.Valid { + talkerAliasCol = extractPrimarySourceTag(sourcesJSON.String) + } + + ctx := c.Request.Context() + autoPopulateSystems := shared.GetSettingValue(c, h.queries, "autoPopulateSystems") == "true" + + // Resolve system. + system, err := h.queries.GetSystemBySystemID(ctx, systemIDRaw) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + slog.Error("v1 upload: failed to query system", "system_id", systemIDRaw, "error", err) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + if !autoPopulateSystems { + shared.WriteAPIError(c, http.StatusUnprocessableEntity, shared.CodeSystemNotFound, + "system is not configured and autoPopulateSystems is disabled", + map[string]any{"systemId": systemIDRaw}) + return + } + label := strconv.FormatInt(systemIDRaw, 10) + 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("v1 upload: failed to auto-create system", "system_id", systemIDRaw, "error", cerr) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + slog.Info("v1 upload: 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 — same observable behaviour as legacy (200 with a hint). + if isBlacklistedTG(system.BlacklistsJson, talkgroupIDRaw) { + slog.Info("v1 upload: talkgroup blacklisted", + "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) + c.JSON(http.StatusOK, gin.H{"status": "blacklisted"}) + return + } + + // Resolve talkgroup. + talkgroup, err := h.queries.GetTalkgroupBySystemAndTGID(ctx, db.GetTalkgroupBySystemAndTGIDParams{ + SystemID: system.ID, + TalkgroupID: talkgroupIDRaw, + }) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + slog.Error("v1 upload: failed to query talkgroup", + "system_id", system.ID, "talkgroup_id", talkgroupIDRaw, "error", err) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + if system.AutoPopulateTalkgroups == 0 { + shared.WriteAPIError(c, http.StatusUnprocessableEntity, shared.CodeTalkgroupNotFound, + "talkgroup is not configured for this system", + map[string]any{"systemId": systemIDRaw, "talkgroupId": talkgroupIDRaw}) + return + } + var tgLabel, tgName sql.NullString + if talkgroupLabel != "" { + tgLabel = sql.NullString{String: talkgroupLabel, Valid: true} + } + if talkgroupName != "" { + tgName = sql.NullString{String: talkgroupName, Valid: true} + } + var groupID sql.NullInt64 + if talkgroupGroup != "" { + groupID = shared.ResolveGroupID(ctx, h.queries, talkgroupGroup) + } + 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("v1 upload: failed to auto-create talkgroup", + "talkgroup_id", talkgroupIDRaw, "error", cerr) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + slog.Info("v1 upload: 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) { + 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("v1 upload: failed to backfill talkgroup", + "talkgroup_id", talkgroup.TalkgroupID, "error", uerr) + } else { + h.hub.BroadcastCFG(ctx) + } + } + + // Duplicate detection — same window + same observable response shape as + // the legacy handler, but signalled via 409 to match the v1 contract. + 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("v1 upload: duplicate detection failed", "error", derr) + } else if dup { + slog.Info("v1 upload: duplicate rejected", + "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw) + shared.WriteAPIError(c, http.StatusConflict, shared.CodeDuplicateCall, + "a call with the same system, talkgroup, and startedAt already exists", + map[string]any{"systemId": systemIDRaw, "talkgroupId": talkgroupIDRaw}) + return + } + } + + // Audio storage. + convMode := audio.ConversionDisabled + if mStr := shared.GetSettingValue(c, h.queries, "audioConversion"); mStr != "" { + if m, err := strconv.Atoi(mStr); err == nil { + convMode = audio.ConversionMode(m) + } + } + convPreset := audio.ParseEncodingPreset(shared.GetSettingValue(c, h.queries, "audioEncodingPreset")) + + relPath, err := h.processor.Store(ctx, fh, convMode, convPreset) + if err != nil { + slog.Error("v1 upload: failed to store audio", + "system_id", systemIDRaw, "talkgroup_id", talkgroupIDRaw, "error", err) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "failed to store audio", nil) + return + } + + 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} + } + } + + 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" + } + } + + 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("v1 upload: failed to insert call", "error", err) + shared.WriteAPIError(c, http.StatusInternalServerError, shared.CodeInternalError, "internal error", nil) + return + } + + if sourcesJSON.Valid { + upsertUnitsFromSources(ctx, h.queries, system.ID, sourcesJSON.String) + } + 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("v1 upload: failed to upsert unit from talkerAlias", + "unit_id", source.Int64, "error", err) + } + } + + // Broadcast over the legacy CAL channel — Phase N-2 will introduce the + // native call.new JSON-object shape on /api/v1/ws/listener. + if h.hub != nil { + 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 + } + if calMsg, err := ws.NewCALMessage(calPayload); err == nil { + h.hub.BroadcastCAL(calMsg, func(cl *ws.Client) bool { + return cl.CanReceive(system.ID, talkgroup.ID) + }) + } else { + slog.Error("v1 upload: build CAL message failed", "error", err) + } + } + + slog.Info("v1 upload: complete", + "call_id", callID, + "system_id", systemIDRaw, + "talkgroup_id", talkgroupIDRaw, + "audio_path", relPath, + "api_key_id", apiKeyID, + ) + + c.JSON(http.StatusOK, gin.H{"id": callID, "message": "Call imported successfully."}) + + // Downstream + transcription — mirrors legacy handler. + if h.dsNotifier != nil { + 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, + }) + } + 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("v1 upload: failed to enqueue transcription", + "call_id", callID, "error", err) + } + } +} + +// PostCallsTestV1 handles POST /api/v1/calls/test — a connectivity check used +// by recorders to validate their upload config without uploading audio. +// +// @Summary Connectivity check +// @Description Validates the Bearer API key and returns 204 No Content. Used by recorder plugins to verify their upload configuration without uploading audio. +// @Tags v1-Calls +// @Security BearerAPIKey +// @Success 204 "OK — credentials valid" +// @Failure 401 {object} shared.APIErrorResponse "Invalid credentials" +// @Router /v1/calls/test [post] +func (h *Handler) PostCallsTestV1(c *gin.Context) { + if _, ok := c.Get("apiKeyID"); !ok { + shared.WriteAPIError(c, http.StatusUnauthorized, shared.CodeInvalidCredentials, "API key required", nil) + return + } + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/handler/calls/v1_test.go b/backend/internal/handler/calls/v1_test.go new file mode 100644 index 0000000..b07f41f --- /dev/null +++ b/backend/internal/handler/calls/v1_test.go @@ -0,0 +1,540 @@ +package calls_test + +// Phase N-1 — integration tests for the native /api/v1/* call surface. +// +// Covers: +// - Happy path POST /api/v1/calls with native field names + Bearer API key. +// - Each validation failure mode of POST /api/v1/calls (missing fields, +// unix-timestamp startedAt, RFC 3339 happy path, missing audio). +// - The connectivity check POST /api/v1/calls/test → 204. +// - The renamed listener tg-selection endpoint at /api/v1/listener/tg-selection. +// - The native error envelope shape on at least one 400, 401, 403, and 404 +// response. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/audio" + "github.com/openscanner/openscanner/internal/auth" + "github.com/openscanner/openscanner/internal/db" + "github.com/openscanner/openscanner/internal/handler/routes" + "github.com/openscanner/openscanner/internal/logging" +) + +func init() { + gin.SetMode(gin.TestMode) + logging.Configure(true, "") +} + +// engineV1 is a v1-flavoured copy of engineWithCalls used by the legacy +// contract tests. Same wiring; only renamed for clarity. +func engineV1(t *testing.T) (*gin.Engine, *db.Queries) { + t.Helper() + sqlDB, err := db.Open(":memory:") + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + q := db.New(sqlDB) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + pool := audio.NewWorkerPool(ctx) + proc := audio.NewProcessor(t.TempDir(), pool) + + r := gin.New() + rl := auth.NewRateLimiter(context.Background()) + routes.RegisterRoutes(r, routes.Deps{ + Queries: q, + RateLimiter: rl, + Processor: proc, + Version: "test", + }) + return r, q +} + +// seedV1Settings enables auto-populate so the upload happy-path doesn't 422. +func seedV1Settings(t *testing.T, q *db.Queries) { + t.Helper() + ctx := context.Background() + for k, v := range map[string]string{ + "autoPopulateSystems": "true", + "audioConversion": "0", + "disableDuplicateDetection": "true", + } { + if err := q.UpsertSetting(ctx, db.UpsertSettingParams{Key: k, Value: v}); err != nil { + t.Fatalf("UpsertSetting %q: %v", k, err) + } + } +} + +func seedV1APIKey(t *testing.T, q *db.Queries, raw string) { + t.Helper() + if _, err := q.CreateAPIKey(context.Background(), db.CreateAPIKeyParams{ + Key: auth.HashAPIKey(raw), + Disabled: 0, + }); err != nil { + t.Fatalf("CreateAPIKey: %v", err) + } +} + +// buildV1Upload writes a native-shaped multipart body. Caller adds the audio +// file plus any additional fields they need. +func buildV1Upload(t *testing.T, fields map[string]string, withAudio bool) (*bytes.Buffer, string) { + t.Helper() + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + for k, v := range fields { + if err := w.WriteField(k, v); err != nil { + t.Fatalf("WriteField %q: %v", k, err) + } + } + if withAudio { + fw, err := w.CreateFormFile("audio", "test.wav") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + _, _ = fw.Write([]byte("RIFF\x24\x00\x00\x00WAVEfmt ")) + } + _ = w.Close() + return &buf, w.FormDataContentType() +} + +// TestPostV1Calls_HappyPath verifies the native upload happy path: +// Authorization: Bearer , native field names, RFC 3339 startedAt. +func TestPostV1Calls_HappyPath(t *testing.T) { + const apiKey = "v1-bearer-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + seedV1Settings(t, q) + + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "startedAt": time.Now().UTC().Format(time.RFC3339), + }, true) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp struct { + ID int64 `json:"id"` + Message string `json:"message"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, w.Body.String()) + } + if resp.ID <= 0 { + t.Errorf("id = %d, want > 0", resp.ID) + } +} + +// TestPostV1Calls_RejectsLegacyAuthTransports — only Authorization: Bearer is +// honoured on the v1 surface. X-API-Key header, ?key= query param, and form +// field "key" must all 401. +func TestPostV1Calls_RejectsLegacyAuthTransports(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + seedV1Settings(t, q) + + tests := []struct { + name string + setup func(req *http.Request) + }{ + { + name: "X-API-Key header rejected", + setup: func(req *http.Request) { + req.Header.Set("X-API-Key", apiKey) + }, + }, + { + name: "?key= query rejected", + setup: func(req *http.Request) { + req.URL.RawQuery = "key=" + apiKey + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "startedAt": time.Now().UTC().Format(time.RFC3339), + }, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + tc.setup(req) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body: %s", w.Code, w.Body.String()) + } + assertV1ErrorEnvelope(t, w.Body.Bytes()) + }) + } +} + +// TestPostV1Calls_ValidationFailed_StartedAtUnix pins that a unix-timestamp +// startedAt is rejected with the v1 envelope. +func TestPostV1Calls_ValidationFailed_StartedAtUnix(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + seedV1Settings(t, q) + + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "startedAt": strconv.FormatInt(time.Now().Unix(), 10), + }, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "validation_failed" { + t.Errorf("code = %v, want validation_failed", env["code"]) + } + details, _ := env["details"].(map[string]any) + if details["field"] != "startedAt" { + t.Errorf("details.field = %v, want startedAt", details["field"]) + } + if _, ok := details["got"]; !ok { + t.Errorf("details.got missing; want the offending value to be echoed back") + } +} + +// TestPostV1Calls_ValidationFailed_MissingFields covers each required-field +// failure path of the native upload. +func TestPostV1Calls_ValidationFailed_MissingFields(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + seedV1Settings(t, q) + + cases := []struct { + name string + fields map[string]string + withAudio bool + wantField string + }{ + { + name: "missing startedAt", + fields: map[string]string{"systemId": "1", "talkgroupId": "100"}, + withAudio: true, + wantField: "startedAt", + }, + { + name: "missing systemId", + fields: map[string]string{"talkgroupId": "100", "startedAt": time.Now().UTC().Format(time.RFC3339)}, + withAudio: true, + wantField: "systemId", + }, + { + name: "missing talkgroupId", + fields: map[string]string{"systemId": "1", "startedAt": time.Now().UTC().Format(time.RFC3339)}, + withAudio: true, + wantField: "talkgroupId", + }, + { + name: "missing audio", + fields: map[string]string{"systemId": "1", "talkgroupId": "100", "startedAt": time.Now().UTC().Format(time.RFC3339)}, + withAudio: false, + wantField: "audio", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body, ct := buildV1Upload(t, tc.fields, tc.withAudio) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + details, _ := env["details"].(map[string]any) + if details["field"] != tc.wantField { + t.Errorf("details.field = %v, want %q", details["field"], tc.wantField) + } + }) + } +} + +// TestPostV1Calls_SystemNotFound returns 422 when auto-populate is disabled. +func TestPostV1Calls_SystemNotFound(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + // Don't seed autoPopulateSystems=true. + _ = q.UpsertSetting(context.Background(), db.UpsertSettingParams{Key: "audioConversion", Value: "0"}) + + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "999", + "talkgroupId": "100", + "startedAt": time.Now().UTC().Format(time.RFC3339), + }, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, want 422; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "system_not_found" { + t.Errorf("code = %v, want system_not_found", env["code"]) + } +} + +// TestPostV1Calls_RejectJWTBearer pins that a JWT-shaped Bearer token sent to +// an API-key endpoint returns invalid_credentials, not "API key required". +func TestPostV1Calls_RejectJWTBearer(t *testing.T) { + engine, q := engineV1(t) + seedV1APIKey(t, q, "real-key") + seedV1Settings(t, q) + + // Mint a real user JWT — the same shape an interactive client would + // use. Should be refused on this Bearer-API-key route. + hash, _ := auth.HashPassword("pw") + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "alice", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "alice", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + body, ct := buildV1Upload(t, map[string]string{ + "systemId": "1", + "talkgroupId": "100", + "startedAt": time.Now().UTC().Format(time.RFC3339), + }, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls", body) + req.Header.Set("Content-Type", ct) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "invalid_credentials" { + t.Errorf("code = %v, want invalid_credentials", env["code"]) + } +} + +// TestPostV1Calls_TestEndpoint_204 — connectivity check returns 204 with no +// body when the Bearer key is valid, 401 otherwise. +func TestPostV1Calls_TestEndpoint(t *testing.T) { + const apiKey = "v1-key" + engine, q := engineV1(t) + seedV1APIKey(t, q, apiKey) + + // Valid: 204. + req := httptest.NewRequest(http.MethodPost, "/api/v1/calls/test", nil) + req.Header.Set("Authorization", "Bearer "+apiKey) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body: %s", w.Code, w.Body.String()) + } + if w.Body.Len() != 0 { + t.Errorf("body = %q, want empty", w.Body.String()) + } + + // Missing auth: 401 with v1 envelope. + req = httptest.NewRequest(http.MethodPost, "/api/v1/calls/test", nil) + w = httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body: %s", w.Code, w.Body.String()) + } + assertV1ErrorEnvelope(t, w.Body.Bytes()) +} + +// TestV1ErrorEnvelope_403_AdminRequired pins the v1 forbidden envelope shape. +func TestV1ErrorEnvelope_403_AdminRequired(t *testing.T) { + engine, q := engineV1(t) + hash, _ := auth.HashPassword("pw") + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "bob", PasswordHash: hash, Role: auth.RoleListener, + CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "bob", auth.RoleListener, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/transcriptions/status", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "forbidden" { + t.Errorf("code = %v, want forbidden", env["code"]) + } +} + +// TestV1ErrorEnvelope_401_MissingJWT pins the v1 unauthorized envelope on a +// JWT-protected route reached without credentials. +func TestV1ErrorEnvelope_401_MissingJWT(t *testing.T) { + engine, _ := engineV1(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] != "invalid_credentials" { + t.Errorf("code = %v, want invalid_credentials", env["code"]) + } +} + +// TestV1ListenerTGSelection_RenamedPath — the renamed v1 endpoint reaches the +// same handler body as the legacy /api/auth/tg-selection. +func TestV1ListenerTGSelection_RenamedPath(t *testing.T) { + engine, q := engineV1(t) + hash, _ := auth.HashPassword("pw") + uid, err := q.CreateUser(context.Background(), db.CreateUserParams{ + Username: "carol", PasswordHash: hash, Role: auth.RoleAdmin, + CreatedAt: time.Now().Unix(), UpdatedAt: time.Now().Unix(), + }) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + tok, _, err := auth.GenerateToken(uid, "carol", auth.RoleAdmin, 0) + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/listener/tg-selection", nil) + req.Header.Set("Authorization", "Bearer "+tok) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } +} + +// TestV1Health_Unauthenticated pins that GET /api/v1/health is reachable +// without auth and returns the same shape as the legacy health route. +func TestV1Health_Unauthenticated(t *testing.T) { + engine, _ := engineV1(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + var resp struct { + Status string `json:"status"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Status != "ok" { + t.Errorf("status = %q, want ok", resp.Status) + } +} + +// TestV1Calls_GetSearch_NotFound — GET /api/v1/calls/{id}/audio for a +// non-existent id surfaces a v1 envelope from the shared handler via the +// rewriter middleware. +func TestV1Calls_GetAudio_NotFound(t *testing.T) { + engine, q := engineV1(t) + _ = q.UpsertSetting(context.Background(), db.UpsertSettingParams{Key: "publicAccess", Value: "true"}) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/calls/%d/audio", 9999999), nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body: %s", w.Code, w.Body.String()) + } + env := assertV1ErrorEnvelope(t, w.Body.Bytes()) + if env["code"] == nil { + t.Errorf("v1 404 envelope missing code: %v", env) + } +} + +// assertV1ErrorEnvelope decodes a body that is expected to match +// {"error": {"code":..., "message":..., "details"?:...}}, returning the +// inner error object for further assertions. +func assertV1ErrorEnvelope(t *testing.T, body []byte) map[string]any { + t.Helper() + var resp map[string]any + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("envelope decode: %v\nbody: %s", err, body) + } + errVal, ok := resp["error"].(map[string]any) + if !ok { + t.Fatalf("missing/non-object error field; body: %s", body) + } + if _, ok := errVal["code"].(string); !ok { + t.Fatalf("envelope missing string code; got %v", errVal) + } + if _, ok := errVal["message"].(string); !ok { + t.Fatalf("envelope missing string message; got %v", errVal) + } + // details is optional and may be a map or absent. + if d, ok := errVal["details"]; ok { + if _, isMap := d.(map[string]any); !isMap && d != nil { + t.Fatalf("details must be object or absent; got %T", d) + } + } + return errVal +} + +// silence unused-imports lint when test compilation skips a branch above +var _ = sql.ErrNoRows diff --git a/backend/internal/handler/routes/routes.go b/backend/internal/handler/routes/routes.go index 24c2be0..4e750dd 100644 --- a/backend/internal/handler/routes/routes.go +++ b/backend/internal/handler/routes/routes.go @@ -22,10 +22,10 @@ import ( "github.com/openscanner/openscanner/internal/auth" "github.com/openscanner/openscanner/internal/db" "github.com/openscanner/openscanner/internal/downstream" - authhandler "github.com/openscanner/openscanner/internal/handler/auth" "github.com/openscanner/openscanner/internal/handler/admin/imports" "github.com/openscanner/openscanner/internal/handler/admin/radioreference" "github.com/openscanner/openscanner/internal/handler/admin/transcriptions" + authhandler "github.com/openscanner/openscanner/internal/handler/auth" "github.com/openscanner/openscanner/internal/handler/bookmarks" "github.com/openscanner/openscanner/internal/handler/calls" "github.com/openscanner/openscanner/internal/handler/health" @@ -189,6 +189,75 @@ func RegisterRoutes(r *gin.Engine, deps Deps) { r.GET("/ws", listenerWS) r.GET("/api/admin/ws", gin.WrapF(ws.HandleAdminWS(deps.Hub, deps.Queries))) + // ----- Native API (Phase N-1, plan §4.1) --------------------------------- + // All v1 routes carry the V1Marker so version-aware middleware can branch, + // and the V1ErrorEnvelope rewriter normalises any 4xx/5xx body emitted by + // shared handlers into the native {error:{code,message,details}} shape. + v1 := r.Group("/api/v1") + v1.Use(middleware.V1Marker(), middleware.V1ErrorEnvelope()) + + // Unauthenticated. + v1.GET("/health", healthHandler.Get) + v1.GET("/setup/status", setupHandler.GetSetupStatus) + v1.POST("/setup", middleware.MaxBodySize(1<<20), setupHandler.PostSetup) + v1.POST("/auth/login", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostLogin) + v1.POST("/auth/refresh", middleware.MaxBodySize(1<<20), middleware.RateLimit(deps.RateLimiter), authH.PostRefresh) + + // Public call surfaces (optional auth, share links). + v1.GET("/calls", middleware.OptionalJWTAuth(), callHandler.GetCalls) + v1.GET("/calls/:id/audio", middleware.OptionalJWTOrSessionAuth(), callHandler.GetCallAudio) + v1.GET("/calls/:id/transcript", middleware.OptionalJWTAuth(), callHandler.GetCallTranscript) + v1SharedRateLimit := middleware.RateLimitByIP(30) + v1.GET("/shared/:token", v1SharedRateLimit, shareHandler.GetSharedCallByToken) + v1.GET("/shared/:token/audio", v1SharedRateLimit, shareHandler.GetSharedCallAudio) + + // JWT-protected v1 routes. + v1Auth := v1.Group("") + v1Auth.Use(middleware.JWTAuth()) + { + v1Auth.POST("/auth/logout", authH.PostLogout) + v1Auth.PUT("/auth/password", authH.PutPassword) + v1Auth.GET("/auth/me", authH.GetMe) + // /api/auth/tg-selection is renamed to /api/v1/listener/tg-selection + // per plan §4.1; the handler body is reused unchanged. + v1Auth.GET("/listener/tg-selection", authH.GetTGSelection) + v1Auth.PUT("/listener/tg-selection", authH.PutTGSelection) + v1Auth.POST("/calls/:id/share", shareHandler.PostShareCall) + v1Auth.DELETE("/calls/:id/share", shareHandler.DeleteShareCall) + v1Auth.GET("/calls/:id/share", shareHandler.GetCallShare) + v1Auth.GET("/bookmarks", bookmarkHandler.GetBookmarkIDs) + v1Auth.GET("/bookmarks/calls", bookmarkHandler.GetBookmarkCalls) + v1Auth.POST("/bookmarks", bookmarkHandler.PostToggleBookmark) + } + + // Native upload — Authorization: Bearer only (enforced by + // APIKeyAuth's v1 branch, keyed off V1Marker). + v1Upload := v1.Group("") + v1Upload.Use(middleware.MaxBodySize(50<<20), middleware.APIKeyAuth(deps.Queries)) + { + v1Upload.POST("/calls", callHandler.PostCallUploadV1) + v1Upload.POST("/calls/test", callHandler.PostCallsTestV1) + } + + // Admin v1 routes. + v1Admin := v1.Group("/admin") + v1Admin.Use(middleware.JWTAuth(), middleware.RequireAdmin(), middleware.MaxBodySize(2<<20)) + { + v1Admin.POST("/import/talkgroups", importsHandler.ImportTalkgroups) + v1Admin.POST("/import/units", importsHandler.ImportUnits) + v1Admin.POST("/import/groups", importsHandler.ImportGroups) + v1Admin.POST("/import/tags", importsHandler.ImportTags) + // Plan §4.1 drops the trailing `/csv` segment on the v1 path. + v1Admin.POST("/radioreference/preview", rrHandler.PreviewCSV) + v1Admin.GET("/transcriptions/status", transcriptionsHandler.GetStatus) + v1Admin.POST("/docs/session", authhandler.PostDocsSession) + } + v1SwaggerDocs := v1.Group("/admin/docs") + v1SwaggerDocs.Use(middleware.SwaggerCookieAuth()) + { + v1SwaggerDocs.GET("/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + } + // Serve embedded frontend (SPA mode). serveFrontend(r) } diff --git a/backend/internal/handler/shared/errors.go b/backend/internal/handler/shared/errors.go new file mode 100644 index 0000000..6021fa8 --- /dev/null +++ b/backend/internal/handler/shared/errors.go @@ -0,0 +1,104 @@ +// Phase N-1 — RFC-style error envelope used by every /api/v1/* handler. +// +// All native v1 error responses share the shape: +// +// { +// "error": { +// "code": "validation_failed", +// "message": "human-readable english", +// "details": { ... } // optional, endpoint-specific +// } +// } +// +// See docs/plans/native-api-design-plan.md §7 for the full contract. +package shared + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Native v1 error codes. Not exhaustive — handlers may emit endpoint-specific +// codes — but these are the canonical ones used by the middleware-level +// envelope rewriter and the most common 4xx/5xx branches. +const ( + CodeValidationFailed = "validation_failed" + CodeInvalidCredentials = "invalid_credentials" + CodeForbidden = "forbidden" + CodeNotFound = "not_found" + CodeCallNotFound = "call_not_found" + CodeConflict = "conflict" + CodeDuplicateCall = "duplicate_call" + CodeUnprocessable = "unprocessable" + CodeSystemNotFound = "system_not_found" + CodeTalkgroupNotFound = "talkgroup_not_found" + CodeRateLimited = "rate_limited" + CodeInternalError = "internal_error" +) + +// APIError is the inner object of the native v1 error envelope. +type APIError struct { + Code string `json:"code"` // stable machine identifier + Message string `json:"message"` // human-readable English + Details map[string]any `json:"details,omitempty"` // endpoint-specific +} // @name APIError + +// APIErrorResponse is the outer JSON envelope: `{"error": {...}}`. +type APIErrorResponse struct { + Error APIError `json:"error"` +} // @name APIErrorResponse + +// WriteAPIError emits the native error envelope and aborts the gin context. +// +// For 5xx responses, the request id (set by middleware.RequestID) is auto- +// injected into details.requestId so operators can correlate the response +// with server logs without leaking other internals. +func WriteAPIError(c *gin.Context, status int, code, message string, details map[string]any) { + if status >= http.StatusInternalServerError { + if rid, ok := c.Get("requestID"); ok { + if ridStr, ok := rid.(string); ok && ridStr != "" { + if details == nil { + details = map[string]any{} + } + if _, exists := details["requestId"]; !exists { + details["requestId"] = ridStr + } + } + } + } + c.AbortWithStatusJSON(status, APIErrorResponse{ + Error: APIError{ + Code: code, + Message: message, + Details: details, + }, + }) +} + +// DefaultCodeForStatus returns the canonical v1 error code for a status code. +// Used by the envelope-rewriter middleware to translate legacy +// `{"error": "..."}` responses emitted by shared handlers into the native shape. +func DefaultCodeForStatus(status int) string { + switch status { + case http.StatusBadRequest: + return CodeValidationFailed + case http.StatusUnauthorized: + return CodeInvalidCredentials + case http.StatusForbidden: + return CodeForbidden + case http.StatusNotFound: + return CodeNotFound + case http.StatusConflict: + return CodeConflict + case http.StatusUnprocessableEntity: + return CodeUnprocessable + case http.StatusTooManyRequests: + return CodeRateLimited + default: + if status >= 500 { + return CodeInternalError + } + return CodeValidationFailed + } +} diff --git a/backend/internal/handler/shared/errors_test.go b/backend/internal/handler/shared/errors_test.go new file mode 100644 index 0000000..d8cfc0f --- /dev/null +++ b/backend/internal/handler/shared/errors_test.go @@ -0,0 +1,91 @@ +package shared + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestWriteAPIError_TableDriven(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + status int + code string + message string + details map[string]any + injectReqID string + wantReqID bool + }{ + {"400 validation_failed", http.StatusBadRequest, CodeValidationFailed, "talkgroupId required", map[string]any{"field": "talkgroupId"}, "", false}, + {"401 invalid_credentials", http.StatusUnauthorized, CodeInvalidCredentials, "bearer missing", nil, "", false}, + {"403 forbidden", http.StatusForbidden, CodeForbidden, "admin required", nil, "", false}, + {"404 call_not_found", http.StatusNotFound, CodeCallNotFound, "no call with id 1", map[string]any{"id": 1}, "", false}, + {"409 duplicate_call", http.StatusConflict, CodeDuplicateCall, "dup", map[string]any{"existingId": 9001}, "", false}, + {"422 unprocessable", http.StatusUnprocessableEntity, CodeSystemNotFound, "system 502 not configured", map[string]any{"systemId": 502}, "", false}, + {"429 rate_limited", http.StatusTooManyRequests, CodeRateLimited, "slow down", map[string]any{"retryAfterSeconds": 30}, "", false}, + {"500 internal_error injects requestId", http.StatusInternalServerError, CodeInternalError, "boom", nil, "req-xyz", true}, + {"500 keeps existing requestId in details", http.StatusInternalServerError, CodeInternalError, "boom", map[string]any{"requestId": "preset"}, "req-xyz", true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + if tc.injectReqID != "" { + c.Set("requestID", tc.injectReqID) + } + WriteAPIError(c, tc.status, tc.code, tc.message, tc.details) + + if w.Code != tc.status { + t.Fatalf("status = %d, want %d", w.Code, tc.status) + } + var env APIErrorResponse + if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\nbody: %s", err, w.Body.String()) + } + if env.Error.Code != tc.code { + t.Errorf("code = %q, want %q", env.Error.Code, tc.code) + } + if env.Error.Message != tc.message { + t.Errorf("message = %q, want %q", env.Error.Message, tc.message) + } + if tc.wantReqID { + rid, ok := env.Error.Details["requestId"].(string) + if !ok || rid == "" { + t.Errorf("expected details.requestId to be set; got %v", env.Error.Details) + } + // Existing preset values must be preserved (not overwritten). + if pre, ok := tc.details["requestId"].(string); ok && pre != "" { + if rid != pre { + t.Errorf("requestId overwrote preset value: got %q, want %q", rid, pre) + } + } + } + }) + } +} + +func TestDefaultCodeForStatus(t *testing.T) { + cases := map[int]string{ + http.StatusBadRequest: CodeValidationFailed, + http.StatusUnauthorized: CodeInvalidCredentials, + http.StatusForbidden: CodeForbidden, + http.StatusNotFound: CodeNotFound, + http.StatusConflict: CodeConflict, + http.StatusUnprocessableEntity: CodeUnprocessable, + http.StatusTooManyRequests: CodeRateLimited, + http.StatusInternalServerError: CodeInternalError, + http.StatusServiceUnavailable: CodeInternalError, + 418: CodeValidationFailed, // unknown 4xx → validation_failed default + } + for status, want := range cases { + if got := DefaultCodeForStatus(status); got != want { + t.Errorf("DefaultCodeForStatus(%d) = %q, want %q", status, got, want) + } + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 051a2f1..c1c51a9 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -182,24 +182,62 @@ func RequireAdmin() gin.HandlerFunc { } } -// 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. +// APIKeyAuth reads the API key from the API-key transports allowed for the +// active API version, then 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. +// +// On legacy paths the three rdio-scanner-style transports are accepted, in +// priority order: X-API-Key header, ?key= query param, and (for Trunk +// Recorder's rdioscanner_uploader plugin) a multipart "key" form field. +// +// On the native /api/v1/* surface — identified by the gin context flag +// "apiVersion" == "v1", set by V1Marker() on the v1 route group — only +// Authorization: Bearer is honoured. JWT-shaped Bearer values +// (three base64url segments separated by dots) are rejected with the +// invalid_credentials envelope so the client surfaces the right error. +// +// The API-key value format itself is unchanged; only the wire transport +// differs between the two surfaces. func APIKeyAuth(queries *db.Queries) gin.HandlerFunc { return func(c *gin.Context) { requestID, _ := c.Get("requestID") + isV1 := c.GetString("apiVersion") == "v1" - // 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") + // Resolve the API key string from the transports allowed for this + // API version. Legacy: header → query → form (rdio-scanner-shaped). + // Native v1: Authorization: Bearer only. + var key string + if isV1 { + header := c.GetHeader("Authorization") + if strings.HasPrefix(header, "Bearer ") { + key = strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + } + // Reject JWT-shaped tokens here so Bearer-JWT-on-API-key-route + // returns the canonical invalid_credentials envelope rather than + // "API key required". + if key != "" && looksLikeJWT(key) { + slog.Warn("api key auth (v1): rejected JWT-shaped bearer", + "request_id", requestID, + "ip", c.ClientIP(), + "path", c.Request.URL.Path, + ) + c.AbortWithStatusJSON(401, gin.H{ + "error": gin.H{ + "code": "invalid_credentials", + "message": "API key required (Authorization: Bearer)", + }, + }) + return + } + } else { + key = c.GetHeader("X-API-Key") + if key == "" { + key = c.Query("key") + } + if key == "" { + key = c.PostForm("key") + } } if key == "" { slog.Warn("api key auth: missing X-API-Key header", @@ -259,6 +297,26 @@ func APIKeyAuth(queries *db.Queries) gin.HandlerFunc { } } +// looksLikeJWT returns true when s has the structural shape of a JWT: +// three non-empty base64url segments separated by two dots. Used by the v1 +// APIKeyAuth path to surface a clearer error when a caller mistakenly sends +// a user JWT to an API-key-protected endpoint. +func looksLikeJWT(s string) bool { + if len(s) < 20 { + return false + } + if strings.Count(s, ".") != 2 { + return false + } + parts := strings.Split(s, ".") + for _, p := range parts { + if p == "" { + return false + } + } + return true +} + // SwaggerCookieAuth validates the short-lived docs session cookie. func SwaggerCookieAuth() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/internal/middleware/v1.go b/backend/internal/middleware/v1.go new file mode 100644 index 0000000..5fc335a --- /dev/null +++ b/backend/internal/middleware/v1.go @@ -0,0 +1,152 @@ +// Phase N-1 — middleware specific to the native /api/v1/* surface. +// +// V1Marker tags the gin context so version-aware middleware (currently +// APIKeyAuth) can branch without doing URL-prefix matching. +// +// V1ErrorEnvelope post-processes responses written by handlers that still +// use the legacy `{"error": ""}` shape (notably JWTAuth, RequireAdmin, +// and routes shared between legacy and v1) so that v1 callers always observe +// the canonical {error: {code, message, details}} envelope. +package middleware + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/openscanner/openscanner/internal/handler/shared" +) + +// V1Marker tags the gin context as part of the /api/v1/* surface. +// +// Other middleware (notably APIKeyAuth) reads c.GetString("apiVersion") to +// branch behaviour by API version without resorting to URL-prefix matching. +func V1Marker() gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("apiVersion", "v1") + c.Next() + } +} + +// envelopeRewriter is a gin.ResponseWriter that buffers the body so it can be +// optionally rewritten before being flushed to the client. +type envelopeRewriter struct { + gin.ResponseWriter + buf bytes.Buffer + status int +} + +func (w *envelopeRewriter) WriteHeader(status int) { + w.status = status + // Defer to embedded writer when we flush. Don't propagate yet. +} + +func (w *envelopeRewriter) Write(p []byte) (int, error) { + return w.buf.Write(p) +} + +func (w *envelopeRewriter) WriteString(s string) (int, error) { + return w.buf.WriteString(s) +} + +// V1ErrorEnvelope rewrites legacy `{"error":""}` 4xx/5xx response +// bodies into the native v1 envelope. Bodies already in the native shape +// (`{"error":{"code":...}}`) are passed through untouched, so handlers that +// emit the native envelope directly (the v1 upload handler, etc.) are not +// double-wrapped. +// +// 2xx responses are never rewritten. +func V1ErrorEnvelope() gin.HandlerFunc { + return func(c *gin.Context) { + // Wrap the writer so we can inspect/rewrite the body after the + // handler chain runs. + orig := c.Writer + rw := &envelopeRewriter{ResponseWriter: orig, status: 0} + c.Writer = rw + defer func() { + c.Writer = orig + }() + + c.Next() + + status := rw.status + if status == 0 { + status = orig.Status() + } + if status == 0 { + status = http.StatusOK + } + + body := rw.buf.Bytes() + + // Pass through 2xx untouched. + if status < 400 { + orig.WriteHeader(status) + if len(body) > 0 { + _, _ = orig.Write(body) + } + return + } + + // Try to detect the legacy `{"error":""}` shape. If anything + // else (already native, plain text, empty), pass through unchanged. + rewritten, ok := rewriteLegacyError(body, status) + if ok { + orig.Header().Set("Content-Type", "application/json; charset=utf-8") + orig.WriteHeader(status) + _, _ = orig.Write(rewritten) + return + } + + orig.WriteHeader(status) + if len(body) > 0 { + _, _ = orig.Write(body) + } + } +} + +// rewriteLegacyError attempts to translate a legacy `{"error":""}` +// body into the v1 envelope. Returns (newBody, true) on success, (nil,false) +// when the body is not a legacy error shape and should be left alone. +func rewriteLegacyError(body []byte, status int) ([]byte, bool) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 || trimmed[0] != '{' { + return nil, false + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &raw); err != nil { + return nil, false + } + errVal, ok := raw["error"] + if !ok { + return nil, false + } + // Already-native shape: error is an object with a "code" field. Don't + // touch. + if len(errVal) > 0 && errVal[0] == '{' { + var inner map[string]json.RawMessage + if err := json.Unmarshal(errVal, &inner); err == nil { + if _, hasCode := inner["code"]; hasCode { + return nil, false + } + } + } + // Legacy shape: error is a JSON string. + var msg string + if err := json.Unmarshal(errVal, &msg); err != nil { + return nil, false + } + env := shared.APIErrorResponse{ + Error: shared.APIError{ + Code: shared.DefaultCodeForStatus(status), + Message: msg, + }, + } + out, err := json.Marshal(env) + if err != nil { + return nil, false + } + return out, true +}