Skip to content

[22/33] Thumbnails: serve one only to somebody who was cleared to see it - #411

Merged
vikramsoni2 merged 4 commits into
nxzai:mainfrom
cerede2000:upstream-22-thumbnail-access
Sep 26, 2026
Merged

vikramsoni2 merged 4 commits into
nxzai:mainfrom
cerede2000:upstream-22-thumbnail-access

Conversation

@cerede2000

Copy link
Copy Markdown

Batch 22 of the plan in #373: thumbnails, and who may see one. Stacked on #410 — merge in order; this PR's own changes: cerede2000/NextExplorer@upstream-21-office-saves...upstream-22-thumbnail-access

/api/thumbnails resolves the path and checks the access, then hands back a URL under /static/thumbnails. That directory is served by express.static, which the authentication middleware does not cover, and a thumbnail's cache name is derived from the file's path — so the name is guessable, and the picture of a file somebody may not open can be fetched by anybody who can reach the server. A 200 against a 404 answers "does this file exist" besides.

A session cannot settle it: it says who is asking, not what they were cleared to see. A visitor holding a perfectly valid session for one share can name a thumbnail belonging to another share, or to a private folder, and it is served.

What it does

  • The decision travels with the URL. /api/thumbnails signs the one filename it has just cleared; the static handler verifies that signature and nothing else — no database read, no session, nothing to confuse. A request without it is answered 401, so existence is not disclosed either.
  • Scoped to one file, and to a day. A token names the exact filename, so one issued for a thumbnail you were cleared for does not unlock another; a nested path with the same basename is refused. It lasts as long as a guest session, and a page left open longer asks the API again — which runs the access check again.
  • Nothing to configure. The key is derived from the session secret, as the ONLYOFFICE one is. A restart that loses it only means open pages fetch their thumbnails again.
  • Authentication switched off stays switched off: with AUTH_MODE=disabled the handler steps aside.

Notes for review

  • No schema change, no new dependency, no frontend change: the client uses whatever URL the API returns, and it now carries the token.
  • The service is called from that one route and nowhere else, so no other path had to learn about it.

How it was checked

On main at 7b07337 with #410 applied, Node 24 as the CI and the image use:

  • the frontend bundles and every backend module loads (the CI's own two steps);
  • the backend suite passes — 1888 tests, the only two failures being the two that already fail on main untouched (auth.test.js "current password is incorrect", browse-hidden-files.test.js);
  • eight new tests. Seven drive the static handler (no token, a token for another file, an expired one, one whose expiry was pushed out by hand, a nested path with the same basename, a malformed URL, authentication disabled) and five of them fail when the handler is taken out; the eighth walks the whole way — ask the API, follow the URL it gives, then ask for the same picture without what the API attached — and fails when the signing is removed from the route.

Benjy added 4 commits September 25, 2026 09:24
…ument

The Document Server does not send the document back: it sends a URL to fetch
it from. The file was emptied first and then written as the answer arrived, so
a stream that stopped arriving — a dropped network, a Document Server
restarting mid-answer — left an empty file where the work had been. The state
the save replaced was gone in every case anyway, since nothing kept it.

Both are the same change. The document is pulled into a file of its own beside
it and handed to the versions, which keep what it replaces and put the new
content in place only once it is whole. A save nobody could finish therefore
leaves the document exactly as it was, and the history the engine has been
keeping since it arrived is finally written by somebody: the versions have been
inert until now, because nothing in the application saved over a file.

A save on purpose — the editor's own Save, or the last one made once everybody
has left — is marked as such, so the automatic saves in between do not each
stand as a state of their own; everyone editing together shares the document
key, which is the session those saves belong to. The person credited is the one
whose changes the callback carries, or the account the editing session was
opened for when it carries none; somebody who came through a share link is
credited as the link, since there is no account to name.

Seven tests against a Document Server that hands the document over, refuses to,
or dies halfway through. Five of them fail on the previous write: the document
is emptied by a download that never finishes, and nothing is kept.
The callback says where the saved document is, and the server fetched whatever
it was told to. The callback is answered without a session — it is how a
separate service reaches us — so anybody able to call it could name any address
the server can reach: one on the machine itself, one inside the container's
network, a metadata service. The answer was then written into a file in the
volume, where it could be read back.

The URL now has to come from the Document Server the document was handed to.
`ONLYOFFICE_DOWNLOAD_ORIGINS` declares the others it may report itself under,
which happens behind a proxy or inside a container network; the configured
address is always allowed, and a refusal says in the log which origin was
turned away and which are allowed, since that is the only way to tell this
apart from a Document Server that cannot be reached.

The test answers perfectly well from a server that is not the Document Server,
so a refusal cannot be mistaken for a network failure: without the check the
document is replaced by what that server handed over.
Collabora hands the document over itself, so nothing here could lose a file the
way an unfinished download could. What was missing is the other half: the state
each save replaced was dropped, so a document edited all afternoon in Collabora
had no history at all, while the same document saved from anywhere else now
does.

The save goes through the versions. Who saved it is the account the editing
session was opened for, and the editor is recorded beside it, so a history
shows where each state came from.

Collabora saves on its own every few minutes. Those saves belong to the session
rather than standing as states of their own, and the session is the lock every
co-editor of the document holds — one per open document, which is exactly what
a session is. A save somebody asked for, or the one made on closing, is marked
as such and is kept even when the timer saves over it a minute later.

Five tests, all failing on the plain rename: what a save replaced is gone, and
nothing says who saved or how.
`/api/thumbnails` resolves the path, checks the access, and hands back a URL
under `/static/thumbnails`. That directory is served by express.static, which
the authentication middleware does not cover, and a thumbnail's cache name is
derived from the file's path — so the name is guessable, and anybody who could
reach the server could fetch the picture of a file they may not open. The 200
against a 404 answered "does this file exist" besides.

A session would not settle it: it says who is asking, not what they were
cleared to see. A visitor holding a perfectly valid session for one share could
name a thumbnail belonging to another share, or to a private folder, and it
would be served.

So the decision travels with the URL. `/api/thumbnails` signs the one filename
it has just cleared, and the static handler verifies that signature — no
database, no session, nothing else to get wrong. The signature lasts a day,
which is the guest session's own lifetime; a page left open longer asks the API
again, and the access check runs again with it. The key is derived from the
session secret, so nothing new has to be configured, and a restart that loses
it only means open pages fetch their thumbnails again.

Nothing else produces such a URL — the service is called from that route and
nowhere else — so no other path had to learn about the token.

Eight tests: seven on the static handler (no token, a token for another file,
an expired one, one whose expiry was pushed out by hand, a nested path with the
same basename, a malformed URL, and authentication switched off), and one that
walks the whole way — ask the API, follow the URL it gives, then ask for the
same picture without what the API attached. Five of the first seven fail
without the handler; the last one fails without the signature.
@vikramsoni2
vikramsoni2 merged commit 3bcdc1d into nxzai:main Sep 26, 2026
1 check passed
vikramsoni2 pushed a commit that referenced this pull request Sep 26, 2026
A thumbnail was made inside the request that asked for it. A folder of five
hundred pictures is five hundred held requests; a video on a slow disk holds
one for minutes; and nothing could be said about which of them mattered, so the
tile somebody was looking at waited behind the one they had scrolled past.

The request now answers with what is already there, or says it has queued the
work and asks the caller to come back. What is worth doing first is decided in
the queue, where it can be: a thumbnail somebody is waiting for goes ahead of
one being fetched in advance, and a prefetch is admitted only while nothing
interactive is running. The browser asks again, with increasing delays, rather
than holding a connection open per tile.

Making them is the other half. Video thumbnails go through ffmpeg with their
own concurrency, their own seek point, their own thread count and a generous
ceiling — a thumbnail killed early is a thumbnail that never appears. RAW files
hand over the full-size preview their own metadata carries, which is far
cheaper than decoding the raw sensor data and is what every other viewer shows.
Both are bounded: sharp's cache is held to a size, the external processes are
counted and can be given a lower priority, and a job that takes too long says
so in the log rather than disappearing into it.

What they leave behind is bounded too. The cache has a file count, an age and a
cleanup that walks it in batches, shared with the RAW previews so the two are
held to one reading of the same settings rather than two copies that drift. A
temporary file an interrupted write left behind is recognised and removed,
under both the name this version writes and the ones earlier versions did —
and a file this process is still writing is never one of them.

A failure is remembered for a while, so a file that cannot be made into a
thumbnail is not attempted again on every listing; and the cache key carries a
version, so a change in how thumbnails are made invalidates what is there
rather than serving it for ever.

The settings screen was saving something other than what it showed: a value
outside the bounds the server holds it to was sent as typed and brought within
them there, and an emptied field was dropped. The bounds are now the page's as
well, and it will not offer to save what the server would change. It also
offered to save before the settings had loaded, and saving then wrote its own
defaults over what was stored.

Gates: lint and formatting on the changed files, `npm run build`, every backend
module loads, and the whole backend suite — 2,026 pass, against the two that
fail on `main` before any of this. Six new tests, and the thumbnail-token test
from #411 now waits for the picture the way the browser does. Every claim fails
when the change behind it is put back: the request held again, and thumbnails
that cannot be switched off. One claim was dropped rather than left passing for
the wrong reason — which of the two short-circuits answers a second ask cannot
be told apart from outside, so nothing here says it can.

Checked in the built image: the first ask answered 202 and said so, the picture
appeared on a later ask, the next ask answered at once, a prefetch was taken,
the picture opened with the proof on its URL and was refused 401 without it,
and the folder drew all three.
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