Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
113 changes: 113 additions & 0 deletions backend/internal/handler/calls/audio.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading