mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-22 11:33:24 +00:00
5242169bb061855677bcb82c570aaa807afbb343
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
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.
|
||
|
|
503676647f |
Let a split-user host serve downloads
A download is not served by PHP. PHP authorizes it and hands the web server the path with X-Accel-Redirect, so the web server has to open a file PHP wrote. Where those are different users — cPanel and Plesk commonly arrange it that way — it cannot: uploads land 0600 inside a 0700 directory, and traversing 0700 means being its owner. Nothing else on the site shows a symptom. Uploading works, the library lists everything, and only downloads fail, as ERR_INVALID_RESPONSE in the browser and `open() ... failed (13: Permission denied)` in the web server's log. FILES_WEB_SERVER_READABLE writes uploads 0644/0755 instead. Opt-in and spread into the disk configuration rather than switched by a ternary, so an install that does not set it keeps byte-for-byte the configuration it had: the relaxed modes are readable by every account on the machine, which is the wrong trade wherever the web server and PHP are one user, as in the image and on most self-administered servers. The two halves are not enforced alike, which is the part worth knowing. `visibility` has Flysystem chmod each file after writing it, so 0644 holds under any umask. A directory is created by mkdir(), which masks its mode argument, so 0755 is a ceiling: a pool at umask 0077 still produces 0700 and still cannot be traversed. That cannot be fixed from config, so INSTALL.md carries it — how to tell the two users apart, the one-time chmod for files already on disk, and the pool setting for the umask. FilePermissionsTest asserts all three modes, umask cases included, since the asymmetry is invisible from the configuration. Reported by @denkfabrik-li (#1668), who diagnosed it and verified the remedy on the affected host. |
||
|
|
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. |
||
|
|
98597d462d |
Give a deleted folder's name back
Delete a folder called Test and you could never have a folder called Test again. The deletion worked, the folder left the screen, and the name went with it — permanently, with an error that named a collision against a row the interface will not show you and offered nothing to do about it. Files and groups had it too. All three carry a unique index on slug and all three soft-delete, so the trashed row sat in the index holding a name nothing could reach. A public one failed outright at the validator, which checks the table and therefore sees rows the screen does not. A private one failed more quietly: the derived slug stepped around the trashed row into report-2, then report-3, once per deletion, climbing forever. The reservation was deliberate — a trashed row's slug was kept so that restoring it could not land on somebody else's URL. But nothing in this application restores anything. There is no restore() call, no route, no screen; File's own comment says as much. Soft deletes are here so rows can outlive their delete for foreign keys, the activity log and the erasure grace period, never so they can come back. The slug was being held for a page that could not return, and route binding already 404s the trashed row in the meantime. So deleting now hands the slug back, and the database is what makes that a rewrite rather than a gentler lookup: teaching the collision checks to skip trashed rows would leave two rows holding "report", which the unique index rejects whatever the application thinks. The slug moves to report__deleted-42 instead. Underscores are the whole trick — Str::slug() turns them into hyphens and Rules::slug() refuses them outright, so no derived slug and no hand-typed one can ever land on a vacated one. That is a guarantee about the character class rather than a hope about collisions. The format lives in VacatedSlug rather than on the trait because the migration needs it too and a trait constant cannot be reached through the trait's own name — the first version of this was a fatal error waiting for whoever ran migrations. The migration matters as much as the hook: without it the fix only helps installations that have never deleted anything, and every name already buried stays buried. The collision checks still count trashed rows. It costs nothing and keeps them honest about what the index will accept if a row is ever soft-deleted by something that bypasses model events. previous_file_id had this same bug and was fixed this same way, in File::detachOnDelete — a trashed row holding its predecessor's unique slot so the chain could never be re-linked. This is that fix, for the other four unique indexes' worth of the same mistake. users.email is the one left, and is deliberately not in here: an email address is a login identity rather than a URL handle, and freeing it silently is the wrong answer. Fixes #1645 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. |