diff --git a/backend/internal/handler/file_handler.go b/backend/internal/handler/file_handler.go index 9cd15ac..f092e2c 100644 --- a/backend/internal/handler/file_handler.go +++ b/backend/internal/handler/file_handler.go @@ -407,9 +407,20 @@ func (h *FileHandler) GetContent(c *gin.Context) { if c.Query("inline") == "1" { disposition = "inline" } + name := "" if res.OriginalName != nil { + name = *res.OriginalName c.Header("Content-Disposition", - fmt.Sprintf("%s; filename=%q", disposition, *res.OriginalName)) + fmt.Sprintf("%s; filename=%q", disposition, name)) + } + + // Serve with byte-range support when the body is seekable (it is for the + // disk store): http.ServeContent advertises Accept-Ranges and answers Range + // requests with 206 Partial Content, which is what lets the browser scrub and + // seek within audio/video. Fall back to a plain stream otherwise. + if seeker, ok := res.Body.(io.ReadSeeker); ok { + http.ServeContent(c.Writer, c.Request, name, time.Time{}, seeker) + return } c.Status(http.StatusOK) io.Copy(c.Writer, res.Body) //nolint:errcheck diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index 697c5b0..ba35c17 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -955,6 +955,34 @@ func TestImportFromFolder(t *testing.T) { assert.True(t, ct.Equal(mtime), "content_datetime %v should equal mtime %v", ct, mtime) } +// TestContentRangeRequests verifies the original-content endpoint answers a +// byte-range request with 206 Partial Content (so the browser can seek within +// audio/video) rather than streaming the whole body. +func TestContentRangeRequests(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, "clip.jpg") + id := file["id"].(string) + + req, err := http.NewRequest("GET", h.url("/files/"+id+"/content?inline=1"), nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Range", "bytes=0-9") + resp, err := h.client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, http.StatusPartialContent, resp.StatusCode) + assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges")) + assert.Regexp(t, `^bytes 0-9/\d+$`, resp.Header.Get("Content-Range")) + require.Len(t, body, 10) + assert.Equal(t, minimalJPEG()[:10], body) +} + // TestBlockRevokesActiveSessions verifies that blocking a user immediately // invalidates their outstanding access tokens. func TestBlockRevokesActiveSessions(t *testing.T) {