Skip to content

Compress static text, cache content-named files, and stop the closed lightbox loading originals - #81

Merged
egeozcan merged 7 commits into
masterfrom
claude/plugin-image-loading-69j4jw
Oct 17, 2026
Merged

egeozcan merged 7 commits into
masterfrom
claude/plugin-image-loading-69j4jw

Conversation

@egeozcan

@egeozcan egeozcan commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Requested by ege · project thread

Before: every list page downloaded the full original of its first image, even with the lightbox closed. The server compressed nothing, and no response carried a Cache-Control header.

After: a list page loads only previews until the lightbox opens. Scripts and stylesheets go out gzipped. Vite's hashed chunks are cached for good. Stored files are cached for good without sign-in and revalidated (a cheap 304) with it.

Measured without sign-in, through a local proxy at 100 ms RTT and 20 Mbit/s, on /resources, timed to the load event (median of three runs). The newest resource on that list is a 31 MB PNG:

cold warm
master 13.9 s, 32 MB 13.3 s, 31 MB
first commit of this PR 0.71 s, 738 KB 0.39 s, 413 KB

With the lightbox fix alone, cold is 1.0 s and 1.5 MB. Compression takes it the rest of the way. I measured these on the first commit. The later commits narrow caching under sign-in, and I haven't re-measured them.

How

  • Lightbox (templates/partials/lightbox.tpl, navigation.js): the image, SVG and video branches are gated on isOpen. Before, their x-if rendered inside a container that x-show only hides, and the store collects its items when the page loads. Because a closed viewer no longer keeps its video element, close() saves the video's position and speed (restored only for the same file) plus its volume and mute (carried to the next video opened).
  • Compression (server/static_delivery.go, compressStatic): gzip on /public/, on plugin public/ files and on the share server's /public/. Whether a file is compressed is decided from its extension before the handler runs, so each file has one representation. HEAD gets the gzip headers, and Range and If-Range are ignored for these files, so a resumed download never splices raw bytes onto a gzip prefix. Images, fonts and video keep their byte ranges. Pages and the API are not compressed, because streamed responses there must not be buffered or reshaped. Sizes: main.js 806 → 224 KB, tailwind.css 148 → 20 KB, ketchup.js 320 → 77 KB.
  • Caching:
    • Vite's hashed chunks under /public/dist/assets/ are sent public, immutable.
    • Stored files (cacheStoredFiles) without sign-in: hash-named files are sent private, immutable.
    • Stored files with sign-in: every guarded file is sent private, no-cache, because one browser cache serves every account signed in on it.
    • The /v1/resource/view redirect is cached for a day, only without sign-in, and only when ?v= is the resource's current hash.
    • The cache and gzip wrappers forward ReadFrom, so stored files keep sendfile.
  • File guard (application_context/scoping.go, server/request_scope.go): FilePathInScope now matches the storage mount as well as the path. A group-limited principal could otherwise open another owner's file at the same path on an alternate mount. That gap predates this PR.

Tests: unit tests in server/static_delivery_test.go, plus api_tests for the redirect, stored-file caching and the mount-scoped guard. e2e/tests/lightbox/closed-viewer-loads-nothing.spec.ts fails on master and passes here, and also covers video state across close and reopen. CI is green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HJrjPqWJxWUQxQGJmmfcgC

@egeozcan egeozcan self-assigned this Oct 17, 2026
@egeozcan
egeozcan marked this pull request as ready for review October 17, 2026 06:32
claude and others added 7 commits October 17, 2026 15:19
…lightbox loading originals

Every list page downloaded the full original of its first image: the
lightbox collects its items when the page loads, and its media x-if
rendered inside a container that x-show only hides. The media branches
are now gated on isOpen.

Static text (/public/, plugin public/ files, the share server's /public/)
is gzipped for clients that accept it: main.js 806 KB -> 224 KB,
tailwind.css 148 KB -> 20 KB, ketchup.js 320 KB -> 77 KB. Range and HEAD
requests are served as before.

Responses whose URL names their exact content are cached for good: Vite's
hashed chunks (public), stored files at their content-hash path (private),
and, for a day, the /v1/resource/view redirect when ?v= is the resource's
current hash. The ?v= bundle and stylesheets keep heuristic caching, since
their version is computed once at startup and a rebuild under a running
server would leave an immutable stale copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJrjPqWJxWUQxQGJmmfcgC
With sign-in on, a hash-named file's bytes never change but who may read
them does, and a browser keeps one cache for every account signed in on
it. An immutable copy reached the next account without passing the file
guard. Such files are now "private, no-cache" with sign-in, so every use
revalidates (a 304 when unchanged), and stay immutable without it.
Clear-Site-Data on logout would not cover this: it is ignored over plain
HTTP, and an expired session never logs out.

The cache and gzip wrappers hid net/http's ReadFrom, so every stored
file, the largest being videos, lost sendfile. Both now forward it.

The plugin-assets docs claimed an updated plugin's files reach browsers
promptly; heuristic freshness can keep an old script for days, so they
now say so and show a versioned URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LNfnqzg94dG8beU8gftX2
compressStatic chose gzip from the response, so a HEAD (never compressed)
reported the identity length while GET sent gzip, and a Range request got
an identity 206 under the same Last-Modified as the gzip 200: a resumed
download spliced raw bytes onto a gzip prefix. The choice is now made from
the file's extension before the handler runs. A gzip-accepting HEAD gets
the gzip headers with no length, and Range/If-Range are ignored for these
files so the whole gzip body is resent. Images, fonts and video keep
their byte ranges.

The closed viewer no longer keeps its video element, so a reopen started
the video over at full volume and normal speed. close() now records the
video's position, volume, mute and speed, and the reopened element
restores them on loadedmetadata when it is the same file.

Found by pi review round 1 (gpt-6.1-sol).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LNfnqzg94dG8beU8gftX2
The kept element used to carry volume and mute to whatever video the
viewer opened next; restoreClosedVideo only restored them for the same
file, so a muted video closed and another opened played at full volume.
Volume and mute now carry to any video, and position and speed only to
the same file, matching what the kept element did (a media load resets
the speed).

The spec's videos are now byte-distinct copies, since the shared sample
video uploaded by another spec returns that spec's resource.

Found by pi review round 2 (gpt-6.1-sol).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LNfnqzg94dG8beU8gftX2
…s state

A client that took a file gzipped and resumed it without gzip, naming the
gzip response's Last-Modified in If-Range, still got an identity 206 and
spliced raw bytes onto its gzip prefix. A request with If-Range for a
compressible file now gets the whole file, whatever encoding it accepts;
a plain identity Range keeps its 206.

Closing the viewer again before a reopened video reached its metadata
saved that element's defaults over the state the earlier close had kept,
since the restore waits for loadedmetadata. A close now leaves the saved
state alone when the element never loaded it.

Found by pi review round 3 (gpt-6.1-sol).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LNfnqzg94dG8beU8gftX2
…-load close

With sign-in on, only hash-named files carried "no-cache". A file a
resource references in place had no Cache-Control, so the browser kept it
by its age, and the now-cached /v1/resource/view redirect reached that
copy with no check at all: the next account signed in on the browser got
a file it could not read. Every guarded stored file now revalidates under
sign-in (cacheContentAddressedFiles becomes cacheStoredFiles); without
sign-in nothing changes.

Closing while the reused video element loaded the next file after a
navigation skipped the save (readyState was below metadata), dropping the
live volume and mute. The save is now skipped only while an earlier saved
state is still waiting to be restored onto the element.

Found by pi review round 4 (gpt-6.1-sol).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LNfnqzg94dG8beU8gftX2
… file guard

A kept /v1/resource/view redirect skipped that handler's check of the
resource itself on every later open by whoever was signed in on the
browser, leaving the raw file guard as the only check. The redirect is
now cached only without sign-in.

That guard was mount-blind: FilePathInScope matched a resource's
location alone, so a group-limited principal who could see a
main-storage resource at a path could open another owner's file at the
same path on an alternate mount (/archive/...). It now matches the
storage mount too ("" for /files/, the mount key otherwise). This
predates the PR; the cached redirect made it reachable through ordinary
navigation.

Found by pi review round 5 (gpt-6.1-sol).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LNfnqzg94dG8beU8gftX2
@egeozcan
egeozcan force-pushed the claude/plugin-image-loading-69j4jw branch from 3b00ccb to 5038bea Compare October 17, 2026 13:19
@egeozcan
egeozcan merged commit d984163 into master Oct 17, 2026
6 checks passed
@egeozcan
egeozcan deleted the claude/plugin-image-loading-69j4jw branch October 17, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants