The grouped filter had a second member, "All previews", built on a
public-preview action that does not exist: previewing is recorded one
way today, so its own option already answers "who previewed this?" in
full. Static analysis caught the reference; the group would have been
unreachable even if it had compiled, since a group with a single
present member is deliberately not offered.
Previews get a group here the day a second way to preview a file is
recorded separately, and the test now pins the single-member case on a
file whose log holds one flavour of download.
A file's history was only reachable from the library list, through the
details panel's Activity tab — so anyone who arrived at the file from a
link, a search or a notification had to go back and find the row they
came from to ask what had happened to it.
The file's own page now carries an Activity tab of its own, next to
General and Sharing: the twenty most recent entries, fetched only if the
tab is opened, and a link to the full history. It is behind the same
view_actions_log permission as everywhere else.
That full history is now filterable, which is the point of sending
somebody to it. The action list is built from the file's own log rather
than from the eighty-odd actions the software can record — all but a
handful of which can never apply to a file — and each option carries its
count. Downloads are three separate actions on purpose (a signed-in
recipient, a public link, the public group listing), so "All downloads"
asks that question once instead of three times; the group only appears
when the file's log actually holds more than one of its members.
Narrowing by who acted and by date range works the same as it does on
the main activity log, the reader's own calendar day included.
An empty response gets Symfony's default Content-Type, text/html, and a
CDN in front of the app takes that at its word: Cloudflare's Email
Obfuscation and Automatic HTTPS Rewrites both rewrite HTML bodies, so
they drop the origin's ETag from the response — a rewritten body would
no longer match it.
That ETag is the client's only signal that a part landed. Nothing on
this side notices its loss, because LocalPartStore keeps its own record
of every part and complete() never reads a client-supplied one; the
upload simply reaches 100% and stops, with no error at either end.
Reported from a Cloudflare-fronted install (#1616), where the visible
symptom was Uppy's "Could not read the ETag header" — which names CORS,
and sends you chasing a preflight that same-origin requests never make.
Naming the content type accurately keeps the response out of every
HTML-rewriting path there is, rather than asking each CDN-fronted
install to discover this one for itself.
The rationale sits inside the header array rather than above the return:
Scramble reads a comment attached to a return statement as that
response's description in the published OpenAPI document, and this
controller is mounted on the API routes too.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.
This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.
Free software under the GNU General Public License v2, or (at your
option) any later version.