diff --git a/backend/internal/handler/file_handler.go b/backend/internal/handler/file_handler.go index a71abc4..9cd15ac 100644 --- a/backend/internal/handler/file_handler.go +++ b/backend/internal/handler/file_handler.go @@ -402,9 +402,14 @@ func (h *FileHandler) GetContent(c *gin.Context) { c.Header("Content-Type", res.MIMEType) c.Header("Cache-Control", "private, max-age=3600") + // Default to attachment (download); ?inline=1 serves it for in-tab viewing. + disposition := "attachment" + if c.Query("inline") == "1" { + disposition = "inline" + } if res.OriginalName != nil { c.Header("Content-Disposition", - fmt.Sprintf("attachment; filename=%q", *res.OriginalName)) + fmt.Sprintf("%s; filename=%q", disposition, *res.OriginalName)) } c.Status(http.StatusOK) io.Copy(c.Writer, res.Body) //nolint:errcheck diff --git a/backend/internal/handler/middleware.go b/backend/internal/handler/middleware.go index b5c95b4..4467ff8 100644 --- a/backend/internal/handler/middleware.go +++ b/backend/internal/handler/middleware.go @@ -24,8 +24,8 @@ func NewAuthMiddleware(authSvc *service.AuthService) *AuthMiddleware { // On success it calls c.Next(); on failure it aborts with 401 JSON. func (m *AuthMiddleware) Handle() gin.HandlerFunc { return func(c *gin.Context) { - raw := c.GetHeader("Authorization") - if !strings.HasPrefix(raw, "Bearer ") { + token := bearerToken(c) + if token == "" { c.JSON(http.StatusUnauthorized, errorBody{ Code: domain.ErrUnauthorized.Code(), Message: "authorization header missing or malformed", @@ -33,7 +33,6 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc { c.Abort() return } - token := strings.TrimPrefix(raw, "Bearer ") claims, err := m.authSvc.ValidateAccessToken(c.Request.Context(), token) if err != nil { @@ -50,3 +49,18 @@ func (m *AuthMiddleware) Handle() gin.HandlerFunc { c.Next() } } + +// bearerToken extracts the access token from the Authorization header. As a +// fallback it accepts an ?access_token= query parameter, but only for GET +// requests — this lets the browser open media (e.g. /files/{id}/content) via a +// plain link/new tab, where it can't send the header, without allowing a crafted +// link to drive a state-changing request. +func bearerToken(c *gin.Context) string { + if raw := c.GetHeader("Authorization"); strings.HasPrefix(raw, "Bearer ") { + return strings.TrimPrefix(raw, "Bearer ") + } + if c.Request.Method == http.MethodGet { + return c.Query("access_token") + } + return "" +} diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index 6669473..ad28f6c 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -762,6 +762,32 @@ func TestBulkTagAutoRule(t *testing.T) { assert.ElementsMatch(t, []string{"outdoor", "nature"}, names) } +// TestMediaQueryTokenAuth verifies the ?access_token= fallback: it authenticates +// a GET (so media can be opened via a plain link/new tab) but is rejected for a +// non-GET, and a missing token is still 401. +func TestMediaQueryTokenAuth(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + h := setupSuite(t) + token := h.login("admin", "admin") + file := h.uploadJPEG(token, "q.jpg") + fileID := file["id"].(string) + + // GET with token in the query, no Authorization header → 200. + resp := h.do("GET", "/files/"+fileID+"/content?access_token="+token, nil, "", "") + require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) + + // No token anywhere → 401. + resp = h.do("GET", "/files/"+fileID+"/content", nil, "", "") + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + // Query token must NOT authorize a state-changing (non-GET) request → 401. + resp = h.do("DELETE", "/files/"+fileID+"?access_token="+token, nil, "", "") + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String()) +} + // --------------------------------------------------------------------------- // Security regression tests // --------------------------------------------------------------------------- diff --git a/frontend/src/lib/components/file/FileViewer.svelte b/frontend/src/lib/components/file/FileViewer.svelte index eb31787..241293f 100644 --- a/frontend/src/lib/components/file/FileViewer.svelte +++ b/frontend/src/lib/components/file/FileViewer.svelte @@ -101,6 +101,16 @@ } } + // Direct link to the full-resolution original, opened in a new tab. A + // navigation can't send the auth header, so the token rides in the query — + // the server accepts ?access_token= for GET media. Reactive on the token so a + // silent refresh keeps the link valid. + let originalUrl = $derived( + fileId + ? `/api/v1/files/${fileId}/content?inline=1&access_token=${encodeURIComponent($authStore.accessToken ?? '')}` + : '#' + ); + // ---- Tags (lazy) ---- // Fetch the current file's tags the first time the Tags section is visible. // Re-runs when fileId changes while the section is still on-screen. @@ -222,7 +232,15 @@