mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-21 02:53:24 +00:00
7c5af8570a24ac00a1058308f6a2ec412f272db6
24 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
92a132d74f |
Give zip builds their own queue, so one archive cannot hold up the mail
The last piece of the #1687 follow-up. BuildZipDownloadJob allows itself an hour, every shipped topology runs exactly one worker, and everything shares the default queue -- so one large archive delayed every notification email queued behind it. The size cap and the one-build-per-person rule bounded that in July; they did not remove it. onQueue('zips') in the constructor rather than at the dispatch site, so a second caller cannot forget it. Both images grow a worker for it: compose.yaml gains worker-zips, supervisord gains [program:queue-zips], and the existing worker in each narrows to --queue=default. --tries=1 there matches the job, which records its own failure rather than being retried. The part that needs care is the manual install. A worker whose command still says plain `queue:work` consumes `default` only, so it would send email happily and never finish a single zip, with nothing in any log saying why. INSTALL.md's unit now reads --queue=default,zips -- one worker watching both, which is right for most installations -- and says what happens if you leave it off, with the two-worker split offered for anyone who would rather keep the two kinds of work apart. CHANGELOG carries it as an upgrade note, since it is something to do rather than something that was done. Verified in the dev stack rather than only in a test: dispatched a build and watched worker-zips take it while the default worker stayed idle. |
||
|
|
eb2917f5ff |
Hold a group object to the same boundary its membership already has
This overturns something #1701 decided, so it should say so. That PR closed the membership hole and left GroupsController::update and destroy installation-wide on purpose, on the grounds that managing the group object is a different question from managing who is in it. What decides it is a measurement that was not in front of that decision. An assignment to a group is how its members reach a file, so deleting a group revokes that access for every member. Measured before this guard, with a client-scoped role holding the group permissions: stranger client can read the shared file true PATCH /groups/{stranger group} 302, renamed DELETE /groups/{stranger group} 302, group gone stranger client can read the shared file false So a staff member who may not add somebody to a group out of their reach could delete it out from under the people already in it. That is not a gentler version of the membership rule, it is a harder one, and the two sitting on opposite sides of the same boundary was the odd part. StaffLibraryScope::allowsGroupChange is the reach half of allowsGroupMembership on its own, since no client appears in this question -- one predicate, two callers, rather than a second statement of it. Both surfaces take it, at 404, matching the membership guards. A group that shares nothing beyond the actor's library still passes, so a group they created or one holding their own clients stays theirs, and unscoped staff are unaffected by construction. The API document moves a 404 above a 422 on two paths. Both already documented the 404 -- route model binding produced one -- and Scramble orders responses by where they appear in the method, so the guard landing before the validate() call is the whole of the change. |
||
|
|
41b4e477b5 |
Give each parallel test worker its own directory for upload parts
A full parallel run failed once and passed on retry while I was doing the #1703 follow-up. A flake is worse than a steady failure: it trains you to re-run rather than look, and it quietly weakens every green run reported beside it. Upload parts are real files under storage_path('app/uploads-tmp/{session_id}'), not a faked disk. Every parallel worker gets its own database, so session ids restart at 1 in each of them, and two workers writing parts land in the same directory. On top of that ChunkedUploadsTest's afterEach deleted the whole tree rather than its own share, for everybody. Six test files write parts, so this was reachable without anything I added. The same collision exists inside one worker: RefreshDatabase rolls back, so ids restart at 1 for every test, and a run that died before its cleanup leaves parts sitting under the id the next test is about to claim. LocalPartStore now reads its root from config, defaulting to exactly where it always was -- an installation with UPLOAD_PARTS_PATH unset behaves identically. Tests\TestCase points it at a per-worker directory and empties that directory per test, which closes the cross-worker, the cross-run and the intra-worker versions together. ChunkedUploadsTest's cleanup and its two directory assertions read the configured root rather than the hardcoded path, so they can no longer reach into a neighbour. Verified with eight consecutive parallel runs, green, and by watching the per-worker directories appear separately (w1, w2, w4 … w14) rather than one shared tree. The isolation itself cannot be asserted from inside a single test; what a test can pin is the mechanism it rests on, so one does: parts go where the configured root says. |
||
|
|
d7e639b7af |
Close the two-request version of the deleted-folder target, and say why it failed
Follow-up to #1703, which made `exists:folders,id` mean what its ten readers already assumed. Two things it named and deliberately left. **The chunked upload is two requests.** store()'s rule only ever sees the first: POST /uploads records the resolved folder on the UploadSession and complete() reads it back from the session rather than from the caller, so deleting the folder while the bytes are in flight still files the assembled file into it -- the same orphan state #1703 removes, reached by a door a validation rule cannot watch. complete() now re-resolves through Folder::query() and files at the root when the folder has gone. Root rather than a refusal, because the two moments cost different things. At store() nothing has been sent, so refusing is free and honest, which is the call #1703 made. Here the bytes are already uploaded, and discarding somebody's finished transfer over a folder that vanished underneath them is the harsher of the two surprises. The file lands somewhere they can see it and move it. **The refusal now explains itself.** "The selected folder id is invalid" says nothing when the answer is that the folder has been deleted -- and that is the usual way to meet this rule, since a live id picked from a list is how anybody gets here. It matters most on the chunked path, the one place #1703 makes a previously-working request fail. A small ValidationRule object carries the message, which keeps the single definition Rules::folderId() exists for: a messages() array would have to be repeated at all ten call sites, and rules meaning different things in ten places is what went wrong in the first place. One note for whoever writes the next test here. Upload parts live in storage_path('app/uploads-tmp/{session_id}'), which is a real shared directory rather than a faked disk, and each parallel worker's database restarts session ids at 1 -- so two files writing parts on two workers collide, and ChunkedUploadsTest's afterEach deletes the whole tree for everybody. Six test files write parts today. These two cases live in ChunkedUploadsTest rather than beside the rest of their subject so this change does not add a seventh racer; the underlying isolation problem predates it and is worth its own fix. |
||
|
|
8d896191e4 |
Merge pull request #1703 from denkfabrik-li/fix/deleted-folder-upload-target
Say what `exists:folders,id` was already being read as |
||
|
|
e7b5b6a757 |
Hold client records to the same boundary the rest of the library uses
The other half of the sweep. ClientsController and its API twin checked `abort_unless($client->isClient(), 404)` and nothing else -- a type check, not a boundary, which is the phrase #1701 used about the group membership routes for exactly the same reason. Measured before the fix, with a client-scoped role holding the client permissions: GET /clients every client on the installation, name + email GET /clients/{stranger} 200 PATCH /clients/{stranger} 302, name actually changed DELETE /clients/{stranger} 302, client gone The tell was one route over. ClientFilesController::index already draws this line with StaffLibraryScope::canAssignClient and calls it "the same boundary StaffLibraryScope enforces everywhere else in the library". Its neighbours in the same family did not. So the predicate is not new here. What is new is StaffLibraryScope::clients(), the listing half of canAssignClient, so a screen narrows by the rule its own buttons are guarded with instead of restating it -- restating it is how this went wrong, and how the last four of these went wrong. Eight actions take it: edit, update, destroy and the two-factor reset on both surfaces, plus both listings. Answering 404 rather than 403, since a client outside the roster should not be distinguishable from one that is not there -- matching the isClient() guard already above it. Account requests stay installation-wide on purpose: a self-registered client who has not been approved belongs to nobody yet, so there is no roster to narrow by and narrowing would empty the screen. The published API document is unchanged -- both routes already documented the 404 that the type check produced. |
||
|
|
f1b35cc9f6 |
Stop a deleted file locking a scoped staff member out of a group for good
#1701 closed a real hole: group membership decides what a client reaches, and through File::scopeVisibleToClient it decides what the staff member holding that client reaches, so `edit_groups` alone was never a boundary. The predicate it added asks whether everything shared with a group is already inside the actor's library. It asked by counting: pluck the group's assignment rows, count how many of those ids the library query returns, and require the two to match. An assignment row outlives the thing it points at — nothing clears them when a file or folder is deleted — while files() and folders() exclude trashed rows by construction. So one deleted file left a count that could never balance again, and the group closed permanently: the scoped staff member could no longer add their own client to it, or remove anybody from it, with a 403 and nothing to explain it. Every group accumulates dead assignments over time, so groups would have gone quiet one at a time. Asked the other way round — is there anything live, shared with this group, that is outside my library — the dead rows drop out by construction, because the query starts from File/Folder rather than from the assignment. That is also the truer question: a deleted file is not reach, since nobody can reach it. Three tests. A group stays usable after a file shared with it is deleted, including removing a member; the same for a deleted folder assignment; and the half that must not soften — a live file still out of reach is still refused, deleted siblings or not. |
||
|
|
93d22378c4 |
Merge pull request #1701 from denkfabrik-li/fix/group-membership-library-scope
Group membership is a library boundary, not just a list |
||
|
|
18e4e014e6 |
Merge pull request #1683 from denkfabrik-li/fix/orphan-scanner-preview-renditions
Keep preview renditions out of the orphan-file scan |
||
|
|
0a8b609e8b |
Build a scoped staff member's library query once per request, not once per row
#1698 moved the library boundary into FileCommentPolicy, where it belongs, and said plainly what that cost: the moderation screen went from 65 queries to 465 for a client-scoped moderator with five assigned clients. Measured here, those numbers are exactly right. The cost is not in asking. It is that StaffLibraryScope::files() rebuilds its query every time, and building one runs four immediate lookups per assigned client — the client's group ids, the same ids again inside Folder::sharedFolderIds(), that method's own assignment lookup, and the shared-folder get() in Folder::scopeVisibleToClient(). None of them depend on the query being built. Gate resolves a fresh policy for every check, so a listing paid for all of it once per row. The built query is now memoised per user and handed back as a clone, since every caller adds to it, and the scope is registered as `scoped` rather than transient so the memo survives a request. Scoped rather than a singleton on purpose: a long-lived queue worker keeps singletons between jobs, and a library query built from one job's data has no business answering the next one's question. That is 465 queries down to 60 on the same page — below the 65 it cost before #1698, because the memo also helps the callers that were already asking repeatedly. FileVersions::sharedAudience(), which runs the same helper twice per candidate while resolving notification recipients, gets it for free. So the answer to the question #1698 left open is neither of the two it offered. can_delete stays a real question asked of the policy; nothing restates the boundary; and the page is faster than it was before the fix. Three tests: one user's query never answers another's, one caller's constraints never follow the next, and the moderation screen does not ask once per row. |
||
|
|
8a6543073b |
Group membership is a library boundary, not just a list
The four routes that edit a group's membership -- add and remove, web
and API -- contain no authorization call of any kind. `can:edit_groups`
in front of them is the whole of it, and a permission is not a boundary.
The authorization sweep looked at these and let them stand, on the
grounds that groups are installation-wide by design: GroupsController
::index lists every group unfiltered, so list and single-object access
agree, and there is no listing/direct-access mismatch to fix. That is
true, and it is the answer to the question of who may *see* a group.
This is a different question: what a write to one *does*.
Joining a group hands the new member everything shared with it. When
that member is one of a client-scoped staff member's own clients,
File::scopeVisibleToClient hands the same content straight back to them
-- that scope is what StaffLibraryScope::files() is built out of. So the
one write turns a file they get a 403 on into a file in their library,
and the download that follows is a 200. ResolvesShareTargets draws that
line on the sharing path through canAssignGroup(); nobody drew it on the
membership path, and canAssignGroup() is *derived from membership*, so
whoever may edit the list also decides what the list entitles them to.
StaffLibraryScope::allowsGroupMembership answers it directly instead of
through the derived predicate, which is the wrong tool here twice over.
Membership asks about reach, so it checks reach: the client must be one
this staff member holds, and the group must not already reach past their
library -- no file assigned to it, and no folder shared with it, outside
StaffLibraryScope. A group nothing has been shared with passes trivially,
which matters, because canAssignGroup() would have said no to a group
that has no members yet and left a scoped staff member unable to put the
first client into one they had just created.
The same write has a second door. MembershipRequestsController::approve
joins a client to a group with identical consequences, under
`approve_groups_memberships_requests`, and deny() decides about somebody
else's client and emails them about it. Both go through the same
boundary, answering 404 to match the guard already above approve().
The queue and its sidebar badge are narrowed to the clients the viewer
holds, through one scope on the model that both read -- the rule the
comment badge in HandleInertiaRequests already states two branches down
("a client-scoped staff member is not shown a number they cannot act
on"), and the reason VisibleCommentScope owns its own pendingTotal()
rather than leaving the middleware to count for itself. Each row carries
the client's name and email, so an unnarrowed queue was also handing
those over for clients outside the roster. Unscoped staff still see every
pending request.
That narrowing is on the client, not on the group: whether a group is
reachable depends on what is shared with it, which is not a question to
ask row by row in a listing. A scoped viewer may therefore still be
shown a request they would be refused on -- one of their own clients
asking to join a group out of their reach. The names were the part that
leaked.
Unscoped staff are unaffected throughout -- both halves of the predicate
are true for them by construction. No seeded role reaches this: Client
Manager is the only client-scoped role that ships, and it holds no group
permissions, so a custom role is needed to get here at all.
The published API document gains a 403 on both member routes.
Regenerated with php artisan scramble:export; Scramble reads abort_unless
out of the method body but not out of a private helper, which is why the
guard is written out at each of the four call sites rather than shared.
|
||
|
|
7727ad7616 |
Say what exists:folders,id was already being read as
Folder uses SoftDeletes. The `exists` rule runs against the table, so a folder in the trash passes it -- while every resolution that follows goes through Folder::query(), which honours the soft delete and finds nothing. Ten rules across five controllers rely on that check, and each one reads it as "this folder exists". Two of them then wrote the id anyway. Api\FilesController::store() resolves the folder, hands the null to Folder::uploadableBy(), is told yes -- correctly, that is the rule for a root upload -- and passes $validated['folder_id'] to the write. FilesController::store() is the same shape once #1694 gives it the guard. FilesController::update() and its API twin write it straight through with nothing in between. The result is a live file inside a deleted folder, which is a state nothing else in the application produces: FolderService::delete() deletes every file in the subtree along with it. The row is reachable by id, in search and over the API, and missing from the listing its uploader would look in. Rules::folderId() makes the check mean what its readers assume, once, where the reasoning can be written down -- the same argument slug() makes for itself one method above. Every site takes it, so the file cannot end up with two spellings of the same rule and no way to tell which is the safe one. What changes, path by path: - POST /files, POST /api/v1/files, PATCH /files/{file} and PATCH /api/v1/files/{file} refuse a folder in the trash instead of writing its id. This is the fix. - POST /uploads used to accept it and quietly file the upload at the root -- its guard and its write already agreed, on null. It now says so instead, which is what the other upload paths do. - files/{file}/move, files/bulk-edit, folders, folders/{folder}/move and the portal's my-folders already refused, through StaffLibraryScope::folders() or Folder::scopeVisibleToClient(), both of which drop trashed rows. They still refuse; the answer is now 422 naming folder_id rather than a bare 404. Those two guards are asking a different question -- "is this folder yours" -- and they keep asking it. No live folder id behaves differently anywhere, and the root (a null folder_id) is untouched. The published API document is unchanged: `exists` renders the same either way. Regenerated with php artisan scramble:export and byte-identical. |
||
|
|
073101d184 |
Put a ceiling on a zip download, and clean up after the ones that fail
Follow-up to #1687, which made a zip build report failure honestly. Four things it passed near, none of them regressions it introduced. A zip has never had a size limit — only a cap of 10,000 files, which bounds nothing that costs anything. Ten thousand spreadsheets zip in seconds; two hundred videos is an hour of stream-copying and an archive that fills the disk. Bytes are what a build actually costs, so the new Settings → Downloads screen caps the total size instead, at 2 GB out of the box. It is a setting rather than a constant because the safe figure depends on free disk, on whether sources live on a remote disk, and on the plan a hosted tenant is on — the file count stays fixed, since it is a foot-gun rail and not a knob anybody needs. The controller measures the selection at request time and names both numbers when it refuses; the job measures again, because it re-derives the selection at run time and a folder can grow while the job waits in the queue. Every shipped topology runs exactly one queue worker, and everything shares the default queue, so raising the job timeout to an hour handed any signed-in person an hour of everyone else's notification mail. There is now one build in progress per requester and a named throttle bucket on the endpoint, which had neither. A pending row older than an hour is treated as abandoned rather than in progress, so a worker killed hard enough to skip failed() cannot lock somebody out for good. Giving zip builds their own queue is the structural fix and wants its own change: it touches compose, supervisord and the systemd unit in INSTALL.md, and an install that upgrades without changing its worker command would stop building zips silently. zip_downloads.requested_by cascades on delete, so removing a user takes their rows with it and strands every archive they built — invisible to a purge that walks rows, and to OrphanFileScanner, which skips zips/ on purpose. The purge now also sweeps files in zips/ that no row explains, after a day's grace so a build in progress is never taken out from under itself. Two smaller things while in here. A build that failed because every file had already hit its download limit said only that nothing was available, and dropped the skipped list — the same distinction the store guard goes out of its way to draw at request time. And a failed close() now logs libzip's reason, which the @ silencing had been discarding: "the disk is full" and "the source vanished" are different problems for whoever has to fix one, while the requester still sees a message with no server paths in it. |
||
|
|
ff7758a31a |
Never mark a zip download ready over an archive that was not written
BuildZipDownloadJob deferred every write to ZipArchive::close() but then
marked the row STATUS_READY regardless of the result:
- close() returns false when a source file was deleted between addFile()
and close() (a concurrent staff delete runs FileDiskCleanup at once) or
the disk filled up; the row went ready over an archive libzip never
wrote, and the download controller X-Accel-served a path that isn't there.
- An archive that ended up with no entries (every selected file removed or
its allowance spent before the queued job ran) is written as no file at
all by libzip, yet close() still returns true — again marked ready.
Check both the close() return and the added-entry count, and fail the row
(deleting any partial archive) when either says nothing was written.
The job also had no $tries/$timeout/failed(): a build of up to MAX_FILES
sources runs past the worker's default 60s timeout, and the kill skips the
catch, stranding the row as PENDING while the frontend polls forever. Give
it room, run it once, and add a failed() backstop that fails a row still
pending (leaving an already-resolved one alone).
Finally, purge leftover zips/{id}.zip* by row id: a killed build leaves a
partial archive and libzip temp file with no path recorded, so the path
field alone never cleaned them up.
|
||
|
|
67f340d23d |
Keep preview renditions out of the orphan-file scan
The orphan scanner skips derived artifacts by path prefix, but the list was a hard-coded ['thumbnails/', 'zips/'] that never learned about 'previews/'. ImageRendition::Preview caches under previews/ (and previews/external/) on the local files disk, so every cached preview was reported as an orphan: offered for import on the orphans screen, and deleted by the purge command once past the grace period. An imported preview also became a File row pointing at a path the rendition cache owns -- destroyed the moment its source file was deleted or the cache was flushed. Derive the rendition prefixes from ImageRendition::cases() rather than repeating them, so a future rendition can't be forgotten here the way previews were; 'zips/' (the download-bundle job's) stays as it was. |
||
|
|
55e17498a2 |
Log which bucket an upload could not be written to
The failure message names the disk, which reads as a credentials problem even when the real cause is a bucket name that was never changed — the exact confusion produced by switching an existing S3 configuration over to Google and leaving the old bucket in the field. Logged rather than shown, because 'throw' => false means the reason is already gone by the time this code runs, and because the message goes to whoever was uploading. That can be a client, and a bucket name is not theirs to see. |
||
|
|
a459d45c87 |
Store the bytes, or say you did not
Two bugs a green suite could not find, both from pointing the application at a real Google Cloud Storage bucket. The adapter attaches a legacy per-object ACL to every write, and a bucket with uniform bucket-level access — which our own setup instructions require, and which Google recommends — refuses it: "Cannot insert legacy ACL for an object when uniform bucket-level access is enabled". So the default configuration could not write to the recommended bucket. The library ships UniformBucketLevelAccessVisibility for exactly this, and nothing is lost by never setting an ACL: every object here is private and every read is a signed URL. The second is worse and was never about Google. Both file disks are configured 'throw' => false, so a refused write returns false rather than raising, and LocalPartStore ignored the return. The upload reported success, the File row was written, and the bytes were nowhere — the listing showed a file whose download could never work. An expired S3 credential did the same thing. It now checks, and the controller already turns that into a validation error rather than a 500, so the person uploading is told. Verified against a live bucket with a key scoped to roles/storage.objectAdmin: the probe lists, writes land, reads round-trip byte for byte, and a signed URL comes back 200 carrying "Informe año.pdf" intact through both the ASCII and RFC 8187 forms of Content-Disposition. |
||
|
|
57540164fa |
Read a file from the disk it is actually on, everywhere
Two routes still assumed every file sits on local disk, which stopped
being true the moment external storage was switched on. A share link
answered with X-Accel-Redirect whatever the file's disk said, pointing
nginx at a path it has nothing behind; a public listing built a
thumbnail from Storage::disk('files')->path(), which for an externally
stored file is a path nobody ever wrote. Both fail only for installs
using S3, and only on those two routes, so the same file downloading
correctly from the file manager made the share link look like the
broken thing rather than where the file lives.
Neither is a new rule. FileDownloadController and
FileThumbnailController already did it right, which is the actual
finding: the knowledge was sitting in a private method on one class and
inline in another, so the next caller could not inherit it and did not.
Both are now objects with one job.
StoredFileResponse replaces InlineFileResponse and grows an
attachment() alongside inline(), since the two differ only by
disposition. LocalSourceFile takes a closure rather than returning a
path: the version that returned one also left the caller to unlink it,
and both of those are exactly the mistakes made here.
The regression tests fail against the previous controllers — checked in
both directions rather than assumed.
|
||
|
|
51eea30dda |
Answer "did they ever actually get it?" from the file itself
The two things staff most often want to know about a file — who downloaded it, who looked at it — were answerable only by reading the whole activity log past everything else that had happened to it, or by going back to the library list for the details panel. The file's own page now has a Downloads & previews tab: the twenty most recent times it was taken or looked at, each with who did it and the address it went to, over a running count of both. Below them, two buttons open the file's full history already filtered — one to every download, one to every preview — so the narrow question is one click and the whole log is still one click further. Which filter value stands for "every download" is decided server-side and travels with the payload, because it is a fact about the log's vocabulary: downloads are three actions and share a group, previews are one action and are filtered by name. The history page now also keeps whatever filter it was sent with visible in its dropdown even at a count of zero, so a button cannot land somebody on an empty table above a select that has gone blank. |
||
|
|
88c182cf3b |
Preview video, audio and PDF, not only images
v1 could preview four kinds of file in a modal — images, video, audio and PDF. v2 previewed only images, and not by decision: preview shipped as part of the image *thumbnail* work (1c68aa1), so "previewable" quietly became a synonym for "GD can decode it". FileThumbnailController::preview() gated on ThumbnailGenerator::SUPPORTED_MIME_TYPES, the frontend mirrored the same four types, and the dialog was a hardcoded <img>. Rather than widen that list — it drives pathFor(), extensionFor(), generate() and FileDiskCleanup, and a video reaching getimagesize() is a 500 — this separates the two questions. PreviewKind now answers "may these bytes be served inline, and what element renders them?", while ThumbnailGenerator keeps answering the narrower "can this app decode it itself?", which is what renditions, the cache and the watermark hook actually depend on. Image delegates to it so the two cannot drift. The allowlist stays a security boundary: mime_type is sniffed from the bytes, so text/html and image/svg+xml remain excluded, and PreviewKind is deliberately narrower than "formats a browser might cope with" — no quicktime, avi or matroska, because an embedded player for those shows a black rectangle. Those still download exactly as before. docs/security-audit-2026-08-05.md finding 1 recorded that adding application/pdf "should be a conscious decision". This is that decision, and three things were measured rather than assumed: - An <iframe sandbox> cannot be used. Chrome refuses to run its PDF viewer in a sandboxed frame at all (ERR_BLOCKED_BY_CLIENT, with or without allow-same-origin) — the attribute removes the feature, it does not harden it. - nginx's `Content-Security-Policy: sandbox; default-src 'none'` on /protected-files/ does work (a <video> frame lands in an opaque origin), but Chrome exempts its PDF viewer from it, so it is not what protects the PDF case. - What does is the allowlist plus the browser's own PDF sandbox, where PDF JavaScript has no DOM and no cookies. Range requests were verified end to end: 206 with a correct Content-Range, a byte-perfect file reassembled from three ranges, and a real browser seeking to 10s of a 20s clip. nginx drops the upstream Content-Length on the X-Accel path, so there is no collision. Two settings, both defaulting on so no installation loses what it has: clients_can_preview_files and public_listing_preview_enabled. Staff are never gated. The anonymous side needed a route of its own — there was no public preview endpoint — with its own throttle bucket, since a bare throttle: shares one counter across that whole block. A preview now logs at most one FilePreviewed per viewer per file per five minutes: a <video> turns one deliberate act into a long tail of Range requests, and a row each would bury the log. Also fixes a layout bug the tests could never catch. A portal file row was flex justify-between with three children — name, comment trigger, download — so the middle one settled wherever the name happened to end and the comment icon sat at a different place on every row. The name block now takes the slack and every action lives in one trailing group, with the comment trigger in a fixed-width slot so the icons form a column. And because half the previewable files have no thumbnail to click — a PDF, an mp3 and an mp4 all render as a generic icon — every row gains an explicit PreviewAction beside DownloadAction, matching whatever style that theme gives its download control. |
||
|
|
cca3d9c314 |
Group only the downloads, which are the actions that need it
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. |
||
|
|
6b76c11192 |
Answer "what happened to this file?" on the file's own page
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. |
||
|
|
9b265e3b87 |
Say what a stored upload part's receipt actually is (#1617)
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> |
||
|
|
6e47d76ba6 |
ProjectSend 2.0.0
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. |