2 Commits

Author SHA1 Message Date
H1K0 76dcb8721a fix(backend): serve original content with byte-range support
deploy / deploy (push) Successful in 1m1s
GetContent streamed the whole file with a plain 200/io.Copy and no
Accept-Ranges, so the browser couldn't seek or scrub audio/video opened
from the viewer. It now serves seekable bodies (the disk store returns an
*os.File) via http.ServeContent, which advertises Accept-Ranges and
answers Range requests with 206 Partial Content; non-seekable bodies
still fall back to a plain stream. Adds an integration test asserting a
ranged request returns 206 with the right Content-Range and bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:40:49 +03:00
H1K0 cefa33c00d feat(frontend): focus the returned file when leaving the viewer
Closing the viewer (or returning via a deep-link ?anchor=) already
scrolled the grid back to the file you were on, but the keyboard
roving-focus stayed unset, so the next arrow press jumped to the top.
Both return paths now place the focus on that file and show the ring, so
arrow navigation resumes exactly where you left off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 18:33:30 +03:00
3 changed files with 47 additions and 1 deletions
+12 -1
View File
@@ -407,9 +407,20 @@ func (h *FileHandler) GetContent(c *gin.Context) {
if c.Query("inline") == "1" { if c.Query("inline") == "1" {
disposition = "inline" disposition = "inline"
} }
name := ""
if res.OriginalName != nil { if res.OriginalName != nil {
name = *res.OriginalName
c.Header("Content-Disposition", 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) c.Status(http.StatusOK)
io.Copy(c.Writer, res.Body) //nolint:errcheck io.Copy(c.Writer, res.Body) //nolint:errcheck
@@ -955,6 +955,34 @@ func TestImportFromFolder(t *testing.T) {
assert.True(t, ct.Equal(mtime), "content_datetime %v should equal mtime %v", ct, mtime) 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 // TestBlockRevokesActiveSessions verifies that blocking a user immediately
// invalidates their outstanding access tokens. // invalidates their outstanding access tokens.
func TestBlockRevokesActiveSessions(t *testing.T) { func TestBlockRevokesActiveSessions(t *testing.T) {
+7
View File
@@ -353,6 +353,9 @@
const anchor = page.url.searchParams.get('anchor'); const anchor = page.url.searchParams.get('anchor');
if (anchor) { if (anchor) {
scrollToFile(anchor); scrollToFile(anchor);
// Pre-focus the anchor file so keyboard navigation resumes from it.
focusedId = anchor;
kbActive = true;
consumeAnchor(); consumeAnchor();
return; return;
} }
@@ -709,6 +712,10 @@
const target = lastOverlayId; const target = lastOverlayId;
lastOverlayId = null; lastOverlayId = null;
scrollToFile(target); scrollToFile(target);
// Land the keyboard roving-focus on the file we came back from, so arrow
// navigation continues from there (and the ring marks where you were).
focusedId = target;
kbActive = true;
} }
}); });