mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 00:55:07 +00:00
main
31 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2768c87b27 |
Widen the start page picker so a translated default fits
"Predeterminado (Panel de control)" was cut off in the old fixed width. The picker now takes the column's width, up to a readable maximum, and still fits a phone screen. |
||
|
|
495f3ae471 |
Let each role, and each person, choose where they land after signing in
A role now has a start page: the dashboard, files, upload, groups, clients or the activity log (the last two for staff only). Anyone can override their role's choice in their profile. The administrator role takes a start page too, while everything else about it stays locked. A choice is only used if the account can open that page now. Otherwise the next one down is tried, ending at the dashboard, so a permission removed later never lands somebody on a 403. A role cannot be saved with a start page its own permissions block. A link followed before signing in still wins, and a waiting getting-started or what's-new page still goes first. Applies to password, two-factor and provider sign-ins, and to the site root for someone already signed in. StartPageTest opens every page for real, with and without its permission, so the enum cannot drift from the routes. Requested by @Zodiac1978 in #1777. |
||
|
|
c21658f6f7 |
Let a client account expire on a date
Staff can give a client an expiry date on the create and edit screens, and through /api/v1/clients. When the date passes, the client is refused at sign-in and on their next request, and their API access ends too. Files and history stay, and a later date (or none) brings them back. Access is checked through one predicate, User::maySignIn(), at every door: sign-in, the web session, API tokens and the two-factor challenge. An hourly sweep also switches `active` off, so the list, its filter and seat counts agree. The sweep is not what enforces it, so a scheduler that is not running cannot keep an account open. An account cannot be active with a date that has passed. Reactivating an expired client needs a new date in the same save. The day-means-end-of-day-where-you-are rule moved out of FileExpiry into a shared DateInput, so file and account expiry read dates the same way. Requested by @Drardollan in #1310. |
||
|
|
123ae68972 |
Give invitations their own place in the navigation
The "Invite client" button led to a history, which is not what it says. The tabs were a way of housing two things that had nowhere else to live, and now they do: Invitations is a sidebar entry between Custom fields and Groups, and the button goes to the form. That is also the shape every other list in this application already has -- Clients, Groups, Categories, Roles all sit in the sidebar with a "New X" button leading to their own create screen -- so the tabs were the odd one out rather than the pattern. Two URLs, each meaning one thing: /clients/invitations is the history, /clients/invitations/create is the form. Sending now returns to the history, where the invitation just sent is the first row. No badge on the sidebar entry, deliberately, unlike the two queues below it. Account requests and Membership requests count things waiting on somebody here; an outstanding invitation is waiting on the person who was invited. A number there would say "you have three things to do" about three things nobody in this installation can act on. Translations move with it: "History (:count pending)" was the tab label and is gone from all sixteen, and "Invite a client to share files with" comes back -- it was the form's description before the tabs took the heading, and had never been translated because it left the code in the same commit that would have reported it missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
74d1de2d6e |
Say why an invitation was refused when the installation is full
Two halves of the same gap. Redemption always provisions with autoApprove: true, so it always meets SeatAllowance::guardClient(), which refuses on the `email` field -- and the redemption form's email input is read-only, never submitted, and had nowhere to render an error. The account was correctly not created and the person was told nothing at all: the form simply came back. The field now renders errors.email, which is where every other account form's refusal already lands. The other half is the button. "New client" has been seat-limited since the limit existed -- it goes dead with the reason beside it, rather than offering a form that cannot be submitted. "Invite client" sat next to it, live, on a full installation. Worse than the original complaint, because the refusal is met by the invited person rather than by the staff member who caused it. So the invite button is seat-limited too, and sending guards as well as redeeming. An outstanding invitation is still not a client and is still not counted as one -- the rule a pending account request follows, for the reason SeatAllowance spells out -- so this reserves nothing. It refuses to send a link a full installation could not honour, and redemption keeps its own guard, because the seat can be taken by somebody else in the days between. SeatLimitedAction's usage caption is now optional, and the invite button omits it. Two buttons governed by one limit, each captioned with the same sentence, reads as two limits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPk8qAs38pudYGWwmGkYPe |
||
|
|
896675d631 |
Tell a client whether their own file arrived
"Did it arrive?" is the question somebody asks about a file they sent, and on a hosted free account — where a link is the whole of the sharing — the count is the only evidence either way. Every file a client uploaded now shows how often it has gone out and when it last did, in every render mode of every theme. Only their own. A download entry says somebody fetched the file, so a count on a file shared with several clients tells each of them about the others' activity, and nobody is entitled to that but the person who put the file there. A file shared *with* this client carries null, not zero: a zero would itself be a claim, and the two have to be distinguishable because zero is an answer the owner came looking for and is shown as words. Counted from the activity log through the same three actions DownloadAllowance uses, so a file leaving by the public site counts as much as one leaving by its link. One query for a listing, none at all for a client with no files of their own. Both filters have a test that fails when only that filter is removed. Two things a render check caught that types and a green build did not. `t()` does no plural selection — the catalogues are flat key/value — so a "one|many" string reached the screen with its pipe intact; the strings are whole sentences now, with the singular spelled out. And the gallery card was already laying its text out beside the action icons in a 200px column, truncating the filename to "Q…" and the size to "75 …" on main today; stacking them gives every line its full width. |
||
|
|
92bb807849 |
Show a client the public link to their own file
A client's portal lists two kinds of file side by side: what they uploaded, and what somebody shared with them. Where a link exists on one of their own, they can now copy it from the row — which is what makes the hosted free plan a product rather than a place to put files, since a customer there has no staff screen on which to make one. The rule is narrow, and both halves are load-bearing: a link this client created, on a file this client uploaded. Not "a link on a file shared with them" — that link is the sharer's decision about who may reach the file, and handing the recipient the URL would quietly turn "you may download this" into "you may pass this on to anyone". And not "any link on their own file" either — a link staff minted on a file a client uploaded exists for a reason the client may be no part of, and on the shared instance it would sit beside the one link they were promised. Ordering is by id, so an unfiltered lookup would hand them whichever was minted first. Both halves have a test that fails when only that half is removed. The first draft did not: every case was carried by the ownership filter alone, so the creator check was green for the wrong reason. Links that no longer work are left out rather than shown greyed. The only thing a client can do here is copy it, and a URL that answers "this link has expired" is worse than no URL at all. One query per listing, not one per row, and none at all for a client with no files of their own. |
||
|
|
3d923188d9 |
Show an announcement on the client's own file portal
The band existed only on the staff dashboard, which is a screen a client never opens. On a shared instance the customer *is* a client account — they sign in, upload, and share by link, and the administrator is the operator — so a message for them has to reach the page they actually use. ViewerAnnouncement reads the shared prop itself rather than taking one, so each theme adds it in a single line without threading a prop through a page that has no other reason to know about it. All four portal themes render it, because a message that only appears in the theme somebody remembered to wire is a message that quietly does not exist. Above the heading, not inside the list: it is not one of the things the client came to do, and burying it under the files would defeat the point of having it at all. No theme decides who sees it. Core drops anything not aimed at the viewer, so a theme renders whatever it is handed and cannot leak a staff message to a client by being careless. |
||
|
|
a5496d24cd |
Stop describe() vouching for a detection it could not make
`FileDelivery::describe()` from a console returned
`{"method":"php","detected":true}` on every installation, whatever its web
server. detect() reads SERVER_SOFTWARE, which only exists inside a
request, so a console process has nothing to look at and falls to the
`php` default — and `detected: true` then vouched for it.
The value is right for that process and wrong as a statement about the
installation, which is how anybody running it from `artisan tinker` will
read it. Somebody verifying a healthy nginx tenant hit exactly that, spent
an afternoon on it, and only recognised it as an artefact of *where* the
question was asked after reading `nginx -T` in the container.
There is now a third field. `observed` is false only outside a request,
where `method` is a default rather than a finding. Both screens that read
this run in a request and always see true; it exists for whoever asks from
a shell, which is the one place the answer could mislead.
The two web paths are unchanged and were never wrong — `projectsend:status`
does not report delivery at all, so no fleet ever reported this
incorrectly. What was wrong was a confident answer to a question that
could not be answered from where it was asked.
Three tests: a console reading says not observed and still says php,
because php is what that process would actually do; a reading during a
request observes nginx; and a stated method is observed wherever it is
read, since a decision needs nothing detected to be true.
Found by the session verifying the 2.4.0 canary, not by me.
|
||
|
|
d7d7acce85 |
Put the announcement behind the header icon too, from one source
A message worth showing was only on the dashboard, which means somebody who works in Files and Clients all day never meets it. It now also sits behind an icon next to the notification bell, and that is on every page. **One shared prop, not two.** "The same message in both places" is the requirement, and two props would have drifted the first time anybody edited one — so the hook moved out of DashboardController into HandleInertiaRequests, and the dashboard reads the same shared value the header does. The band and the dropdown also share the component that renders the words, for the same reason: the reliable way to keep two renderings identical is not to have two. Renamed with it. ResolvingDashboardCallout was accurate for about an hour and became a lie the moment it appeared somewhere else; it is ResolvingAnnouncement now, and the prop is `announcement`. Free to rename because nothing has shipped yet — the only other reference was cloud-modules', by string, updated alongside. The icon follows UpdateAvailableIcon beside it: absent entirely when there is nothing to say rather than a dead control, and a plain dot instead of a count, because there is only ever one of these and a "1" would invite somebody to look for the second. Two tests worth naming. One asserts the message reaches a page that is not the dashboard, which is the whole point of the addition. The other asserts a client is shown nothing even from a listener that sets it unconditionally — a client's header carries the bell too, and staff messages must not reach it however careless the listener. |
||
|
|
334b11d562 |
Give packages a way into the sidebar and the top of the dashboard
Two seams, in the shape docs/extension-points-architecture.md settles on: a Laravel event with a mutable payload, dispatched unconditionally, and with nothing listening the documented default holds. A community installation gets an empty list and a null callout, which is exactly what it had before. ResolvingNavigationLinks exists because the sidebar is a hardcoded array in app-sidebar.tsx, so a package could not contribute to it at all — the nav entry was a separate manual edit every time a package grew a screen, and being manual it was forgotten more than once. Staff-only, decided in HandleInertiaRequests rather than trusted to each listener: these render in the administration area, and a client's portal shows their own files and nothing about the installation. There is a test that a listener adding unconditionally still reaches no client. ResolvingDashboardCallout is one band above the widget grid rather than a widget in it. The grid is a closed list of keys that dashboard.tsx renders one by one and each viewer arranges, so a message that mattered would sit wherever somebody dragged it, or under a fold, or switched off. One at a time, first listener wins: a dashboard that can accumulate banners accumulates them, and the second is what teaches people to skip the first. Core learns nothing about what either seam carries. Titles, URLs and copy all arrive from the listener, and that is not fastidiousness — the first caller is the hosted edition's link to its own customer portal and its pitch to free instances, which is commercial copy belonging to one offering and has no business sitting in the public repository because the sidebar happens to live here. An external link renders as a plain anchor opening in a new tab, never an Inertia <Link>: Link expects a page component back and another origin will not give it one, so it fails without saying so. It is also never marked active — nothing outside this app is the page you are on. |
||
|
|
ea214fc27e |
Give the client portal a file editor
The authorization landed last commit; this is the way in. A client with edit_files now gets an Edit action on the files they uploaded, opening a form with every field their role actually grants, and a Delete beside it. One page for every theme, not one per theme. portal/edit-file.tsx picks its shell from the `theme` prop exactly as portal/upload.tsx does, because a form with eight fields behind five separate permissions, rebuilt four times, is four places for a field to go quietly missing. What *is* per-theme is only the entry point: one <FileRowActions /> in each theme's row actions group, the file twin of the FolderRowActions that was already there. Row actions gate on can_update/can_delete, sent per file by MyFilesController and answered by FilePolicy — never on is_mine, which is half the question. Holding the file is one half and the role's keys are the other, and a theme that reads is_mine offers an Edit button that 403s. Written into docs/theming-files-checklist.md so the next theme does not have to rediscover it. The folder picker offers only folders the client could have uploaded to, so it cannot present a destination the save would refuse. Publishing says in plain words that anyone with the link will be able to open the file without signing in, and says so differently when the installation has no public page configured, because there the switch would do nothing visible. Hiding a control is a courtesy, never the enforcement. Every can_* prop here is the same question ApplyFileEdits asks when the form posts, and the tests assert both ends. Verified in a real browser over CDP rather than only by types and tests, which say nothing about whether a page mounts: 23 edit actions on the client's 23 own files and none on the file shared with them, the editor mounting with its real values, every gated field present, no console errors. The dev instance's Client role was snapshotted before the run and restored to exactly what it was. Refs #1771 |
||
|
|
d32788e4a1 |
Put the CAPTCHA settings screen behind a capability
The screen is open in both editions and stays that way by default, so a self-hosted installation loses nothing: nobody else supplies its keys, and nobody else is affected by what it decides. What the key buys is the ability to take it away. A hosted fleet puts every tenant on one parent domain and one sending reputation, so an administrator who turns their own CAPTCHA off is spending everybody else's deliverability rather than only their own. That is not the shape LDAP and social login have, which is why those two stay ungated and this one does not. Gated all-or-nothing on the route, read included, exactly as Storage and Branding are. Per-field gating in the controller would not have closed it: switching the CAPTCHA off needs none of the gated fields — `provider: none` does it, and so does unticking the four per-form switches while leaving good keys in place — so the PATCH had to be closed too, and the middleware closes both verbs at once. Which keys the screen may offer is still the separate, narrower question Capability::CaptchaManagedKeys answers per field. An operator withdraws it by naming captcha.configure in PROJECTSEND_CAPABILITIES_DISABLED. Note that the key also joins the list `projectsend:status` and GET /api/v1/me report, which is additive — the OpenAPI document types capabilities as an untyped array, so nothing there needed regenerating. |
||
|
|
25e4f77b63 |
Make the whole downloads row open the explanation
The warning triangle alone did not read as clickable. The label is now an underlined button and the value and icon are a second one, so either half opens the dialog and the row looks like the one thing on this card you are meant to act on. Two buttons rather than one wrapping the row: a dt/dd pair cannot be nested inside a single button without losing the description-list semantics that tie the value to its name. The value button carries an aria-label because "PHP, button" describes nothing on its own. Only when PHP is sending the files. On the fast path the row stays plain text, since there is nothing to go and read. |
||
|
|
90ed2d60b9 |
Shorten the PHP downloads explanation
It was 2975 characters and scrolled. Same four questions answered and nothing dropped -- what is happening, what it costs, why it is set that way, the three ways out -- in 1696, which fits the dialog without scrolling. The three fixes are a list rather than three headed paragraphs, since each is one instruction. A wall of text explaining a performance trade-off is self-defeating: the person who most needs to read it is the one who opened the dashboard for something else. |
||
|
|
d6fd5a917d |
Send downloads the way the web server in front of us understands
Uploads live outside the web root, so PHP authorizes every download and then hands the file to the web server with a header naming it. Four routes decided that for themselves and all four hard-coded nginx's spelling. On Apache or LiteSpeed nothing acts on the header, so the empty body PHP sent goes to the visitor: files upload fine, thumbnails are broken images, and downloads arrive as 0 bytes, with every other page working. Reported as #1765 from an Apache 2.4 install, and before that as #1266, #1215, #870 and #1271. It is also a regression from v1, which had a download_method setting -- php, apache_xsendfile, litespeed, nginx_xaccel -- defaulting to php. v1 therefore worked on any server out of the box and v2 did not, and a v1 Apache user migrating lost every download with nothing to tell them why. So the four sites now go through one FileDelivery, and it picks: auto (default) nginx when SERVER_SOFTWARE says nginx, else php nginx X-Accel-Redirect, a URL path via the internal location xsendfile X-Sendfile, an absolute path (Apache mod_xsendfile, LiteSpeed) php BinaryFileResponse Defaulting to auto rather than nginx is the point of the change: a default that assumes nginx leaves an Apache install exactly as broken as it is today until somebody reads INSTALL.md. Slow beats empty. Auto never picks xsendfile, even where the module is loaded. mod_xsendfile also needs XSendFilePath to allow the storage directory, which cannot be seen from here, and choosing it on the strength of the module being present would trade a silent failure an administrator can diagnose from the dashboard for one nobody can. BinaryFileResponse rather than a readfile loop because it answers Range requests. nginx does that itself on the fast path, so hand-rolling it would have broken seeking through a video on exactly the installations this fallback exists for. Verified end to end: 206 with the right Content-Range through the live stack. Two guards. Every method checks the path cannot climb out of the storage area -- nginx resolves `..` in the URL it is handed as happily as PHP would -- and the two methods that hand over a filesystem path resolve it and prove it lands inside the root. Callers pass paths from rows they just authorized, so this is a backstop; it is here because the cost of being wrong once is handing over any file the web server can read. The dashboard's System panel names the method, with a warning icon and a dialog when PHP is doing the sending: what is happening, what it costs (one worker held for the whole of each download, so a few large simultaneous ones can occupy every worker while the processor sits idle), why it is set that way, and the three ways out. Written to be accurate rather than reassuring -- nothing is broken, it does not scale -- and the notice stays even when php was chosen deliberately, because the trade-off is the same either way. /system/settings/downloads repeats it, which is where somebody coming from v1 goes looking for the dropdown. An environment variable rather than a stored setting: it describes the server this installation runs on, not a preference, and a value in the database travels to a different server in a restore and is wrong there. Read only in config/projectsend.php, so config:cache cannot blank it. The suite pins itself to nginx. Left at auto it would detect no server at all, fall back to php, and quietly retire the coverage of the mechanism most installations actually use. |
||
|
|
13b56186f4 |
Say the seat limit before the form, not after it
On a managed installation with its staff seats full, /users/create opened as though there were room. You typed a name, an address and a password you had to invent, pressed Save, and the plan limit came back as a validation error under the email field -- which reads as a complaint about the address rather than a fact about the plan. A full installation is an ordinary state on a plan sold by the seat, so it is now stated up front. The list carries the seat position, the button goes dead once the last seat is taken and says why, and the create screen turns away anyone who reaches it by link or bookmark. The guard in store() is untouched: that is still the rule, this is only the door. The refusal is worded once, in SeatAllowance, and the screen is handed that sentence rather than writing its own -- two wordings of one limit is how somebody ends up believing there are two limits. `full` is derived there too, from the same comparison the guard refuses on, so a screen cannot disagree with it about the edge (used > limit, after an operator lowers a limit) and offer a button for a form that cannot be submitted. Clients get the same treatment: the cap exists there too, and reached it the same way. Self-hosted installations have no limit, so they are shown nothing about one. |
||
|
|
5d99ab94fd |
Say on screen when nothing is building zip downloads
Zip building moved onto its own queue, which a manual install's worker has to be told about. update.sh repairs the service file and Docker is unaffected, so the population left is somebody upgrading by hand who skipped the release note — and for them the failure is the worst shape available. Email keeps going out perfectly. Zip downloads never finish. Nothing in any log says why, because nothing went wrong: the jobs sit on a queue nobody is reading. The person who missed it has no reason to suspect anything, so the notice has to go looking for them. The application cannot see its own worker processes, only whether work gets done, so the question is asked from the other end: was a build requested that no worker ever picked up? That needs a record of when a build *started*, which is what the new zip_downloads.started_at column is — stamped before any of the work, so it says a worker had the row, not that the row succeeded. Two conditions, because either alone cries wolf. A build has waited past five minutes and was never started, *and* no other build is in hand. The second matters because one worker builds one archive at a time: a queue behind a large build is a healthy queue, and its waiting rows look exactly like abandoned ones until you notice something running. "In hand" is bounded by the job's own timeout, so a worker that died holding a build stops counting as alive an hour later. The banner sits beside the stale-code one, on every staff page rather than the dashboard alone, gated on view_system_info for the reason that one already argues: a background worker not picking work up is a fact about the machine, not a feature of an edition. It names the fix rather than the symptom — "your worker command needs --queue=default,zips" — because somebody reading that downloads are not being processed still has to work out what to do about it. Eight tests, covering both halves of the discrimination rather than just the happy one: a queue waiting behind a live build stays quiet, and a build held by a worker that died does not. Translated into all sixteen locales in the same commit, since a release is close and a banner nobody can read is worse than none. Checked on screen as well as in assertions, with a real stalled row on the dev stack: the banner renders, wraps, and reads correctly. |
||
|
|
3b51c5308c |
Translate the fourteen strings today's work added, into all sixteen locales
Everything merged today landed in English, which is the deliberate trade: a feature never waits on a language nobody in the room speaks. This is the pass that settles up. Fourteen keys, sixteen locales, 224 entries. Appended rather than sorted in, matching how the previous passes left these files, so the diff is additions and one trailing comma per catalogue and nothing else. One of the fourteen was a bug rather than a gap. The scoped expired-files note was written with a `’` escape in the TSX, so the scanner read the raw source and the runtime read the interpreted string: two different keys for one sentence, and a catalogue entry for either one would never have matched the other. The apostrophe is now a literal character, which is what every other string in these files does. Checked mechanically — every :placeholder survives, no plural pipe count moved, `projectsend:erase-account` is intact in the two messages that name it — and then read on screen, because a file that parses is not evidence that a sentence fits its button. Settings -> Descargas renders its label, its paragraph and its help text in Spanish with no overflow. Orphans left alone at 250. The ten that touch today's subjects were checked one by one and every one is a false positive of the kind the skill warns about: `Expired files` is WIDGET_LABELS data, `Uploader` is a role name from the database, `Page Expired` is laravel-lang's. |
||
|
|
c8078f65c5 |
Say whose expired files the dashboard is listing
Closing the one thing
|
||
|
|
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. |
||
|
|
4f4fb92b85 |
Group the Settings menu by subject instead of by permission
The conditional entries were pushed onto the end of the list after the unconditional ones, so where an item appeared depended on whether it needed a capability rather than on what it was about: Storage sat under Languages, Email templates sat nowhere near Email, and Scheduler landed between Branding and About. Now there is one ordered list and each entry carries its own condition, so the order survives whatever the edition and permissions turn on. |
||
|
|
4f38c9adee |
Show one confirmation toast, not two
Every page wraps itself in AppLayout, so a flashed redirect that lands on a different page component tears the layout down and builds it again -- Toaster with it. The fresh Toaster then reads the flash at mount *and* catches the router success event for the same visit, and every "Client created." arrived twice. Saves that stay on the same component never remount, which is why this survived unnoticed. Deduping on the flash object's identity rather than its text is what keeps the success listener doing its job: two genuine identical messages in a row are separate objects and still both toast. Verified in a real browser rather than by types: create a client, two toasts before, one after, and two consecutive creates over SPA navigation still toast once each. Reported and diagnosed by @denkfabrik-li in #1675. |
||
|
|
8f12c83d21 |
Tell a clone-and-build install to rebuild, not to pull
ProjectSend prints the update instructions for the way this server was installed, and it knew two answers where it needed three: anything inside a container was handed `docker compose pull && docker compose up -d`. On the Compose stack that builds from a checkout there is no image behind those containers, so `pull` skips every ProjectSend service and `up -d` then finds them all current — the update reports success, changes nothing, and the dashboard goes on offering the same release. Reported by @mueller7382, who stayed on 2.0.0 that way while 2.1.0 was out (#1661). Those installations are now their own kind, told to `git pull` and rebuild, with the two steps a checkout needs that an image does not: its dependencies and its compiled frontend live outside git, so a release that moved either leaves them stale. Two signals decide it, in that order. The published image now declares itself with PROJECTSEND_IMAGE, which is the only evidence an operator bind-mounting over /var/www/html can neither hide nor forge; failing that — images published before this — a working tree in the install directory, which the image never has and the repository's own stack always does. getenv() rather than env(), because a cached configuration makes env() outside a config file return null, and the answer would flip silently on exactly the installs most likely to have cached it. The stale-code banner keeps treating both container kinds alike: what clears it is recreating the container, whichever way its image was built. The changelog also credits the reporter of #1663, which was missed when that entry was written. |
||
|
|
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. |
||
|
|
b671d0d74a |
Put the update script's useful options one click away
The screen that tells somebody how to update names one command and stops, which is right — the command is the procedure. But two of the options behind it are the ones an administrator wants at exactly that moment: whether it can take the backup for them, and whether they can just look without changing anything. Both were documented only in UPDATE.md and in --help, neither of which is open on the screen they are reading. They are behind an "Other options" link rather than printed, so the page in its resting state is unchanged and the one command stays the thing you see. Four lines, the two above plus the two for a run nobody is sitting in front of. It opens from the dashboard card and from the update dialog both, which means a dialog on top of a dialog in the second case. That is the right shape here: the reader asked for a footnote to what they are already reading, and Escape puts them back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b72d30d89e |
Sign the two greeting pages the way the website signs itself
"Made with care in Argentina. Shared with the world." — projectsend.org's own line, at the foot of the two screens where somebody is being greeted rather than getting on with their work. Drawn rather than set as the flag emoji the website uses. Regional indicator pairs have no glyphs on Windows or on most Linux desktops, and both fall back to a pair of small letters — the first render of this component read "AR Made with care in Argentina" on the machine that screenshotted it, which is what a good half of the people self-hosting this would have seen. Eleven lines of SVG look the same everywhere. The Sun of May is a plain disc: at sixteen pixels its rays are a smudge, and a smudge reads as a rendering fault rather than as a flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
046567fdfc |
Say thank you, and make the list scannable at a glance
Both greeting pages read like status reports. The install page opened ":name is installed and yours" without saying which version, the update page opened "The update finished, and everything came back up", and the quick-start list was eight full-width rows of two-line descriptions — about 1400px, with the last two steps and the invitation below the fold on a laptop. The install page now thanks somebody for installing ProjectSend and names the version they are on. The update page thanks them for updating and for continuing to trust it with their file sharing. Both revert to plain wording when the page is opened later from a link: thanking a reader again for something they did months ago is the cold thing, not the warm one. The list becomes a two-column grid of icon cards — four rows instead of eight, 1000px against 1490px, which is one screen. Icons come from the sidebar's own vocabulary, so the chip on a card is the icon on the screen it opens. Descriptions are one short clause each; the screen at the other end explains itself. And the steps stop pretending to be equally urgent. QuickStart now says which are essential — the two that make this application do anything at all, the mail server, the scheduler — and those carry an amber chip and a label, against the brand colour for everything else and green for the done ones. Amber is not invented here: it is the warning Alert variant's palette, reused verbatim so dark mode is somebody else's solved problem. The Discord card was two identical copies within an hour of each other, so it is one component now, before the pair could drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ed0d36de25 |
Reduce a manual update to one command that asks first (#1628)
Updating a server install cost nine artisan invocations plus a PHP-FPM
reload, written out in three places that had already drifted apart. One
of those steps is silently fatal to skip: with opcache.validate_timestamps
off — what production guides recommend and what our own image ships — the
database moves to the new version while every visitor keeps being served
the old code, and artisan reports the new version throughout.
`sudo ./update.sh` is now the whole procedure. It asks whether to check
GitHub, asks whether to download the release and verifies the checksum
published beside it, and asks whether there is a backup — offering to dump
the database when the answer is no. Then it takes the site down, replaces
the files, runs the update, reloads PHP-FPM, restarts the worker and
brings the site back. The application still has no self-updater: nothing
is fetched or applied unless somebody runs this and answers yes.
Underneath it is `php artisan projectsend:update`, which is everything an
update does that needs no root — and now the only definition of it. Both
container entrypoints call it instead of carrying their own copy of the
sequence, so the two paths cannot drift again.
Three findings worth keeping in the record, all from rehearsing rather
than reasoning:
- queue:restart has to come last. It writes its signal into the cache,
so clearing the cache afterwards deletes it and the worker runs old
code forever.
- optimize:clear is not safe to recommend. It runs cache:clear, which
on Redis is FLUSHDB — harmless on the default two-database layout,
but on a single-database Redis it takes the sessions and the queue
with it. The compiled caches are cleared individually instead.
- update.sh overwrites itself mid-run, because the zip contains it and
bash reads its own script lazily by byte offset. It re-execs from a
temporary copy before touching anything.
And when the reload is skipped anyway, the application now says so:
projectsend:update records the version it applied, and any staff page
compares that with what the running process actually compiled. The same
check catches the mirror image — new files in place, update never run.
Rehearsed end to end against real installs: a container upgrade (69 to 73
migrations, key and data intact, healthy), a scripted update on a real
nginx + php-fpm install with OPcache pinned (web process moved 2.1.0 to
2.1.1), the skipped-reload case (banner appears naming both versions, and
clears on reload), the refusals (downgrade, non-release zip, truncated
zip, URL passed to --zip, non-root), a database taken down mid-update
(site comes back out of maintenance mode by itself), and a real download
of the published 2.0.0 zip with its checksum verified.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
72749a9070 |
Document updating, and the OPcache step nobody documents (#1626)
Updating had a numbered list inside INSTALL.md and a code block in the dashboard, and between them they were missing the step that decides whether an update works at all. Rehearsed both paths end to end against a real 2.0.0 install before writing any of this down. On Docker the whole update is `docker compose pull && docker compose up -d`: the entrypoint waits for the database, migrates, ensures the roles, and starts the workers, and it does not start nginx until that finishes — so a failed migration is a container that stays down rather than a half-updated site taking traffic. The generated APP_KEY on the storage volume is left alone. Verified: 69 migrations to 73, key unchanged, data intact, healthcheck healthy. The manual path is where the gap was. Replace the files, run the four documented commands, and the site still serves the old version: with `opcache.validate_timestamps=0` — what every production guide recommends, and what our own image ships — PHP never re-reads a file it has already compiled. The database is on the new version, every visitor is on the old code, and `php artisan` cheerfully reports the new version the whole time you are trying to work out why. Reproduced exactly that, then watched a php-fpm reload fix it. UPDATE.md is now the whole procedure for both, INSTALL.md keeps the short sequence with the reload added, and the in-app instructions gained the same line — in the code block, which is not translated, so no locale is left saying something different. 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. |