24 Commits

Author SHA1 Message Date
ignacionelson fba5f30436 Release 2.4.0 2026-09-08 09:33:14 -03:00
ignacionelson 0f66f9030c Cut the unreleased notes down to what an operator needs
Each entry was three paragraphs explaining itself. Somebody deciding
whether to upgrade reads a list, and a list that takes ten minutes is one
they skim — so the reasoning is gone and the fact is what is left.

What stayed long is Upgrade notes, deliberately: those are the two things
somebody has to *do*, and a one-liner that says "allow headroom" without
saying how much or when is a note they have to come back and ask about.

This makes the section shorter than 2.3.0 and 2.2.1 above it. Those are
published and stay as they are; the style changes from here.
2026-09-08 09:06:00 -03:00
ignacionelson 7c16733c16 Stop a managed instance being able to hide the project news
I shipped both daily calls as the same kind of thing — an operator's
preference — and only one of them is. That was wrong in the direction that
matters, because it handed a decision over rather than keeping it.

An update notice on a hosted tenant is useless: they cannot act on it, the
image is ours, and the screen that would show it is closed by capability.
So that check does not run there at all, which is right and unchanged.

News is the reverse. Announcements about the product are exactly what a
hosted customer should be told, and a Cloud client with view_news sees
that card today. One administrator switching it off for everybody on that
instance is not a decision the platform meant to hand over — so on a
managed instance the news now runs whatever any setting says, including a
row left behind by an instance that used to be self-hosted.

Capability::NewsConfigure, Community-only, and the thing it gates is the
*choice* rather than the news. A self-hosted operator keeps the switch,
because there nobody else decides what their installation reaches out for.
An edition difference through the capability registry rather than an
edition check, as everything here is.

Gated in all three places rather than only the screen: the command ignores
the setting without the capability, the controller neither sends nor reads
the field, and the checkbox is absent. There is a test that a hand-crafted
PATCH cannot do what the missing checkbox could not, and the guard is
proved load-bearing — remove it and the managed-instance test goes red.

The changelog and product highlights said "two switches" and now say what
is actually true, including that neither appears on Cloud and why they are
absent for opposite reasons.
2026-09-08 02:34:00 -03:00
ignacionelson 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.
2026-09-08 02:17:56 -03:00
ignacionelson 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.
2026-09-08 02:07:08 -03:00
ignacionelson da1f432d87 Let an installation stop calling home, two different ways
Every instance reached projectsend.org twice a day and an operator could
stop neither. The news feed had no switch of any kind — FetchNewsCommand
went straight to the request, touching Settings only to write results back.
The update check had one, but its default is on, and a managed fleet had
been setting PROJECTSEND_CHECK_FOR_UPDATES=false for months against code
that reads no such variable: check_for_updates is a database setting, so
the environment never touched it and updates were enabled fleet-wide the
whole time.

They look like one problem and are two, which is why they are fixed
differently.

**The news feed gets a Setting**, its own key, default on. A Cloud client
with view_news sees that card today — DashboardController gates it on the
permission alone, with a comment saying in as many words that it is both
editions and carries no capability. So switching it off is an operator's
choice rather than an edition's, and it must stay reachable everywhere.
Its own key rather than riding on check_for_updates because they are two
different wants: "do not tell me about releases" and "do not show me the
project's news" are asked separately, and an installation with no outbound
access at all wants both.

**The update check gets a capability guard**, ahead of the setting it
already had, and deliberately not a Setting of its own. On a managed
installation the result is unreachable rather than unwanted: the
dashboard's System card and the update UI are both gated on
Capability::SystemUpdates, which is Community-only, and the image is
chosen by whoever provisioned the instance. A Setting would encode a fact
about the edition as a preference — leaving it switchable back on per
tenant, buying a nightly call for a number no screen can draw, and putting
the reason in a provisioning script rather than beside the code. A
self-hosted install holds the capability and loses nothing: its own
setting still decides.

Both guards return success rather than failure. A scheduled task that was
asked not to run has not failed, and reporting it as one would put a red
line in the scheduler history every night for an installation behaving
exactly as configured.

The news switch is on the General settings screen, outside the
can_manage_updates block that hides the update toggle where the capability
is absent — a setting only reachable by editing a database row is a row,
not a switch. Seven tests, and the two that matter go red when either
guard is removed. Sixteen locales translated in the same commit rather
than left for the pass, since a release is close.
2026-09-08 01:27:08 -03:00
ignacionelson 82dd475f8f Write up what #1724 and #1733 mean for an operator
Two entries under Unreleased, both for the same reason: an operator would
otherwise be surprised.

The shorter download link is a behaviour change with a cost attached — a
resumed download more than a minute old is refused where an hour tolerated
it — so it says that plainly rather than only advertising the benefit. It
also says who is not affected, since installations on local disk never used
one of these links at all, and neither do zip bundles.

The upload fix is an ordinary bug fix and would normally need no entry, but
it moves peak temporary disk from "the file plus one part" to "the file
twice over" while assembling. That is a sizing question somebody with a
small temp volume has to answer, so it gets an upgrade note. Nothing to
configure — just headroom.
2026-09-07 19:25:30 -03:00
ignacionelson b758fca19c Merge pull request #1724 from fix/assemble-keeps-parts-for-retry
Keep an upload's parts until its bytes are stored
2026-09-07 19:24:06 -03:00
ignacionelson 02946abf85 Stop the delivery docblock naming nginx as the only local path
#1733 explains its two lifetimes by contrasting a presigned URL with
X-Accel-Redirect, "nginx serves these bytes, now, to this request". That
was true when the branch was written and stopped being true on 1 September,
when FileDelivery gave the local path four methods — auto, nginx, xsendfile
and PHP streaming.

The argument survives intact: every one of those authorises exactly one
response and nothing that outlives it, which is the property the contrast
rests on. Only the naming was stale, and a docblock that says "nginx" to
an operator running Apache reads as "this does not apply to me".

Found resolving the merge, not by the author — the branch predates the
change it collided with.
2026-09-07 19:24:00 -03:00
ignacionelson b7ac44e77b Merge pull request #1733 from fix/presigned-download-window
Give a download's presigned URL a minute rather than an hour

Conflicted against FileDelivery, which landed on main after this branch
was written: main added a constructor where the branch added two
constants. Both belong; the resolution keeps each.
2026-09-07 19:23:52 -03:00
ignacionelson 50a6a19455 Translate the client file editor into all sixteen locales
The eight strings the portal file editor added, which had been sitting in
English since the feature landed — the deliberate trade, but the pass is
due now that the English has settled.

Nothing else came up. The scan reports eight missing per locale and they
are all from this feature, so no unrelated drift crept in alongside it.

Written against each catalogue's own established voice rather than
translated fresh: Spanish stays informal, and "Expires on" takes the verb
its neighbouring "Leave empty for a file that never expires" already uses
in each language. Quoting follows each locale too — Russian keeps its
guillemets, Japanese its corner brackets, Chinese and Vietnamese their
curly quotes — matching how the sibling folder-deletion warning already
reads there.

Every :name placeholder survives verbatim, checked rather than assumed,
and the diff is additive: the one deleted line per file is the previous
last entry re-emitted with a comma.

Checked on the screen, not only in the file, which is the part a parsing
JSON cannot show: the editor rendered in Spanish with every label and hint
in place, "Vence el" agreeing with the hint below it, and no console
errors. The dev instance's Client role was snapshotted, granted the keys
for the run, and restored; the throwaway file it needed is gone.

Locale suite green, 13 tests. The 249 orphans each catalogue reports are
older than this work and left alone deliberately — scan.php cannot see a
key held as data or supplied by a package, and a wrongly deleted entry
reverts a screen to English in silence.
2026-09-07 12:48:10 -03:00
ignacionelson 8de28059db Say when a folder choice publishes the file
Found reviewing the client file editor rather than building it.

File::isEffectivelyPublic() is "my own flag OR my folder's", and
Folder::uploadableBy() admits a client to a public folder on
upload_to_public_folders — a different key from upload_public. So a client
can make a file world-readable without touching the public switch, and
without holding the key that switch is behind.

That is what those two keys have always meant and what uploading into such
a folder has always done, so this does not refuse it. What was new is
where the choice is made. The upload page is entered from a folder the
client has already navigated to, where the list shows a Globe badge on a
public folder. The editor's picker is a flat list of names, and it is the
first place a destination is chosen with none of that context — so the
consequence was invisible exactly where it mattered most.

Public folders now carry the badge in the picker, and choosing one says in
words that anyone will be able to open the file without signing in. Two
tests: that the side door genuinely publishes and is labelled, and that a
private folder is not labelled — a warning on everything is a warning on
nothing.

The rest of the review found no defect. Ownership, the per-field keys, the
staff-scope trap and mass assignment were already covered; a client
deleting a file that staff later revised was checked directly and moves
the chain's recipients onto the successor without widening them, which is
what it is supposed to do. The write path was driven in a real browser —
rename, publish and delete through the actual form and dialog — because a
green suite over a write that 419s in every browser is a mistake this
repository has made before. Bytes gone, audit trail complete, and
file.made_public records the slug.
2026-09-07 12:09:27 -03:00
ignacionelson 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
2026-09-07 11:15:22 -03:00
ignacionelson 922be7226c Let a client edit and delete the files they uploaded
A client could upload a file and then never touch it again. No rename, no
description, no expiry, no categories, no delete — the portal has three
file routes and all three are GET. Meanwhile the Roles screen happily
grants the Client role edit_files, delete_files, set_file_categories,
set_file_expiration_date and upload_public, and every one of them was
inert, because the routes that honour them are `staff`-gated rather than
permission-gated. That is what #1771 hit: a permission granted, saved, and
silently doing nothing.

A client owns what they uploaded. Ownership is now what lets them edit and
delete it, subject to the same per-field keys staff are subject to.

The obvious implementation is a trap, and it is worth writing down. Both
policy methods began `if (! $user->isStaff()) return false;` and both end
in StaffLibraryScope, whose allowsFile() reads `if (! isClientScoped())
return true` — and isClientScoped() is `isStaff() && role->client_scoped`,
so it is false for every client. Delete the early return and a client
falls into the branch meaning "this staff member is unrestricted" and is
handed the whole library. Same for folders(), which returns an unfiltered
query: a client could move their file into any folder on the installation.
So clients get their own branch, reaching neither. The portal asks
Folder::uploadableBy() instead — a file cannot be moved somewhere it could
not have been uploaded.

edit_others_files and delete_others_files stay inert for clients by
construction. A client has no others' files, only files somebody showed
them, and being shown a file is not being given it.

Which fields an editor may write moved into ApplyFileEdits, shared by the
staff editor, /api/v1 and the portal. There were two copies of the same
eight permission checks and this would have been the third; the checks are
easy, which is exactly why the drift would have been invisible. Callers
normalise their own request shape, this gates and writes and logs. Expiry
reading and writing came along too, as FileExpiry — three copies, of which
only the API's could read a timestamp.

Clients do not choose the public slug. It is derived from the name they
already picked, because an installation-wide unique slug a client sets is
a name to squat and an existence oracle to probe with.

One consequence for later, written up in docs/api-todo.md: the policy now
says yes to a client for file writes, so `staff-token` is the only thing
holding the API boundary where there used to be two independent refusals.
ActorBoundaryTest pins it, and asserts the policy passes first so the test
cannot quietly stop testing the middleware.

Also corrects a stale comment that claimed a deleted file's bytes stay on
disk. They have not since File::booted() grew a `deleted` hook; nothing
ever forceDelete()s a File row, so "until a purge lands" would have meant
never — which is why a client's delete frees their quota by exactly what
it frees on disk.

The UI comes next; this is the authorization, the routes and the tests.

Fixes #1771
2026-09-07 02:37:26 -03:00
ignacionelson 1e30e83f11 Stop projectsend:captcha-off claiming a success it did not have
The command writes Setting::CaptchaProvider = 'none'. On an installation
using the platform's managed keys, Captcha::resolve() returns
managedConfig() — read from config — before it ever looks at that setting,
so the write lands somewhere nothing reads and every form stays protected.

The command then printed "CAPTCHA is off". That is false in the worst
direction: the person running this is locked out and debugging, and the
message sends them away from the one thing that would have explained why
they are still being challenged.

It now says it changed nothing, and names PROJECTSEND_CAPTCHA_DISABLED,
which is checked ahead of the key source and is therefore the only one of
the two escape hatches that works on a managed installation. The docblock
said those two were equivalent; they never were.

Deliberately not gated behind captcha.configure. Gating it would take a
self-hosted operator's way back in — the alternative being a hand-edited
database row — to close something that on a managed installation does
nothing anyway. Reaching it needs a shell in the container, which needs an
RCE, at which point the CAPTCHA is not the problem.

The command had no test at all. It has three now, including one that pins
the ordering inside resolve(): if the environment check ever moves below
the key source, a locked-out operator loses their last way in.
2026-09-07 01:24:13 -03:00
ignacionelson 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.
2026-09-07 01:20:22 -03:00
Eliana Bracciaforte c3503a0651 Merge pull request #1769 from projectsend/docs/readme-projectsend-cloud
Name the official hosted version near the top of the README
2026-09-04 09:22:10 -03:00
Eliana Bracciaforte 51477cbd02 Name the official hosted version near the top of the README
ProjectSend Cloud already appears in LICENSING.md and CONTRIBUTING.md,
but not in the README — the first thing people and search engines read.
One paragraph after the intro names it, says who runs it, and points to
LICENSING.md for where the line between the free core and Cloud sits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 09:19:53 -03:00
ignacionelson 7c9847981a Patch 8 pending security advisories in dependencies
league/commonmark 2.9.0 -> 2.10.0 fixes an XSS bypass and three DoS
issues; nanoid, qs, brace-expansion, and @humanfs/node bumped via
npm audit fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iQNYLu5a65foArRdE9zzx
2026-09-03 11:57:15 -03:00
ignacionelson 7da4635f13 Say which clients a scoped staff member may be told about
A staff member limited to their own assigned clients could read the names
and ids of clients on nobody's roster but their own, out of ordinary file
metadata.

The file boundary was never wrong. Sharing means a file can legitimately
reach a scoped viewer through client A while client B uploaded it, or
while B also receives it -- StaffLibraryScope::buildFiles is right to
permit that, and a B-only file is still a 403. What was wrong is that
every response then went on to name B. FileResource serialised the loaded
uploader and each assignment unfiltered; ShareTargets::assigned took no
viewer at all, so the details panel published the recipient list as it
stands and forSubject narrowed available_clients while handing
assigned_clients straight through. FoldersController::fileRow,
FilesController::edit, FileDetailsController and ClientFilesController
each named the uploader the same way. The API's uploaded_by filter asked
the question without any name attached: it answered "does this client of
yours put files in front of a client of mine" for any id a caller cared
to try.

12a8ebe3 said the rule out loud while fixing topClientsByStorage -- "the
file was theirs to read and the uploader's name was not theirs to see" --
and then the rule stayed in that widget. So it is a class now.
ClientIdentityScope is the one decision, asked by every surface that
names a client, and it deliberately answers about clients only: a
colleague's name is not a client identity, and hiding it would hide who
uploaded most of the library from the people who work in it. Groups go
through it too, on the same argument -- a group is a list of clients
wearing one name -- which the report did not cover but is the same leak.

Two judgement calls worth naming. assigned() keeps returning the whole
truth and gains a warning, because VisibleCommentScope resolves
notification recipients from it and a recipient filtered out of that list
is one who never hears about a message addressed to them; assignedFor()
is the display half. And FileResource asks at serialisation rather than
in its callers' eager loads, which is the opposite of how the version
counterparts next door are narrowed: that one is set-shaped and folds
into a query, this one is a per-row roster check across eight call sites
in four controllers, two of them re-loading assignments after a write.

The tests assert on whole response bodies rather than on named keys. The
leak was never in one field -- the same name arrived through the
uploader, through the recipient list and through four screens -- so a
body that does not contain the name anywhere is the only assertion that
would have caught all of it. Ten of the eighteen fail without this
change; the rest are the negative controls, including that an unscoped
administrator still sees every name and that the uploaded_by filter still
works for a client on the roster and for staff.

Reported by @Noorkhalel, GHSA-whmp-p9hv-r7j7. Their write-up named every
affected surface and the root cause in each, which is most of why this
took one pass.
2026-09-03 00:56:41 -03:00
ignacionelson ddf09677f0 Document the branding endpoints where their callers are
Branding moved into the application on 2026-08-28 and its two read-only
endpoints came with it unchanged -- same paths under
/api/v1/modules/branding, same capability, same ability. Their
documentation did not: the guide still sent readers to
packages/cloud-modules/docs/api.md, so endpoints that every installation
now carries were described in a private repository almost none of their
callers can open.

The OpenAPI document is still not the place for them. OpenApiContractTest
skips api/v1/modules/* on purpose: that document is served
unauthenticated and has to be identical on every installation, while a
module's paths exist only where the module does. So this is a plain
markdown file beside the guide, and published like it -- ignored docs are
maintainer notes, and this one is for integrators.

It is the cloud-modules file moved across, minus the attribution switch:
that half stayed Cloud-only and has no API surface at all. The gate is
described as every edition holding branding.customize, with a hosted plan
able to subtract it, because that is what the enum now says.
2026-09-02 18:56:55 -03:00
ignacionelson 9b2aea4812 Say what the actions cast actually costs if it goes
The comment said a reader unmarshalling a map breaks on an empty array.
Checked against the reader since, and it is worse than that: the hosted
platform decodes the block into a typed struct and discards a block it
cannot read, and Go refuses a JSON list into a map outright. A [] here
loses the whole usage block -- downloads and uploads with it -- on the
day a tenant happens to have no counted activity, with nothing logging a
fault. The quietest installations would be the ones that went quiet.

Comment only. The cast was already right; what was missing was the
reason it is load-bearing, which is exactly the kind of condition this
week kept proving nobody had written down.
2026-09-01 02:05:29 -03:00
denkfabrik-li 5a9133bb07 Give a download's presigned URL a minute rather than an hour
StoredFileResponse hands external storage a presigned URL for an hour,
whatever the delivery is for. That URL is a bearer credential: whoever
holds it fetches the file without passing any of the caller's checks
again, and it outlives them. A download cap spent in the meantime, an
expires_at that falls inside the hour, an assignment withdrawn -- none of
them reach it, and nothing here can revoke one. It is also forwardable,
which the local path is not: X-Accel-Redirect authorises one response to
one request.

The two deliveries do not need the same window, so they no longer share
one.

A download has to survive being followed -- a redirect and a request --
which a minute covers with room to spare. An object store checks the
signature when the request arrives rather than while it runs, so a
transfer that starts inside the window finishes however long it takes.

A preview keeps the hour, because it is watched rather than fetched: the
player holds the URL and issues a Range request every time somebody seeks
past the buffer, so a minute would break playback of anything longer than
a minute. The class docblock now says that this is the trade being made,
instead of leaving it in a single number.

Two tests, one per window. Without the fix the download link is an hour
long.
2026-08-28 06:40:53 +02:00
denkfabrik-li f2b705beee Keep an upload's parts until its bytes are stored
complete() holds a lock whose comment promises "the lock's TTL releases
the claim if a completion dies mid-flight, so a later retry still works".
A retry has nothing to work from but the parts, and assemble() unlinked
each one inside the loop that read it -- so everything that can fail
afterwards took the retry with it.

Measured on main, with a disk refusing the write (the case the guard forty
lines further down was written for, found against a real GCS bucket):

  first complete  → 422, 0 parts left, the half-written copy left behind
  retry           → 422 "Upload is incomplete: missing parts."

For good: listParts() is empty, so no later attempt can ever succeed, and
the client has to send the whole file again. The abandoned copy sat in the
session directory until the sweeper came round.

The parts now go when abort() clears the session directory -- which
already ran on success -- and a failure deletes only the half-written copy
it made. The cost is temp space: peak usage during assembly is the whole
file twice over rather than the file plus one part. The docblock says so.

Also checked while here: every read and every write in the concatenation.
A failing fwrite is loud in practice, since Laravel's error handler turns
the warning into an ErrorException, but loud there is a 500 carrying a PHP
message where this method's other storage failure is a sentence the person
uploading can act on. A short write arriving without a warning would be
worse: the byte count and the checksum describe the buffer that was read,
so an unchecked one records a truncated file with a checksum matching
bytes that were never stored.

Two tests: the retry after a refused write now succeeds, and a temporary
directory that refuses writes (/dev/full, skipped where it does not exist)
fails the upload with this method's own message. Without the fix both go
red.
2026-08-28 06:40:47 +02:00
78 changed files with 3964 additions and 357 deletions
+1
View File
@@ -39,6 +39,7 @@ yarn-error.log
/database/seeders/DevDataSeeder.php /database/seeders/DevDataSeeder.php
/docs/*.md /docs/*.md
!/docs/api-guide.md !/docs/api-guide.md
!/docs/api-modules.md
!/docs/email-oauth.md !/docs/email-oauth.md
!/docs/api-zapier.md !/docs/api-zapier.md
+47
View File
@@ -13,6 +13,53 @@ Anything under **Upgrade notes** is something you have to do, not something we d
This section collects changes as they land; the release process turns it into a numbered entry This section collects changes as they land; the release process turns it into a numbered entry
when a version is cut. when a version is cut.
## 2.4.0 — 8 September 2026
Clients can now look after the files they uploaded, and this release closes three ways somebody
could see a little more than they should.
**New**
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
categories, download limit and public flag each behind the permission that already governs it.
A file shared *with* a client is still not theirs to touch.
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
default; off means the request is never made.
**Closed holes in who can see what**
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
out of file details and the uploader filter. Reported by
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
builds your email templates.
**Fixed**
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
supplied centrally, where it changed nothing.
### Upgrade notes
- **Resuming an interrupted download from external storage more than a minute after it started now
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
### Issues closed since 2.3.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
## 2.3.0 — 1 September 2026 ## 2.3.0 — 1 September 2026
If you run ProjectSend on Apache or LiteSpeed, this is the release to take. It installed fine on If you run ProjectSend on Apache or LiteSpeed, this is the release to take. It installed fine on
+5
View File
@@ -23,6 +23,11 @@ page to download it.
No public link passed around by email, no third-party service holding your clients' documents, no No public link passed around by email, no third-party service holding your clients' documents, no
per-seat pricing. It runs on your server, and the files stay there. per-seat pricing. It runs on your server, and the files stay there.
Prefer not to run the server yourself? [ProjectSend Cloud](https://projectsend.cloud) is the
official hosted version of ProjectSend, run by the same team — every subscription funds this free
software. The line between the free core and Cloud, and the commitments that go with it, are set
out in [LICENSING.md](LICENSING.md).
## What it does ## What it does
**For the people you send to** **For the people you send to**
@@ -24,7 +24,10 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\LatestReleaseInfo; use App\Modules\Platform\Updates\LatestReleaseInfo;
use App\Modules\Platform\Updates\RunningCodeState; use App\Modules\Platform\Updates\RunningCodeState;
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Inertia\Middleware; use Inertia\Middleware;
class HandleInertiaRequests extends Middleware class HandleInertiaRequests extends Middleware
@@ -84,6 +87,19 @@ class HandleInertiaRequests extends Middleware
// ignore this and always show it. // ignore this and always show it.
'attribution' => app(Attribution::class)->visible(), 'attribution' => app(Attribution::class)->visible(),
'capabilities' => $capabilities->enabledKeys(), 'capabilities' => $capabilities->enabledKeys(),
// Sidebar entries a package asked for. Shared rather than
// passed per page because the sidebar is on every page, and
// dispatched unconditionally so that with nothing listening
// the list is empty and the sidebar is exactly what it was.
// See ResolvingNavigationLinks for why core never learns what
// is in it.
'extra_nav_links' => $this->extraNavLinks($request),
// Shared rather than a dashboard prop, because it is shown in
// two places — the band on the dashboard and the icon beside
// the notification bell everywhere else — and "the same
// message" is the requirement. Two props would drift the day
// somebody edited one.
'announcement' => $this->announcement($request),
// Shared rather than passed by each page: the sign-in buttons, // Shared rather than passed by each page: the sign-in buttons,
// the registration form and the Connected accounts nav entry // the registration form and the Connected accounts nav entry
// all need the same list, and a nav entry to a screen with // all need the same list, and a nav entry to a screen with
@@ -301,4 +317,43 @@ class HandleInertiaRequests extends Middleware
/** @var array<string, string> */ /** @var array<string, string> */
return app('translator')->getLoader()->load($locale, '*', '*'); return app('translator')->getLoader()->load($locale, '*', '*');
} }
/**
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
*/
private function extraNavLinks(Request $request): array
{
$user = $request->user();
// Staff only, decided here rather than in each listener: these
// render in the administration area, and a client's portal shows
// their own files and nothing about the installation.
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
if (! $event->isStaff) {
return [];
}
Event::dispatch($event);
return $event->links;
}
/**
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
private function announcement(Request $request): ?array
{
$user = $request->user();
if ($user === null) {
return null;
}
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
Event::dispatch($event);
return $event->announcement;
}
} }
@@ -12,6 +12,7 @@ use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope; use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityPresenter; use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DashboardWidgetPreferences; use App\Modules\Audit\DashboardWidgetPreferences;
use Illuminate\Support\Facades\Event;
use App\Modules\Clients\ClientStorageUsage; use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Delivery\FileDelivery; use App\Modules\Files\Delivery\FileDelivery;
@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Access;
use App\Models\User;
use App\Modules\Groups\Models\Group;
/**
* Whether a viewer may be told who a client is.
*
* A different question from whether they may read a file, and the gap
* between the two is the whole reason this exists. A stranger client's
* upload can sit legitimately inside a client-scoped staff member's
* library — shared with a group one of their own clients belongs to, or
* assigned to one of their clients alongside somebody else's. The file is
* theirs to read. The other client's name is not theirs to see.
*
* Commit 12a8ebe3 said exactly that while fixing one dashboard widget, and
* then the rule stayed in that widget. Every other place that serialises a
* file went on publishing the uploader and each recipient by name, so a
* manager assigned to one client could read the names and ids of clients
* on nobody's roster but their own out of ordinary file metadata. That is
* what this class ends: one statement of the rule, asked by every surface
* that names a client.
*
* Two things it deliberately is not:
*
* - It is not a download check. The file boundary is StaffLibraryScope's
* and FilePolicy's, and it is already correct — a file belonging only
* to a client off the roster is a 403 today. This narrows what a
* permitted response is allowed to say, nothing more.
* - It is not applied to staff. A colleague's name is not a client
* identity, and hiding it would hide who uploaded most of the library
* from the people who work in it.
*
* Unscoped staff are unaffected: they may identify everyone, which is what
* `null` means everywhere StaffLibraryScope answers this shape of question.
*/
class ClientIdentityScope
{
/**
* Memoised per viewer, since the listings ask once per row and each
* miss is a roster query. Registered as `scoped`, so this lasts a
* request and is dropped between queue jobs — the same lifetime, and
* for the same reason, as StaffLibraryScope's own memo.
*
* @var array<int, list<int>|null>
*/
private array $clientIds = [];
/** @var array<int, list<int>|null> */
private array $groupIds = [];
public function __construct(private readonly StaffLibraryScope $scope) {}
/**
* Whether $viewer may be told that $subject exists, and what they are
* called.
*
* A null subject is permitted: there is no identity to leak, and every
* caller here is reading an optional relation.
*/
public function permits(?User $viewer, ?User $subject): bool
{
if ($subject === null) {
return true;
}
if (! $subject->isClient()) {
return true;
}
if ($viewer === null) {
return false;
}
if ($viewer->is($subject)) {
return true;
}
$ids = $this->identifiableClientIds($viewer);
return $ids === null || in_array($subject->id, $ids, true);
}
/**
* The same question about a client known only by id — used where a
* caller has a foreign key rather than a loaded model.
*
* An id that belongs to nobody, or to a staff member, is permitted:
* there is no client identity behind it to protect.
*/
public function permitsClientId(?User $viewer, ?int $id): bool
{
if ($id === null) {
return true;
}
return $this->permits($viewer, User::query()->find($id));
}
/**
* Whether $viewer may be told a group exists.
*
* A group is a list of clients wearing one name, so naming one to
* somebody who may reach none of its members says the same thing
* naming a client would. The set is StaffLibraryScope's
* assignableGroupIds — every group holding at least one of the
* viewer's own clients.
*/
public function permitsGroupId(?User $viewer, ?int $id): bool
{
if ($id === null) {
return true;
}
if ($viewer === null) {
return false;
}
$ids = $this->identifiableGroupIds($viewer);
return $ids === null || in_array($id, $ids, true);
}
/**
* A client's name, or null when this viewer may not be told it.
*
* Null rather than a placeholder on purpose: every consumer of these
* fields already renders "no uploader recorded" for a null, because a
* deleted account leaves one behind. Inventing a "Hidden" string would
* be a new thing for sixteen locales to translate and would itself
* announce that there is somebody there to hide.
*/
public function nameOf(?User $viewer, ?User $subject): ?string
{
return $this->permits($viewer, $subject) ? $subject?->name : null;
}
/**
* Drop the entries this viewer may not be told about from a list of
* id/name pairs describing clients.
*
* @param list<array{id: int, name: string}> $pairs
* @return list<array{id: int, name: string}>
*/
public function filterClientPairs(?User $viewer, array $pairs): array
{
if ($this->identifiableClientIds($viewer) === null) {
return $pairs;
}
return array_values(array_filter(
$pairs,
fn (array $pair): bool => $this->permitsClientId($viewer, $pair['id']),
));
}
/**
* @param list<array{id: int, name: string}> $pairs
* @return list<array{id: int, name: string}>
*/
public function filterGroupPairs(?User $viewer, array $pairs): array
{
if ($this->identifiableGroupIds($viewer) === null) {
return $pairs;
}
return array_values(array_filter(
$pairs,
fn (array $pair): bool => $this->permitsGroupId($viewer, $pair['id']),
));
}
/**
* Both halves of a `shares` payload at once, since the two lists are
* always filtered together.
*
* @param array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>} $shares
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/
public function filterShares(?User $viewer, array $shares): array
{
return [
'clients' => $this->filterClientPairs($viewer, $shares['clients']),
'groups' => $this->filterGroupPairs($viewer, $shares['groups']),
];
}
/**
* Whether this viewer is narrowed at all. Callers use it to skip
* per-row work for the common unscoped case.
*/
public function isNarrowed(?User $viewer): bool
{
return $viewer === null || $this->identifiableClientIds($viewer) !== null;
}
/**
* @return list<int>|null
*/
private function identifiableClientIds(?User $viewer): ?array
{
if ($viewer === null) {
return [];
}
// Deliberately the same set as "who may I share with". A client on
// the roster is one this viewer already works with by name; a
// client off it is one they have no business knowing exists.
return $this->clientIds[$viewer->id] ??= $this->scope->assignableClientIds($viewer);
}
/**
* @return list<int>|null
*/
private function identifiableGroupIds(?User $viewer): ?array
{
if ($viewer === null) {
return [];
}
return $this->groupIds[$viewer->id] ??= $this->scope->assignableGroupIds($viewer);
}
}
+30 -2
View File
@@ -28,13 +28,25 @@ use Illuminate\Support\Collection;
*/ */
class ShareTargets class ShareTargets
{ {
public function __construct(private readonly StaffLibraryScope $scope) {} public function __construct(
private readonly StaffLibraryScope $scope,
private readonly ClientIdentityScope $identity,
) {}
/** /**
* The clients and groups a subject is already shared with, as id/name * The clients and groups a subject is already shared with, as id/name
* pairs. Neutral keys, so callers can nest it ('shares' on the details * pairs. Neutral keys, so callers can nest it ('shares' on the details
* panel) or flatten it (the edit pages' assigned_* props). * panel) or flatten it (the edit pages' assigned_* props).
* *
* **This is the unfiltered truth, and it is not what a screen should
* show.** Everyone a file is really in front of is the right answer for
* deciding something — VisibleCommentScope resolves notification
* recipients from it, and a recipient left out of that list is one who
* never hears about a message addressed to them. It is the wrong answer
* for telling somebody, because a client-scoped viewer may hold a file
* that is also shared with a client they have no business knowing
* exists. Anything rendering these names wants assignedFor() below.
*
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>} * @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/ */
public function assigned(File|Folder $subject): array public function assigned(File|Folder $subject): array
@@ -47,6 +59,17 @@ class ShareTargets
]; ];
} }
/**
* assigned(), narrowed to the recipients this viewer may be told
* about. The display half of the pair — see the warning above.
*
* @return array{clients: list<array{id: int, name: string}>, groups: list<array{id: int, name: string}>}
*/
public function assignedFor(File|Folder $subject, ?User $viewer): array
{
return $this->identity->filterShares($viewer, $this->assigned($subject));
}
/** /**
* The assigned lists plus everything still available to share with, * The assigned lists plus everything still available to share with,
* narrowed to what this viewer is allowed to reach. * narrowed to what this viewer is allowed to reach.
@@ -76,7 +99,12 @@ class ShareTargets
->orderBy('name') ->orderBy('name')
->get(); ->get();
$assigned = $this->assigned($subject); // assignedFor, not assigned: an edit page listing a recipient this
// viewer may not identify would both name them and offer a control
// for a share the viewer cannot otherwise reach. available_* below
// was already narrowed this way; assigned_* was not, which is the
// asymmetry that made the whole panel a roster listing.
$assigned = $this->assignedFor($subject, $viewer);
return [ return [
'assigned_clients' => $assigned['clients'], 'assigned_clients' => $assigned['clients'],
@@ -31,32 +31,65 @@ use Symfony\Component\HttpFoundation\Response;
* path, BinaryFileResponse when PHP is streaming — each dropping the * path, BinaryFileResponse when PHP is streaming — each dropping the
* Content-Length passed here in favour of the range actually served. * Content-Length passed here in favour of the range actually served.
* *
* The two paths are not equally revocable, which is why the lifetimes
* below differ. Every local delivery method authorises one response and
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
* streaming the bytes itself: these bytes, now, to this request, and
* nothing that outlives it. A presigned URL is a bearer
* credential — whoever holds it can fetch the file without passing the
* caller's checks again, and it outlives them: a download cap that is
* spent in the meantime, an expires_at that falls in between, an
* assignment that is withdrawn. Nothing here can revoke one, so the only
* dial is how long it lasts.
*
* A download needs to survive being followed, which is a redirect and a
* request: a minute is generous. A preview is held by the player for as
* long as somebody watches, and each seek outside the buffer is a fresh
* Range request against the same URL, so it keeps the hour. That is the
* trade, stated rather than left in a single number.
*
* Callers of inline() must have established that the mime type is * Callers of inline() must have established that the mime type is
* inline-safe first; PreviewKind is the allowlist, and the reason there * inline-safe first; PreviewKind is the allowlist, and the reason there
* is one. * is one.
*/ */
class StoredFileResponse class StoredFileResponse
{ {
/**
* Long enough for a browser, a download manager or a queued transfer
* to follow the redirect and start the request. An object store
* checks the signature when the request arrives, not while it runs,
* so a transfer that begins inside this window finishes however long
* it takes.
*/
private const DOWNLOAD_LINK_SECONDS = 60;
/**
* A preview is watched, not fetched: the player holds this URL and
* issues a Range request every time somebody seeks past the buffer,
* so it has to outlive the viewing rather than the redirect.
*/
private const PREVIEW_LINK_SECONDS = 3600;
public function __construct(private readonly FileDelivery $delivery) {} public function __construct(private readonly FileDelivery $delivery) {}
/** Shown in place — a preview. */ /** Shown in place — a preview. */
public function inline(File $file): Response|RedirectResponse public function inline(File $file): Response|RedirectResponse
{ {
return $this->make($file, ContentDisposition::inline($file->original_name)); return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
} }
/** Handed over — a download. */ /** Handed over — a download. */
public function attachment(File $file): Response|RedirectResponse public function attachment(File $file): Response|RedirectResponse
{ {
return $this->make($file, ContentDisposition::attachment($file->original_name)); return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
} }
private function make(File $file, string $disposition): Response|RedirectResponse private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
{ {
if ($file->disk !== 'files') { if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl( $url = Storage::disk($file->disk)->temporaryUrl(
$file->path, $file->path,
now()->addHour(), now()->addSeconds($linkSeconds),
['ResponseContentDisposition' => $disposition], ['ResponseContentDisposition' => $disposition],
); );
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Editing;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Models\File;
/**
* The one place that decides which fields an editor may actually write.
*
* Three surfaces edit a file — the staff editor, `/api/v1/files/{file}`,
* and now a client's own uploads in the portal — and they had grown two
* copies of the same eight permission checks with a third about to be
* written. The checks are not hard; the problem is that they are *easy*,
* so a new field gets added to one caller and the drift is invisible until
* somebody finds the surface where the gate is missing.
*
* The split is deliberate: **callers normalise, this gates.** A caller
* turns its own request shape into `$changes` — form semantics versus the
* API's `sometimes`, a date string versus an instant — and this decides
* what the actor is allowed to write, writes it, and records what happened.
*
* `$changes` uses array_key_exists semantics throughout: a key that is
* absent is left alone, a key present with `null` is written as null. That
* is the API's existing contract, and the web forms post every field they
* own, so it is also the forms'.
*
* Two things deliberately do NOT live here, because they are the caller's
* and getting them wrong is how a boundary breaks:
*
* - **Whether this actor may edit this file at all.** That is
* `Gate::authorize('update', $file)` and FilePolicy. Nothing below
* re-checks it.
* - **Whether a destination folder is reachable.** Staff ask
* StaffLibraryScope; a client asks `Folder::uploadableBy()`. Those are
* different questions with the same shape, and the staff one answers
* `true` for any client — see FilePolicy::update()'s note.
*/
class ApplyFileEdits
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly CommentingRules $commenting,
) {}
/**
* @param array<string, mixed> $changes only the fields the caller
* wants written; absent keys
* are left as they are
*/
public function apply(User $actor, File $file, array $changes): void
{
$attributes = [];
// Covered by the permission to edit the file at all, which the
// policy has already settled by the time anything reaches here.
foreach (['name', 'description', 'folder_id'] as $field) {
if (array_key_exists($field, $changes)) {
$attributes[$field] = $changes[$field];
}
}
// Only meaningful while the comment scope is `selected`, and only
// offered by a form then — but a request reaching here directly
// must not be able to set a flag the UI is currently hiding.
if (array_key_exists('commentable', $changes) && $this->commenting->scope() === CommentScope::SelectedFiles) {
$attributes['commentable'] = $changes['commentable'];
}
// From here down, every field has a permission of its own, and the
// rule for all of them is the same: lacking it leaves the field
// exactly as it was rather than failing the request. An editor who
// may rename a file but not publish it saves a rename, and the
// public state does not move. The web and the API have always
// behaved this way; it is why the portal can reuse both forms.
if (array_key_exists('expires_at', $changes) && $actor->can('set_file_expiration_date')) {
$attributes['expires_at'] = $changes['expires_at'];
}
if (array_key_exists('download_limit', $changes) && $actor->can('limit_downloads')) {
$attributes['download_limit'] = $changes['download_limit'];
}
if (array_key_exists('download_limit_scope', $changes) && $actor->can('limit_downloads')) {
$attributes['download_limit_scope'] = $changes['download_limit_scope'];
}
$wasPublic = $file->public;
if (array_key_exists('public', $changes) && $actor->can('upload_public')) {
$attributes['public'] = $changes['public'];
// A caller that offers the slug passes what was submitted; one
// that does not simply omits the key and gets a derived slug.
// The client portal is the second kind on purpose — an
// installation-wide unique slug chosen by a client is a name to
// squat and an existence oracle to probe, for no benefit over a
// slug made from the name they already chose.
//
// Omitting the slug on an update keeps the current one: it must
// not silently change just because the name did.
$submitted = is_string($changes['slug'] ?? null) ? trim($changes['slug']) : '';
$attributes['slug'] = $submitted !== ''
? $submitted
: ($file->slug ?: File::uniqueSlugFrom(
is_string($changes['name'] ?? null) ? $changes['name'] : $file->name,
$file->id,
));
}
$file->update($attributes);
// After the write, not inside it: categories are a relation, not a
// column. Gated by their own key, so an editor who may rename but
// not categorise leaves them untouched.
if (array_key_exists('categories', $changes) && $actor->can('set_file_categories')) {
$file->categories()->sync($changes['categories']);
}
$this->activity->log(Action::FileUpdated, subject: $file);
// Publishing and unpublishing are their own entries. A file
// becoming reachable without a login is not a detail of "file
// updated", and it is the line an audit is most likely to be read
// for.
if (! $wasPublic && $file->public) {
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
} elseif ($wasPublic && ! $file->public) {
$this->activity->log(Action::FileMadePrivate, subject: $file);
}
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Editing;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Carbon\Carbon;
/**
* Reading and writing a file's expiry in the zone of whoever is looking.
*
* The stored value is an instant. What a person sets is a calendar day,
* and "the 12th" means the end of the 12th where *they* live — otherwise a
* file asked to expire on the 12th dies partway through the 11th for
* anyone west of Greenwich, and gives anyone east of it most of a day
* nobody promised.
*
* The two halves have to agree, which is the whole reason they sit
* together: a form is rendered with asShown() and posts the same string
* back untouched with every other edit, so a caller compares against
* asShown() to tell "the editor changed the date" from "the editor renamed
* the file and the date came along for the ride". Re-deriving on every
* save instead moves the expiry by the difference between two people's
* zones each time somebody edits anything.
*
* Was three private copies — the staff editor, the API, and now the client
* portal — of which the API's was the only one that could read a
* timestamp.
*/
class FileExpiry
{
public function __construct(
private readonly TimezoneRegistry $timezones,
) {}
/**
* The stored instant as the calendar day a form should show, in the
* viewer's zone. Null when the file never expires.
*/
public function asShown(File $file, ?User $viewer): ?string
{
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
}
/**
* The instant a submitted value actually names.
*
* A bare `YYYY-MM-DD` is a calendar day and means the end of it where
* the setter is — what every date input posts. Anything carrying a
* time is an instant somebody named on purpose and is stored as it
* arrives: the API can express a moment, and a date input cannot.
*/
public function instant(?string $value, ?User $setter): ?Carbon
{
if ($value === null) {
return null;
}
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
? LocalDay::end($value, $this->timezones->resolve($setter))
: Carbon::parse($value);
}
}
+31 -5
View File
@@ -11,9 +11,15 @@ use App\Modules\Files\Models\File;
/** /**
* Ownership rules as policy methods (brief §6.13): "own" versus * Ownership rules as policy methods (brief §6.13): "own" versus
* "others'" files map onto the v1 permission pairs. Clients may only * "others'" files map onto the v1 permission pairs. Clients may only
* view/download what is assigned to them, directly or via a group. For * view/download what is assigned to them, directly or via a group, and may
* client-scoped staff, every action is additionally gated by the * edit or delete only what they uploaded themselves. For client-scoped
* StaffLibraryScope, so direct access can't reach out-of-scope files. * staff, every action is additionally gated by the StaffLibraryScope, so
* direct access can't reach out-of-scope files.
*
* Every method here branches on isStaff() before it reaches the scope.
* That is not stylistic: StaffLibraryScope answers "is this *restricted*
* staff member allowed?", and its "no restriction" answer is `true`. A
* client falling through to it is handed the whole library. See update().
*/ */
class FilePolicy class FilePolicy
{ {
@@ -33,8 +39,25 @@ class FilePolicy
public function update(User $user, File $file): bool public function update(User $user, File $file): bool
{ {
// A client edits what they uploaded and nothing else. Deliberately
// its own branch rather than a shared one, because the staff branch
// below is unsafe for a client in two ways at once.
//
// First, `edit_others_files` must never be reachable here. It is a
// staff key by construction: a client has no "others' files" they
// could hold a legitimate claim over, only files somebody shared
// with them, and being shown a file is not being given it. Granting
// that key to the Client role does nothing, and a test pins that.
//
// Second, and the trap: StaffLibraryScope::allowsFile() returns
// true outright for anyone who is not client-*scoped* staff —
// User::isClientScoped() is `isStaff() && role->client_scoped`, so
// it is false for every client. That predicate means "this staff
// member is unrestricted", and a client reaching it would inherit
// "unrestricted" over the whole library. Nothing here may touch the
// staff scope.
if (! $user->isStaff()) { if (! $user->isStaff()) {
return false; return $file->isOwnedBy($user) && $user->can('edit_files');
} }
$permitted = $file->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files'); $permitted = $file->isOwnedBy($user) ? $user->can('edit_files') : $user->can('edit_others_files');
@@ -72,8 +95,11 @@ class FilePolicy
public function delete(User $user, File $file): bool public function delete(User $user, File $file): bool
{ {
// Their own upload, and only with the key — same two reasons as
// update() above, `delete_others_files` standing in for
// `edit_others_files`.
if (! $user->isStaff()) { if (! $user->isStaff()) {
return false; return $file->isOwnedBy($user) && $user->can('delete_files');
} }
$permitted = $file->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files'); $permitted = $file->isOwnedBy($user) ? $user->can('delete_files') : $user->can('delete_others_files');
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Files; namespace App\Modules\Files;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File; use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder; use App\Modules\Files\Models\Folder;
@@ -30,6 +31,10 @@ class FilesServiceProvider extends ServiceProvider
// reached twice. Scoped rather than a singleton so a long-lived // reached twice. Scoped rather than a singleton so a long-lived
// queue worker starts each job with an empty memo. // queue worker starts each job with an empty memo.
$this->app->scoped(StaffLibraryScope::class); $this->app->scoped(StaffLibraryScope::class);
// Same lifetime, same reason: the identity rule memoises a roster
// per viewer and the file listings ask it once per row.
$this->app->scoped(ClientIdentityScope::class);
} }
public function boot(): void public function boot(): void
@@ -10,23 +10,21 @@ use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action; use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger; use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientStorageUsage; use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Comments\CommentingRules; use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Access\ViewableFileScope; use App\Modules\Files\Access\ViewableFileScope;
use App\Modules\Files\DownloadLimitScope; use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Http\Resources\Api\FileResource; use App\Modules\Files\Http\Resources\Api\FileResource;
use App\Modules\Files\Models\File; use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder; use App\Modules\Files\Models\Folder;
use App\Modules\Files\Storage\ResolvingUploadDisk; use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Files\Uploads\StoreUploadedFile; use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Modules\Files\Uploads\UploadExtensionPolicy; use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings; use App\Modules\Platform\Settings\Settings;
use App\Support\Rules; use App\Support\Rules;
use Carbon\Carbon;
use Closure; use Closure;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
@@ -60,9 +58,10 @@ class FilesController extends Controller
private readonly UploadExtensionPolicy $extensionPolicy, private readonly UploadExtensionPolicy $extensionPolicy,
private readonly ClientStorageUsage $storageUsage, private readonly ClientStorageUsage $storageUsage,
private readonly ActivityLogger $activity, private readonly ActivityLogger $activity,
private readonly CommentingRules $commenting,
private readonly StaffLibraryScope $scope, private readonly StaffLibraryScope $scope,
private readonly TimezoneRegistry $timezones, private readonly ClientIdentityScope $identity,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
) {} ) {}
/** /**
@@ -114,6 +113,17 @@ class FilesController extends Controller
} }
if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) { if (array_key_exists('uploaded_by', $filters) && $filters['uploaded_by'] !== null) {
// A filter is a question, and this one asks "did client N put
// anything into my library". Answered plainly it is an oracle:
// a client-scoped caller could walk the id space and learn
// which clients off their roster share files with clients on
// it, without ever reading a name. So an id this caller may
// not identify matches nothing — indistinguishable from a
// client who has uploaded nothing, which is the point.
if (! $this->identity->permitsClientId($user, (int) $filters['uploaded_by'])) {
$query->whereRaw('1 = 0');
}
$query->where('files.uploaded_by', $filters['uploaded_by']); $query->where('files.uploaded_by', $filters['uploaded_by']);
} }
@@ -316,44 +326,32 @@ class FilesController extends Controller
} }
} }
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id'])); // `sometimes` throughout the rules above means $validated already
// holds exactly the fields the caller sent, which is the same
// array_key_exists contract ApplyFileEdits reads — so the payload
// passes through almost untouched. Which of them this token's user
// may actually write is that class's decision, shared with the
// staff editor and the client portal.
$changes = array_intersect_key($validated, array_flip([
'name',
'description',
'folder_id',
'commentable',
'download_limit',
'download_limit_scope',
'public',
'slug',
'categories',
]));
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) { // The one field that needs converting rather than passing along: a
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'], $user); // caller may send a calendar day or a full timestamp, and a day
// means the end of that day where the caller is.
if (array_key_exists('expires_at', $validated)) {
$changes['expires_at'] = $this->expiry->instant($validated['expires_at'], $user);
} }
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) { $this->fileEdits->apply($user, $file, $changes);
$attributes['download_limit'] = $validated['download_limit'];
}
if (array_key_exists('download_limit_scope', $validated) && $user->can('limit_downloads')) {
$attributes['download_limit_scope'] = $validated['download_limit_scope'];
}
if (array_key_exists('commentable', $validated) && $this->commenting->scope() === CommentScope::SelectedFiles) {
$attributes['commentable'] = $validated['commentable'];
}
$wasPublic = $file->public;
if (array_key_exists('public', $validated) && $user->can('upload_public')) {
$attributes['public'] = $validated['public'];
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'] ?? $file->name, $file->id));
}
$file->update($attributes);
if (array_key_exists('categories', $validated) && $user->can('set_file_categories')) {
$file->categories()->sync($validated['categories']);
}
$this->activity->log(Action::FileUpdated, subject: $file);
if (! $wasPublic && $file->public) {
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
} elseif ($wasPublic && ! $file->public) {
$this->activity->log(Action::FileMadePrivate, subject: $file);
}
return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file); return new FileResource($file->fresh()?->load(['folder', 'uploader', 'categories']) ?? $file);
} }
@@ -369,29 +367,4 @@ class FilesController extends Controller
return response()->json(status: 204); return response()->json(status: 204);
} }
/**
* What an `expires_at` value means.
*
* A bare `YYYY-MM-DD` is a calendar day, and a calendar day ends where
* the person naming it lives — the same rule the web form's date input
* gets from FilesController::expiryInstant. Stored as it arrives it
* would be midnight UTC instead, so a file asked to expire on the 12th
* would die at the *start* of the 12th, and for a caller west of
* Greenwich partway through the 11th.
*
* Anything carrying a time is an instant the caller named on purpose
* and is stored as it arrives, unchanged from before: the API can
* express a moment, and a date input cannot.
*/
private function expiryInstant(?string $value, User $setter): ?Carbon
{
if ($value === null) {
return null;
}
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
? LocalDay::end($value, $this->timezones->resolve($setter))
: Carbon::parse($value);
}
} }
@@ -6,6 +6,7 @@ namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\Category; use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File; use App\Modules\Files\Models\File;
@@ -28,6 +29,7 @@ class ClientFilesController extends Controller
{ {
public function __construct( public function __construct(
private readonly StaffLibraryScope $scope, private readonly StaffLibraryScope $scope,
private readonly ClientIdentityScope $identity,
) {} ) {}
public function index(Request $request, User $client): Response public function index(Request $request, User $client): Response
@@ -66,7 +68,11 @@ class ClientFilesController extends Controller
'size' => $file->size, 'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(), 'created_at' => $file->created_at?->toIso8601String(),
'uploaded_by_client' => $file->uploaded_by === $client->id, 'uploaded_by_client' => $file->uploaded_by === $client->id,
'uploader' => $file->uploader?->name, // Being allowed to browse this client's files does not
// extend to the other clients who shared files with them:
// a file reaches this listing through the client in the
// URL, and its uploader can be somebody else entirely.
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
'downloads_count' => $file->downloads_count, 'downloads_count' => $file->downloads_count,
'can_download' => Gate::forUser($viewer)->allows('view', $file), 'can_download' => Gate::forUser($viewer)->allows('view', $file),
'categories' => $file->categories->map(fn (Category $category): array => [ 'categories' => $file->categories->map(fn (Category $category): array => [
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityPresenter; use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DownloadPresenter; use App\Modules\Audit\DownloadPresenter;
use App\Modules\Comments\CommentingRules; use App\Modules\Comments\CommentingRules;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\DownloadAllowance; use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\ShareTargets; use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\DownloadLimitScope; use App\Modules\Files\DownloadLimitScope;
@@ -79,6 +80,7 @@ class FileDetailsController extends Controller
private readonly ActivityPresenter $presenter, private readonly ActivityPresenter $presenter,
private readonly DownloadPresenter $downloadPresenter, private readonly DownloadPresenter $downloadPresenter,
private readonly ShareTargets $shareTargets, private readonly ShareTargets $shareTargets,
private readonly ClientIdentityScope $identity,
private readonly CommentingRules $commenting, private readonly CommentingRules $commenting,
private readonly FileVersionLinks $versionLinks, private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance, private readonly DownloadAllowance $allowance,
@@ -100,7 +102,10 @@ class FileDetailsController extends Controller
'size' => $file->size, 'size' => $file->size,
'mime_type' => $file->mime_type, 'mime_type' => $file->mime_type,
'checksum' => $file->checksum, 'checksum' => $file->checksum,
'uploader' => $file->uploader?->name, // Null when the uploader is a client this viewer may not
// be told about, which reads the same as an uploader whose
// account has since been deleted.
'uploader' => $this->identity->nameOf($viewer, $file->uploader),
'folder' => $file->folder?->only('id', 'name'), 'folder' => $file->folder?->only('id', 'name'),
'categories' => $file->categories()->orderBy('name')->get() 'categories' => $file->categories()->orderBy('name')->get()
->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color]) ->map(fn (Category $category): array => ['id' => $category->id, 'name' => $category->name, 'color' => $category->color])
@@ -140,7 +145,7 @@ class FileDetailsController extends Controller
// Resolved from the chain root for a revision (ShareTargets // Resolved from the chain root for a revision (ShareTargets
// does that), so this names who really has the file. The panel // does that), so this names who really has the file. The panel
// says where those recipients are set. // says where those recipients are set.
'shares' => $this->shareTargets->assigned($file), 'shares' => $this->shareTargets->assignedFor($file, $viewer),
'sharing_root' => $file->isRevision() 'sharing_root' => $file->isRevision()
? File::query()->find($file->sharingOwnerId())?->only('id', 'name') ? File::query()->find($file->sharingOwnerId())?->only('id', 'name')
: null, : null,
@@ -368,7 +373,7 @@ class FileDetailsController extends Controller
'name' => $folder->name, 'name' => $folder->name,
'files_count' => $folder->files()->count(), 'files_count' => $folder->files()->count(),
'children_count' => $folder->children()->count(), 'children_count' => $folder->children()->count(),
'creator' => $folder->creator?->name, 'creator' => $this->identity->nameOf($viewer, $folder->creator),
'created_at' => $folder->created_at?->toIso8601String(), 'created_at' => $folder->created_at?->toIso8601String(),
'open_url' => route('files.index', ['folder' => $folder->id], false), 'open_url' => route('files.index', ['folder' => $folder->id], false),
// Read-only here, same as a file's shares — sharing (and every // Read-only here, same as a file's shares — sharing (and every
@@ -377,7 +382,7 @@ class FileDetailsController extends Controller
'edit_url' => route('folders.share', $folder, false), 'edit_url' => route('folders.share', $folder, false),
'can_update' => Gate::forUser($viewer)->allows('update', $folder), 'can_update' => Gate::forUser($viewer)->allows('update', $folder),
'can_view_activity' => $viewer->can('view_actions_log'), 'can_view_activity' => $viewer->can('view_actions_log'),
'shares' => $this->shareTargets->assigned($folder), 'shares' => $this->shareTargets->assignedFor($folder, $viewer),
]); ]);
} }
@@ -10,9 +10,12 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger; use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\CommentingRules; use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope; use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\ShareTargets; use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\DownloadLimitScope; use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Models\Category; use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File; use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder; use App\Modules\Files\Models\Folder;
@@ -22,13 +25,10 @@ use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Modules\Files\Uploads\UploadExtensionPolicy; use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Files\Versions\FileVersionLinks; use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions; use App\Modules\Files\Versions\FileVersions;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings; use App\Modules\Platform\Settings\Settings;
use App\Support\PublicUrl; use App\Support\PublicUrl;
use App\Support\Rules; use App\Support\Rules;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@@ -49,10 +49,12 @@ class FilesController extends Controller
private readonly StaffLibraryScope $scope, private readonly StaffLibraryScope $scope,
private readonly PublicUrl $publicUrl, private readonly PublicUrl $publicUrl,
private readonly ShareTargets $shareTargets, private readonly ShareTargets $shareTargets,
private readonly ClientIdentityScope $identity,
private readonly CommentingRules $commenting, private readonly CommentingRules $commenting,
private readonly FileVersions $versions, private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks, private readonly FileVersionLinks $versionLinks,
private readonly TimezoneRegistry $timezones, private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
) {} ) {}
public function create(Request $request): Response public function create(Request $request): Response
@@ -164,7 +166,7 @@ class FilesController extends Controller
'original_name' => $file->original_name, 'original_name' => $file->original_name,
'size' => $file->size, 'size' => $file->size,
'mime_type' => $file->mime_type, 'mime_type' => $file->mime_type,
'uploader' => $file->uploader?->name, 'uploader' => $this->identity->nameOf($viewer, $file->uploader),
'folder_id' => $file->folder_id, 'folder_id' => $file->folder_id,
'public' => $file->public, 'public' => $file->public,
'commentable' => $file->commentable, 'commentable' => $file->commentable,
@@ -173,7 +175,7 @@ class FilesController extends Controller
// calendar date the editor typed — read back in their // calendar date the editor typed — read back in their
// zone, not the server's, or a file set to expire on the // zone, not the server's, or a file set to expire on the
// 12th reopens showing the 11th. // 12th reopens showing the 11th.
'expires_at' => $this->expiryDateFor($file, $request->user()), 'expires_at' => $this->expiry->asShown($file, $request->user()),
'expired' => $file->isExpired(), 'expired' => $file->isExpired(),
'download_limit' => $file->download_limit, 'download_limit' => $file->download_limit,
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value, 'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
@@ -274,6 +276,8 @@ class FilesController extends Controller
// change comparison below matches the model's int. // change comparison below matches the model's int.
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null; $folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
$user = $request->user(); $user = $request->user();
// Gate::authorize above cannot pass without one.
assert($user !== null);
// Reparenting through update() is the same privileged write as // Reparenting through update() is the same privileged write as
// move()/bulkUpdate(), so it needs the same guard: the destination // move()/bulkUpdate(), so it needs the same guard: the destination
@@ -281,80 +285,45 @@ class FilesController extends Controller
// folder actually changes, so re-saving a file that already sits in // folder actually changes, so re-saving a file that already sits in
// an out-of-scope folder (reachable via a direct client share) still // an out-of-scope folder (reachable via a direct client share) still
// works. // works.
if ($folderId !== null && $folderId !== $file->folder_id && $user !== null) { if ($folderId !== null && $folderId !== $file->folder_id) {
$this->scope->folders($user)->findOrFail($folderId); $this->scope->folders($user)->findOrFail($folderId);
} }
$attributes = [ // Normalised into the shape ApplyFileEdits reads, then handed
// over: which of these the actor may actually write is that
// class's decision, and it is the same decision the API and the
// client portal get. See its docblock for why the split is here.
$changes = [
'name' => $validated['name'], 'name' => $validated['name'],
'description' => $validated['description'] ?? null, 'description' => $validated['description'] ?? null,
'folder_id' => $folderId, 'folder_id' => $folderId,
// Present unconditionally; the comment scope decides whether it
// is honoured. Defaulted to the stored value so a form that
// does not render the field cannot clear it.
'commentable' => $validated['commentable'] ?? $file->commentable,
'download_limit' => $validated['download_limit'] ?? null,
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
'public' => $validated['public'] ?? $file->public,
'slug' => $validated['slug'] ?? '',
'categories' => $validated['categories'] ?? [],
]; ];
// Only meaningful while the comment scope is `selected`, and only // The one field that is conditionally *present* rather than
// offered by the page then — but a request reaching here directly // conditionally honoured, and the reason it cannot move into
// must not be able to set a flag the UI is currently hiding, the // ApplyFileEdits: the form was rendered with the stored instant
// same shape as the upload_public gate below. // read back as a date in this viewer's zone, and posts it again
if ($this->commenting->scope() === CommentScope::SelectedFiles) { // untouched with every other edit. Re-deriving it unconditionally
$attributes['commentable'] = $validated['commentable'] ?? $file->commentable; // would move the expiry by the difference between two people's
// zones each time somebody merely renamed the file. Compared
// against the same string the form was given, so "unchanged" means
// what the editor actually saw.
$posted = $validated['expires_at'] ?? null;
if ($posted !== $this->expiry->asShown($file, $user)) {
$changes['expires_at'] = $this->expiry->instant($posted, $user);
} }
// Only a user who can set expiration dates may change this file's $this->fileEdits->apply($user, $file, $changes);
// own expiry — same "leave it alone if you lack the permission"
// rule as the upload_public gate below.
if ($request->user()?->can('set_file_expiration_date') === true) {
$posted = $validated['expires_at'] ?? null;
// Re-derived only when the date actually changed. The form was
// rendered with the stored instant read back as a date in *this*
// viewer's zone, and posts it again untouched with every other
// edit — so deriving it unconditionally moves the expiry by the
// difference between two people's zones each time somebody
// merely renames the file. Compared against the same string the
// form was given, above, so "unchanged" means what the editor
// saw.
if ($posted !== $this->expiryDateFor($file, $request->user())) {
$attributes['expires_at'] = $this->expiryInstant($posted, $request->user());
}
}
// Same rule again for the download cap, behind its own
// permission — the one that already gates a share link's
// max_downloads, since both are the same question asked about
// different objects.
if ($request->user()?->can('limit_downloads') === true) {
$attributes['download_limit'] = $validated['download_limit'] ?? null;
$attributes['download_limit_scope'] = $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value;
}
$wasPublic = $file->public;
// Only a user who can manage public state may change it — a user
// who can edit a file but lacks upload_public leaves its public
// state exactly as it was, same rule as FoldersController::update.
if ($request->user()?->can('upload_public') === true) {
$attributes['public'] = $validated['public'] ?? $file->public;
// Omitting the field on an update leaves the current slug
// alone — it must not silently change just because the name
// did.
$attributes['slug'] = ($validated['slug'] ?? '') ?: ($file->slug ?: File::uniqueSlugFrom($validated['name'], $file->id));
}
$file->update($attributes);
// Categories are gated by their own permission; leave them untouched
// for a user who can edit the file but not set categories.
if ($request->user()?->can('set_file_categories') === true) {
$file->categories()->sync($validated['categories'] ?? []);
}
$this->activity->log(Action::FileUpdated, subject: $file);
if (! $wasPublic && $file->public) {
$this->activity->log(Action::FileMadePublic, subject: $file, context: ['slug' => $file->slug]);
} elseif ($wasPublic && ! $file->public) {
$this->activity->log(Action::FileMadePrivate, subject: $file);
}
return back()->with('success', __('File updated.')); return back()->with('success', __('File updated.'));
} }
@@ -475,7 +444,7 @@ class FilesController extends Controller
// update()'s expires_at handling. // update()'s expires_at handling.
if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) { if ($validated['expiration_action'] !== 'no_change' && $canSetExpiration) {
$attributes['expires_at'] = $validated['expiration_action'] === 'set' $attributes['expires_at'] = $validated['expiration_action'] === 'set'
? $this->expiryInstant($validated['expires_at'], $user) ? $this->expiry->instant($validated['expires_at'], $user)
: null; : null;
} }
@@ -542,40 +511,17 @@ class FilesController extends Controller
Gate::authorize('delete', $file); Gate::authorize('delete', $file);
$name = $file->name; $name = $file->name;
// Soft delete; the bytes stay on disk until a purge policy // Soft delete of the row — but not of the bytes. File::booted()'s
// lands with the retention work. // `deleted` hook runs FileDiskCleanup on commit, so the upload and
// every cached rendition of it are gone from disk by the time this
// returns. The row is kept because version chains, the activity
// log and the erasure grace period all still point at it; nothing
// serves it (route-model binding 404s), and nothing ever
// forceDelete()s it either.
$file->delete(); $file->delete();
$this->activity->log(Action::FileDeleted, context: ['name' => $name]); $this->activity->log(Action::FileDeleted, context: ['name' => $name]);
return redirect()->route('files.index')->with('success', __('File deleted.')); return redirect()->route('files.index')->with('success', __('File deleted.'));
} }
/**
* The instant a `<input type="date">` expiry actually falls on.
*
* The form posts a bare `YYYY-MM-DD`, which Eloquent would otherwise
* store as midnight UTC — so "expires on the 12th" would cut the file
* off partway through the 11th for anyone in the Americas, and give
* anyone east of Greenwich most of a day they were not promised. It
* means the end of the 12th where the person setting it lives.
*/
private function expiryInstant(?string $date, ?User $setter): ?Carbon
{
return $date === null
? null
: LocalDay::end($date, $this->timezones->resolve($setter));
}
/**
* The inverse: the calendar date a stored expiry falls on for this
* viewer, which is what the date input is given and what it posts back.
*
* The pair has to agree, or a re-save reads one date and writes
* another.
*/
private function expiryDateFor(File $file, ?User $viewer): ?string
{
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
}
} }
@@ -10,6 +10,7 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger; use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\Access\VisibleCommentScope; use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules; use App\Modules\Comments\CommentingRules;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Access\DownloadAllowance; use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\ShareTargets; use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope; use App\Modules\Files\Access\StaffLibraryScope;
@@ -54,6 +55,7 @@ class FoldersController extends Controller
private readonly ActivityLogger $activity, private readonly ActivityLogger $activity,
private readonly PublicUrl $publicUrl, private readonly PublicUrl $publicUrl,
private readonly ShareTargets $shareTargets, private readonly ShareTargets $shareTargets,
private readonly ClientIdentityScope $identity,
private readonly BreadcrumbBuilder $breadcrumbs, private readonly BreadcrumbBuilder $breadcrumbs,
private readonly CommentingRules $commenting, private readonly CommentingRules $commenting,
private readonly VisibleCommentScope $comments, private readonly VisibleCommentScope $comments,
@@ -240,7 +242,11 @@ class FoldersController extends Controller
'original_name' => $file->original_name, 'original_name' => $file->original_name,
'mime_type' => $file->mime_type, 'mime_type' => $file->mime_type,
'size' => $file->size, 'size' => $file->size,
'uploader' => $file->uploader ? [ // The whole block goes, not just the name: type and role
// describe the same person, and "a client uploaded this" on a
// row whose uploader is off this viewer's roster narrows who
// it could be just as effectively as naming them.
'uploader' => ($file->uploader !== null && $this->identity->permits($user, $file->uploader)) ? [
'name' => $file->uploader->name, 'name' => $file->uploader->name,
'type' => $file->uploader->type->value, 'type' => $file->uploader->type->value,
'role' => $file->uploader->role?->name, 'role' => $file->uploader->role?->name,
@@ -5,10 +5,16 @@ declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers; namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientStorageUsage; use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Comments\Access\VisibleCommentScope; use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules; use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\DownloadAllowance; use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
use App\Modules\Files\Folders\BreadcrumbBuilder; use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Models\Category; use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File; use App\Modules\Files\Models\File;
@@ -22,6 +28,7 @@ use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Theming\PublicThemeRegistry; use App\Modules\Platform\Theming\PublicThemeRegistry;
use App\Support\ConcatenatedPagination; use App\Support\ConcatenatedPagination;
use App\Support\Pagination; use App\Support\Pagination;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -66,6 +73,9 @@ class MyFilesController extends Controller
private readonly DownloadAllowance $allowance, private readonly DownloadAllowance $allowance,
private readonly FileVersions $versions, private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks, private readonly FileVersionLinks $versionLinks,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
private readonly ActivityLogger $activity,
) {} ) {}
public function index(Request $request): Response|RedirectResponse public function index(Request $request): Response|RedirectResponse
@@ -207,9 +217,11 @@ class MyFilesController extends Controller
$fileRows = $sliced['items']['files']; $fileRows = $sliced['items']['files'];
$commentCounts = $this->comments->countsFor($client, $fileRows); $commentCounts = $this->comments->countsFor($client, $fileRows);
// Two queries for the page, not two per row. No URL resolver: the // Two queries for the page, not two per row. Still no URL resolver:
// portal has no per-file page to link to, so a counterpart is named // the portal's per-file page is an *editor* for a client's own
// and not linked (see docs/theming-files-checklist.md). // uploads, and a version counterpart is frequently neither theirs
// nor editable — so a counterpart stays named and not linked (see
// docs/theming-files-checklist.md).
$versions = $this->versionLinks->forMany($fileRows, $client); $versions = $this->versionLinks->forMany($fileRows, $client);
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all()))); $unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
@@ -237,6 +249,14 @@ class MyFilesController extends Controller
'size' => $file->size, 'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(), 'created_at' => $file->created_at?->toIso8601String(),
'is_mine' => $file->uploaded_by === $client->id, 'is_mine' => $file->uploaded_by === $client->id,
// Decided per row by FilePolicy, exactly as the folder rows
// above are: a client's own uploads are theirs to manage
// and files shared with them are not, and both kinds sit in
// the same list. A theme reads these and never works them
// out from is_mine — holding the file is only half of it,
// the role's keys are the other half.
'can_update' => Gate::forUser($client)->allows('update', $file),
'can_delete' => Gate::forUser($client)->allows('delete', $file),
// Effective status (own flag or inherited from a public // Effective status (own flag or inherited from a public
// folder) — same "will visitors on the public site see // folder) — same "will visitors on the public site see
// this" badge as the staff library shows. // this" badge as the staff library shows.
@@ -306,6 +326,200 @@ class MyFilesController extends Controller
]); ]);
} }
/**
* The editor page for a file this client uploaded.
*
* One page for every theme, not one per theme — the same shape
* `upload()` uses, and for the same reason: this is a form, and a form
* rebuilt four times is four places for a field to go missing. The
* `theme` prop picks the shell (see portal/edit-file.tsx), which is the
* only part that differs.
*
* Every `can_*` prop below is the *same* question ApplyFileEdits will
* ask when the form posts. A control this page hides is not a control
* the server then trusts: hiding it is a courtesy so a client is not
* shown a switch that will silently do nothing, and the refusal is
* server-side either way.
*/
public function edit(Request $request, File $file): Response
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('update', $file);
$file->loadMissing('categories');
return Inertia::render('portal/edit-file', [
'theme' => $this->themeKey(),
'file' => [
'id' => $file->id,
'name' => $file->name,
'description' => $file->description,
'original_name' => $file->original_name,
'size' => $file->size,
'public' => $file->public,
'commentable' => $file->commentable,
// The stored instant as the calendar day this client's own
// zone shows — the value the form posts back untouched, and
// the one update() compares against to tell a real change
// from a date that merely came along with a rename.
'expires_at' => $this->expiry->asShown($file, $client),
'download_limit' => $file->download_limit,
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
'folder_id' => $file->folder_id,
'categories' => $file->categories->pluck('id')->all(),
],
'can_delete' => Gate::forUser($client)->allows('delete', $file),
'can_publish' => $client->can('upload_public'),
'can_set_expiration' => $client->can('set_file_expiration_date'),
'can_set_categories' => $client->can('set_file_categories'),
'can_limit_downloads' => $client->can('limit_downloads'),
// Only while the installation asks per file; otherwise the
// setting decides and the switch would be a lie.
'can_set_commentable' => $this->commenting->scope() === CommentScope::SelectedFiles,
'categories' => Category::query()->orderBy('name')->get(['id', 'name', 'color'])
->map(fn (Category $category): array => [
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
])->all(),
// Somewhere this client could have uploaded it in the first
// place — the same rule update() enforces, so the picker cannot
// offer a destination the save would refuse.
'folders' => Folder::query()->visibleToClient($client)->orderBy('name')->get()
->filter(fn (Folder $folder): bool => Folder::uploadableBy($client, $folder))
->map(fn (Folder $folder): array => [
'id' => $folder->id,
'name' => $folder->name,
// A destination can publish the file without the public
// switch being touched: File::isEffectivelyPublic() is
// "my own flag OR my folder's", and a client holding
// upload_to_public_folders may move into a public
// folder without holding upload_public. That is the
// established meaning of the two keys, and it is what
// uploading there has always done — but in a picker of
// bare names it would be invisible, so the name carries
// the consequence with it.
'public' => $folder->isEffectivelyPublic(),
])
->values()->all(),
// Public files are reachable at the installation's one public
// slug; without it configured, publishing shows nowhere and the
// page says so rather than offering a switch that does nothing
// visible.
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
]);
}
/**
* Edit a file this client uploaded.
*
* The client portal's counterpart to the staff file editor, and
* deliberately a separate route rather than the staff one opened up:
* `files.*` renders assignments, share links, activity and download
* history, which are staff surfaces, and its folder guard asks
* StaffLibraryScope — which answers "allowed" for every client (see
* FilePolicy::update()).
*
* Who may edit at all is FilePolicy: the file must be this client's own
* upload and they must hold `edit_files`. Which *fields* they may
* write is ApplyFileEdits, the same decision the staff editor and the
* API get, so a client holding `set_file_categories` but not
* `upload_public` gets exactly what those keys say and nothing is
* decided twice.
*/
public function update(Request $request, File $file): RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('update', $file);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'folder_id' => Rules::folderId(),
'public' => ['sometimes', 'boolean'],
'commentable' => ['sometimes', 'boolean'],
'categories' => ['array'],
'categories.*' => ['integer', 'exists:categories,id'],
'expires_at' => ['nullable', 'date'],
'download_limit' => ['nullable', 'integer', 'min:1'],
'download_limit_scope' => ['nullable', Rule::enum(DownloadLimitScope::class)],
]);
// No `slug`, on purpose, and its absence is what makes
// ApplyFileEdits derive one from the name. An installation-wide
// unique slug that a client picks is a name to squat and an
// existence oracle to probe against every file on the
// installation, for nothing a derived slug does not already give
// them.
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
// The client rule, not the staff one: somewhere they could have
// uploaded it in the first place. Same check the upload path makes,
// so moving a file cannot reach a folder that uploading it could
// not. Only when the folder actually changes, so re-saving a file
// that already sits somewhere unusual still works.
if ($folderId !== null && $folderId !== $file->folder_id) {
$folder = Folder::query()->visibleToClient($client)->find($folderId);
abort_unless($folder !== null && Folder::uploadableBy($client, $folder), 403);
}
$changes = [
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
'folder_id' => $folderId,
'commentable' => $validated['commentable'] ?? $file->commentable,
'download_limit' => $validated['download_limit'] ?? null,
'download_limit_scope' => $validated['download_limit_scope'] ?? DownloadLimitScope::Total->value,
'public' => $validated['public'] ?? $file->public,
'categories' => $validated['categories'] ?? [],
];
// Only when the date actually moved — the form posts back what it
// was rendered with, and re-deriving it on every save would shift
// the expiry by a timezone difference each time somebody renamed
// the file. See FileExpiry.
$posted = $validated['expires_at'] ?? null;
if ($posted !== $this->expiry->asShown($file, $client)) {
$changes['expires_at'] = $this->expiry->instant($posted, $client);
}
$this->fileEdits->apply($client, $file, $changes);
return back()->with('success', __('File updated.'));
}
/**
* Delete a file this client uploaded.
*
* Their own upload and `delete_files`, both settled by
* FilePolicy::delete(). A file merely shared with them is not theirs to
* remove, and no permission changes that.
*
* The row is soft-deleted and the bytes are not: File::booted()'s
* `deleted` hook removes the upload and every cached rendition on
* commit, so the client's storage quota — which sums untrashed rows —
* frees up by exactly what the disk does.
*/
public function destroy(Request $request, File $file): RedirectResponse
{
$client = $request->user();
abort_unless($client !== null && $client->isClient(), 404);
Gate::authorize('delete', $file);
$name = $file->name;
$file->delete();
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
return redirect()->route('my-files.index')->with('success', __('File deleted.'));
}
/** /**
* Files this client may name as the previous version of what they are * Files this client may name as the previous version of what they are
* uploading — THEIR OWN UPLOADS ONLY. * uploading — THEIR OWN UPLOADS ONLY.
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Files\Http\Resources\Api; namespace App\Modules\Files\Http\Resources\Api;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\DownloadLimitScope; use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Models\File; use App\Modules\Files\Models\File;
use App\Modules\Files\Models\FileAssignment; use App\Modules\Files\Models\FileAssignment;
@@ -26,6 +27,23 @@ use Illuminate\Http\Resources\Json\JsonResource;
* - `checksum` is included deliberately, since verifying an integration's * - `checksum` is included deliberately, since verifying an integration's
* own download is a real use case, and it reveals nothing about * own download is a real use case, and it reveals nothing about
* location. * location.
*
* Two fields are narrowed to the caller: the uploader and the assignment
* list both name clients, and a client-scoped account may hold a file whose
* uploader or co-recipients are clients off their own roster — the file is
* theirs to read, those names are not theirs to see. ClientIdentityScope is
* the rule; a name dropped here is dropped to null or out of the list, and
* an unscoped account is unaffected.
*
* That narrowing happens here rather than in the controllers, which is the opposite of how the version counterparts are
* handled a few files over — and deliberately so. Whether a counterpart may
* be named is a set-shaped question with a query to express it, so it is
* asked once in the caller's eager load. Whether a client may be named is a
* per-row check against the viewer's roster with no query to fold it into,
* and this resource is built at eight call sites across four controllers,
* two of them re-loading `assignments.assignable` after a write. Asking at
* the point of serialisation is the only version of this rule that cannot
* be forgotten by the ninth caller.
*/ */
class FileResource extends JsonResource class FileResource extends JsonResource
{ {
@@ -34,6 +52,15 @@ class FileResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$viewer = $request->user();
$identity = app(ClientIdentityScope::class);
// The morph class rather than ::class, matching ShareTargets: with
// a morph map registered the two disagree, and this line now
// decides which roster an entry is checked against, so getting it
// wrong would mean checking a group id against the client list.
$groupMorph = (new Group)->getMorphClass();
return [ return [
'id' => $this->id, 'id' => $this->id,
'name' => $this->name, 'name' => $this->name,
@@ -96,11 +123,16 @@ class FileResource extends JsonResource
]), ]),
// Name only. The uploader is a user record; their email address // Name only. The uploader is a user record; their email address
// is not part of what "this file exists" needs to say. // is not part of what "this file exists" needs to say. Null
'uploaded_by' => $this->whenLoaded('uploader', fn (): ?array => $this->uploader === null ? null : [ // when the uploader is a client the token's owner is not
'id' => $this->uploader->id, // scoped to; an unscoped account always gets the name.
'name' => $this->uploader->name, 'uploaded_by' => $this->whenLoaded(
]), 'uploader',
fn (): ?array => $identity->permits($viewer, $this->uploader) && $this->uploader !== null ? [
'id' => $this->uploader->id,
'name' => $this->uploader->name,
] : null,
),
'categories' => $this->whenLoaded('categories', fn (): array => $this->categories 'categories' => $this->whenLoaded('categories', fn (): array => $this->categories
->map(fn ($category): array => [ ->map(fn ($category): array => [
@@ -109,15 +141,22 @@ class FileResource extends JsonResource
]) ])
->all()), ->all()),
// Who the file is shared with, as far as this caller is
// concerned: a recipient the token's owner is not scoped to is
// left out rather than returned without a name.
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments 'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
->filter(fn (FileAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
->map(fn (FileAssignment $assignment): array => [ ->map(fn (FileAssignment $assignment): array => [
'type' => $assignment->assignable_type === Group::class ? 'group' : 'client', 'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
'id' => $assignment->assignable_id, 'id' => $assignment->assignable_id,
// getAttribute() rather than ->name: the relation is a // getAttribute() rather than ->name: the relation is a
// MorphTo over User|Group, so the property is only // MorphTo over User|Group, so the property is only
// knowable at runtime. Both targets carry a name. // knowable at runtime. Both targets carry a name.
'name' => $assignment->assignable?->getAttribute('name'), 'name' => $assignment->assignable?->getAttribute('name'),
]) ])
->values()
->all()), ->all()),
'links' => [ 'links' => [
+139 -73
View File
@@ -27,6 +27,12 @@ use Throwable;
*/ */
class LocalPartStore class LocalPartStore
{ {
/**
* Said twice, because a full temp volume can announce itself in the
* middle of the copy or only when the last buffer is flushed.
*/
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
/** /**
* The route name is a parameter because the same flow is mounted twice: * The route name is a parameter because the same flow is mounted twice:
* once on the session-authenticated web routes for the browser, once on * once on the session-authenticated web routes for the browser, once on
@@ -140,9 +146,21 @@ class LocalPartStore
} }
/** /**
* Stream-append parts in order onto the files disk, hashing as we * Stream-append parts in order onto the files disk, hashing as we go.
* go. Peak temp usage ≈ file size + one part (parts are unlinked *
* as they are consumed). * The parts stay on disk until the assembled bytes are safely on the
* target disk. ChunkedUploadsController's completion lock promises that
* "a later retry still works", and everything that can fail after the
* concatenation — reopening the copy, a disk refusing the write, the
* File row itself — happens while the client has nothing but this
* session to retry with. Unlinking each part as it was consumed left
* listParts() empty, so every later complete() answered "Upload is
* incomplete: missing parts" for good.
*
* The cost is temp space: peak usage is the whole file twice over
* (every part, plus the assembled copy) rather than the file plus one
* part. Both are freed by the abort() below the moment the write lands,
* and by the failure path the moment it does not.
* *
* @return array{path: string, disk: string, size: int, checksum: string} * @return array{path: string, disk: string, size: int, checksum: string}
*/ */
@@ -158,6 +176,93 @@ class LocalPartStore
} }
$assembledPath = $this->directory($session).'/assembled'; $assembledPath = $this->directory($session).'/assembled';
try {
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
$readStream = fopen($assembledPath, 'rb');
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
}
$diskEvent = new ResolvingUploadDisk($session->user);
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
// The disks are configured with 'throw' => false, so a refused
// write is a `false` return rather than an exception — and the
// caller goes on to record a File row for bytes that were never
// stored. Losing an upload silently is worse than failing it, and
// this is the only place that can tell the difference: a real
// instance of it was a GCS bucket rejecting the adapter's ACL,
// which looked exactly like a successful upload.
if ($written === false) {
// The reason is lost by the time it gets here — 'throw' => false
// means Flysystem swallowed the exception rather than passing it
// on — so log what was attempted. Which bucket it was is the
// difference between reading this as "my credentials expired"
// and "I typed the wrong bucket name", and only the log can say
// it: the message below is shown to whoever was uploading, which
// includes clients, and a bucket name is not theirs to see.
Log::error('Upload could not be written to storage.', [
'disk' => $disk,
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
'driver' => config('filesystems.disks.'.$disk.'.driver'),
'path' => $targetPath,
]);
throw new RuntimeException(
'Could not write the assembled upload to the "'.$disk.'" disk. '
.'Check the storage backend is reachable and its credentials are still valid.'
);
}
} catch (Throwable $failure) {
// The half-written copy belongs to this attempt and the next one
// makes its own; the parts belong to the client, and they are
// what a retry needs. Deleting the copy here is also the only
// thing that removes it at all on this path — it used to sit in
// the session directory until the sweeper came round.
FileSystem::delete($assembledPath);
throw $failure;
}
$this->abort($session);
return [
'path' => $targetPath,
'disk' => $disk,
'size' => $size,
'checksum' => $checksum,
];
}
/**
* Concatenate the parts into $assembledPath, returning the byte count
* and the sha256 of what was written.
*
* Every read and every write is checked. They were not, and while a
* failing fwrite on a full volume is loud in practice — Laravel's
* error handler turns the warning into an ErrorException — loud there
* means a 500 carrying a PHP message, where the disk-refused-the-write
* case a few lines above becomes a sentence the person uploading can
* act on. A short write arriving without a warning would be worse
* still: $size and the hash describe the buffer that was read, so an
* unchecked one yields a truncated file with a checksum matching bytes
* that were never stored.
*
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
* @return array{0: int, 1: string}
*/
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
{
$out = fopen($assembledPath, 'wb'); $out = fopen($assembledPath, 'wb');
if ($out === false) { if ($out === false) {
@@ -167,85 +272,46 @@ class LocalPartStore
$hash = hash_init('sha256'); $hash = hash_init('sha256');
$size = 0; $size = 0;
foreach ($parts as $part) { try {
$partPath = $this->partPath($session, $part['PartNumber']); foreach ($parts as $part) {
$in = fopen($partPath, 'rb'); $in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
if ($in === false) { if ($in === false) {
fclose($out); throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
while (! feof($in)) {
$buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
break;
} }
fwrite($out, $buffer); try {
hash_update($hash, $buffer); while (! feof($in)) {
$size += strlen($buffer); $buffer = fread($in, 1024 * 1024);
if ($buffer === false) {
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
}
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
throw new RuntimeException(self::WRITE_FAILED);
}
hash_update($hash, $buffer);
$size += strlen($buffer);
}
} finally {
fclose($in);
}
} }
} catch (Throwable $failure) {
fclose($out);
fclose($in); throw $failure;
unlink($partPath);
} }
fclose($out); // fclose flushes, so a volume that filled up on the last buffer
// fails here rather than in the loop.
$readStream = fopen($assembledPath, 'rb'); if (! fclose($out)) {
throw new RuntimeException(self::WRITE_FAILED);
if ($readStream === false) {
throw new RuntimeException('Could not reopen assembled file.');
} }
$diskEvent = new ResolvingUploadDisk($session->user); return [$size, hash_final($hash)];
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
// The disks are configured with 'throw' => false, so a refused
// write is a `false` return rather than an exception — and the
// caller goes on to record a File row for bytes that were never
// stored. Losing an upload silently is worse than failing it, and
// this is the only place that can tell the difference: a real
// instance of it was a GCS bucket rejecting the adapter's ACL,
// which looked exactly like a successful upload.
if ($written === false) {
// The reason is lost by the time it gets here — 'throw' => false
// means Flysystem swallowed the exception rather than passing it
// on — so log what was attempted. Which bucket it was is the
// difference between reading this as "my credentials expired"
// and "I typed the wrong bucket name", and only the log can say
// it: the message below is shown to whoever was uploading, which
// includes clients, and a bucket name is not theirs to see.
Log::error('Upload could not be written to storage.', [
'disk' => $disk,
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
'driver' => config('filesystems.disks.'.$disk.'.driver'),
'path' => $targetPath,
]);
throw new RuntimeException(
'Could not write the assembled upload to the "'.$disk.'" disk. '
.'Check the storage backend is reachable and its credentials are still valid.'
);
}
$this->abort($session);
return [
'path' => $targetPath,
'disk' => $disk,
'size' => $size,
'checksum' => hash_final($hash),
];
} }
public function abort(UploadSession $session): void public function abort(UploadSession $session): void
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Announcements\Events;
/**
* A single message a package wants put in front of staff.
*
* Shown twice, from one source: a band across the top of the dashboard,
* and an icon beside the notification bell that opens the same words on
* every other page. One event rather than two because "the same message"
* is the requirement — two props would drift the day somebody edits one.
*
* Not a widget, on purpose. The widget grid is a closed list of keys that
* dashboard.tsx renders one by one, and each viewer arranges it — so a
* message that matters would sit wherever somebody happened to drag it,
* or under a fold, or switched off. A band above the grid is seen without
* competing with the columns for space.
*
* **Core knows nothing about what it says.** Title, body, the label on the
* button and where the button goes all come from the listener. The first
* caller is the hosted edition telling a free instance what a paid plan
* would give it, which is commercial copy belonging to one offering and
* has no place in the public repository.
*
* One at a time, deliberately. A dashboard that can accumulate banners
* accumulates them, and the second one is what teaches people to skip the
* first. A listener that finds one already set should leave it alone
* rather than overwrite it.
*/
class ResolvingAnnouncement
{
/**
* @var array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
*/
public ?array $announcement = null;
public function __construct(
/** Whether the viewer is a staff account. */
public readonly bool $isStaff,
) {}
/**
* `tone` picks the accent the band is drawn in. Two values, because
* two is what the difference is worth: `info` for something worth
* knowing, `warning` for something worth acting on. Anything else
* falls back to `info` rather than rendering unstyled.
*/
public function show(string $title, string $body, ?string $actionLabel = null, ?string $actionUrl = null, string $tone = 'info'): void
{
if ($this->announcement !== null) {
return;
}
$this->announcement = [
'title' => $title,
'body' => $body,
'action_label' => $actionLabel,
'action_url' => $actionUrl,
'tone' => in_array($tone, ['info', 'warning'], true) ? $tone : 'info',
];
}
}
@@ -32,6 +32,24 @@ enum Capability: string
case EmailTransportConfigure = 'email.transport.configure'; case EmailTransportConfigure = 'email.transport.configure';
case SystemUpdates = 'system.updates'; case SystemUpdates = 'system.updates';
// Community-only — whether this installation may switch off the
// project news on its dashboard.
//
// Note what is Community-only: the *choice*, not the news. A managed
// instance still fetches and still shows it, and cannot be made to
// stop. That is the difference from SystemUpdates beside it, and it
// is worth stating because the two look alike and are opposites. An
// update notice is useless on a hosted tenant — they cannot act on
// it, the image is ours — so the check does not run at all there.
// News is the reverse: announcements about the product are exactly
// what a hosted customer should be told, and an administrator
// switching them off for everybody on that instance is not a
// preference we meant to hand over.
//
// A self-hosted operator keeps the switch, because there nobody else
// decides what their installation reaches out for.
case NewsConfigure = 'news.configure';
// Community-only — scheduled-task run history and failed-queue-job // Community-only — scheduled-task run history and failed-queue-job
// visibility. Cut on managed installations, where infrastructure // visibility. Cut on managed installations, where infrastructure
// monitoring happens outside this application; a transient failure // monitoring happens outside this application; a transient failure
@@ -79,11 +97,36 @@ enum Capability: string
// simply inert and files stay on local disk. // simply inert and files stay on local disk.
case StorageManaged = 'storage.managed'; case StorageManaged = 'storage.managed';
// Both editions, and present by default: a self-hosted installation
// has this screen today and needs it, because nobody else is going to
// supply its keys. It exists as a key so a managed platform can
// subtract it, and the reason to subtract it is narrower than the
// reason LDAP and social login stayed ungated.
//
// On a managed installation the administrator and the host are the
// same person, but the *reputation* is not theirs. Every tenant is a
// name under one shared domain, sending mail from one shared pool. An
// administrator who sets the provider to none, or who leaves the keys
// alone and just unticks the four per-form switches, turns their own
// public forms into an open door and spends everybody else's
// deliverability doing it. That is the same shape as Storage: not a
// feature somebody paid for, but a setting whose blast radius reaches
// past the installation that holds it.
//
// All-or-nothing on the route, read included, exactly as Storage and
// Branding are. Per-field gating in the controller would not do:
// switching the CAPTCHA off does not need the key fields at all, so
// the PATCH has to be closed too, and the middleware closes both
// verbs at once.
case CaptchaConfigure = 'captcha.configure';
// Cloud-only — managed installations supply CAPTCHA keys centrally, so // Cloud-only — managed installations supply CAPTCHA keys centrally, so
// protection is on before anybody finds the settings screen. The // protection is on before anybody finds the settings screen. The
// feature itself is in both editions and behind no capability: this // feature itself is in both editions: this covers only the option of
// covers only the option of using *our* credentials, which cannot ship // using *our* credentials, which cannot ship inside a self-hosted
// inside a self-hosted package. // package. Distinct from CaptchaConfigure above — that one says
// whether the screen opens at all, this one says what it may offer
// once it does.
case CaptchaManagedKeys = 'captcha.managed_keys'; case CaptchaManagedKeys = 'captcha.managed_keys';
// Cloud-only — letting an AI assistant act on this installation on // Cloud-only — letting an AI assistant act on this installation on
@@ -125,10 +168,12 @@ enum Capability: string
self::StorageConfigure, self::StorageConfigure,
self::EmailTransportConfigure, self::EmailTransportConfigure,
self::SystemUpdates, self::SystemUpdates,
self::NewsConfigure,
self::SchedulerMonitoring, self::SchedulerMonitoring,
self::CustomAssets => [Edition::Community], self::CustomAssets => [Edition::Community],
self::UsersManage, self::UsersManage,
self::CaptchaConfigure,
self::Branding => [Edition::Community, Edition::Cloud], self::Branding => [Edition::Community, Edition::Cloud],
self::AttributionHide, self::AttributionHide,
@@ -20,8 +20,12 @@ use Illuminate\Console\Command;
* administrator editing a database table by hand, guessing which of * administrator editing a database table by hand, guessing which of
* several rows matters. * several rows matters.
* *
* PROJECTSEND_CAPTCHA_DISABLED does the same thing for anyone who would * PROJECTSEND_CAPTCHA_DISABLED is the other half of the same escape
* rather touch .env than run artisan. * hatch, and not merely the .env spelling of this one: it is checked
* first, ahead of the key source, so it is the only one of the two that
* works on an installation running the platform's managed keys. This
* command writes a setting those installations never read, and says so
* rather than reporting a success it did not have.
*/ */
class DisableCaptchaCommand extends Command class DisableCaptchaCommand extends Command
{ {
@@ -29,7 +33,7 @@ class DisableCaptchaCommand extends Command
protected $description = 'Switch off the CAPTCHA on public forms'; protected $description = 'Switch off the CAPTCHA on public forms';
public function handle(Settings $settings): int public function handle(Settings $settings, Captcha $captcha): int
{ {
$settings->set(Setting::CaptchaProvider, 'none'); $settings->set(Setting::CaptchaProvider, 'none');
@@ -38,6 +42,24 @@ class DisableCaptchaCommand extends Command
Captcha::forgetDisplayCache(); Captcha::forgetDisplayCache();
CaptchaVerifier::forgetOutage(); CaptchaVerifier::forgetOutage();
// Managed keys are not this setting. Captcha::resolve() reaches
// them from config and returns before it ever looks at
// Setting::CaptchaProvider, so on an installation using them the
// write above changed a value nothing reads. Saying "CAPTCHA is
// off" there would be false, and false in the worst direction: an
// operator who is still being challenged would stop looking,
// having just been told the thing challenging them is gone.
//
// Read after the write rather than before it, because the write is
// what makes the answer meaningful — if this still resolves to
// something, the something is not ours to switch off.
if ($captcha->managedKeysSelected()) {
$this->warn('Nothing changed. This installation uses CAPTCHA keys supplied by the platform, and those do not come from the setting this command writes.');
$this->line('Set PROJECTSEND_CAPTCHA_DISABLED=true in the environment and restart to switch it off.');
return self::SUCCESS;
}
$this->info('CAPTCHA is off. Your keys are still stored — switch it back on at /system/settings/captcha.'); $this->info('CAPTCHA is off. Your keys are still stored — switch it back on at /system/settings/captcha.');
return self::SUCCESS; return self::SUCCESS;
@@ -24,13 +24,18 @@ use Inertia\Response;
/** /**
* Configuring the CAPTCHA on public forms. * Configuring the CAPTCHA on public forms.
* *
* Available in **both** editions and behind no capability, for the reason * Available in **both** editions, and behind Capability::CaptchaConfigure
* LDAP settled and social login repeated: this is an administrator's * — present by default, so a self-hosted installation keeps the screen,
* setting, not an edition difference. What *is* an edition difference is * and removable by an operator whose tenants share a domain and a sending
* the option of using the platform's own keys, and that is enforced per * reputation. Enforced entirely by the `capability:captcha.configure`
* field rather than on the route — the shape EmailSettingsController uses * route middleware, which covers the PATCH as well as the GET: turning
* for SMTP, so a hand-crafted PATCH cannot select a key source this * the CAPTCHA off needs no gated field at all, so nothing short of
* installation has no keys for. * closing the write would have closed it.
*
* Which keys this installation may point at is a second question, and
* that one is still enforced per field below rather than on the route —
* the shape EmailSettingsController uses for SMTP, so a hand-crafted
* PATCH cannot select a key source this installation has no keys for.
* *
* The secret key follows the pattern MailProviderSettings established and * The secret key follows the pattern MailProviderSettings established and
* LdapSettings and SocialSettings repeated: it is never sent to the * LdapSettings and SocialSettings repeated: it is never sent to the
@@ -45,6 +45,10 @@ class SystemSettingsController extends Controller
{ {
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates) $canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
&& $request->user()?->can('manage_updates') === true; && $request->user()?->can('manage_updates') === true;
// No permission beside it, unlike updates: turning the news card
// off is an ordinary settings change, and edit_settings already
// gates this whole screen.
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
return Inertia::render('system/settings/general', [ return Inertia::render('system/settings/general', [
'site_name' => $this->settings->get(Setting::SiteName), 'site_name' => $this->settings->get(Setting::SiteName),
@@ -62,6 +66,15 @@ class SystemSettingsController extends Controller
'viewer_timezone' => $request->user()?->timezone, 'viewer_timezone' => $request->user()?->timezone,
'can_manage_updates' => $canManageUpdates, 'can_manage_updates' => $canManageUpdates,
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null, 'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
// Its own capability, and deliberately not $canManageUpdates:
// the update block disappears on a managed instance because
// nobody there can act on it, while this one disappears
// because the news must keep arriving whether or not the
// instance's administrator would have chosen it. Null where
// the choice is not theirs, so the page renders no switch
// rather than a switch that would do nothing.
'can_configure_news' => $canConfigureNews,
'fetch_news' => $canConfigureNews ? $this->settings->get(Setting::FetchNews) : null,
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null, 'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
'check_result' => $request->session()->get('update_check_result'), 'check_result' => $request->session()->get('update_check_result'),
]); ]);
@@ -123,6 +136,10 @@ class SystemSettingsController extends Controller
{ {
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates) $canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
&& $request->user()?->can('manage_updates') === true; && $request->user()?->can('manage_updates') === true;
// No permission beside it, unlike updates: turning the news card
// off is an ordinary settings change, and edit_settings already
// gates this whole screen.
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
$rules = [ $rules = [
'site_name' => ['required', 'string', 'max:255'], 'site_name' => ['required', 'string', 'max:255'],
@@ -133,6 +150,10 @@ class SystemSettingsController extends Controller
// "follow APP_TIMEZONE", and only a fresh install has that. // "follow APP_TIMEZONE", and only a fresh install has that.
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())], 'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
]; ];
if ($canConfigureNews) {
$rules['fetch_news'] = ['sometimes', 'boolean'];
}
if ($canManageUpdates) { if ($canManageUpdates) {
// Omitting the field (any caller not sending it, not just this // Omitting the field (any caller not sending it, not just this
// page's own form) leaves the current value alone rather than // page's own form) leaves the current value alone rather than
@@ -156,6 +177,13 @@ class SystemSettingsController extends Controller
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']); $this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
} }
// Never read where the choice is not this installation's, so a
// hand-crafted PATCH cannot switch off the news on a managed
// instance any more than the absent checkbox could.
if ($canConfigureNews && array_key_exists('fetch_news', $validated)) {
$this->settings->set(Setting::FetchNews, $validated['fetch_news']);
}
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']); $this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
return back(); return back();
@@ -450,6 +450,16 @@ class StatusCommand extends Command
// on the day it happens to be empty rather than on the day it // on the day it happens to be empty rather than on the day it
// is written. It cannot be empty today, but the allowlist is // is written. It cannot be empty today, but the allowlist is
// meant to be edited. // meant to be edited.
//
// What that costs, confirmed against the reader rather than
// guessed at: the hosted platform's probe decodes this block
// into a typed struct and discards a block it cannot read, and
// Go refuses a JSON list into a map outright. So a `[]` here
// would not lose `actions` — it would lose the whole `usage`
// block, downloads and uploads with it, on the day a tenant
// happened to have no counted activity. The quietest
// installations would stop reporting and nothing would log a
// fault. Both shapes are pinned by tests on that side too.
'actions' => (object) $this->usageActions($since), 'actions' => (object) $this->usageActions($since),
]; ];
} }
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Navigation\Events;
/**
* Extra links a package wants in the sidebar.
*
* The sidebar is built from a hardcoded array in app-sidebar.tsx, which
* means a package could not contribute to it at all — the nav link was a
* separate manual edit every time a package grew a screen, and being
* manual it was forgotten. This is the seam that fixes that, in the shape
* the extension-points document settles on: core dispatches
* unconditionally, listeners add or do not, and with no listener the
* default (no extra links) holds.
*
* **Core deliberately learns nothing about what is added.** A link's
* label, its URL and the reason it exists all arrive from whoever
* registers it. That is not fastidiousness: the first caller is the
* hosted edition's link to its own customer portal, and a product URL
* belonging to one commercial offering has no business sitting in the
* public repository just because the sidebar happens to live here.
*
* Staff only, and enforced here rather than trusted to each listener:
* these appear in the administration area, and a client's portal shows
* only their own files.
*/
class ResolvingNavigationLinks
{
/**
* @var list<array{title: string, url: string, external: bool, icon: string|null}>
*/
public array $links = [];
public function __construct(
/** Whether the viewer is a staff account. Listeners that only make
* sense for staff should check this rather than assume. */
public readonly bool $isStaff,
) {}
/**
* `external` opens in a new tab and marks the link as leaving this
* installation — a link that navigates a person away from the app
* they are working in should say so before they click it, not after.
*/
public function add(string $title, string $url, bool $external = false, ?string $icon = null): void
{
$this->links[] = [
'title' => $title,
'url' => $url,
'external' => $external,
'icon' => $icon,
];
}
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Modules\Platform\News\Console; namespace App\Modules\Platform\News\Console;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings; use App\Modules\Platform\Settings\Settings;
use Illuminate\Console\Command; use Illuminate\Console\Command;
@@ -12,9 +14,13 @@ use Illuminate\Support\Facades\Http;
use Stevebauman\Purify\Facades\Purify; use Stevebauman\Purify\Facades\Purify;
/** /**
* Both editions — unlike CheckForUpdatesCommand, this isn't gated on any * Both editions, and on a managed instance not switchable off — unlike
* Capability: dashboard news is informational content, not an update * CheckForUpdatesCommand, which does not run there at all. Dashboard news
* action, so Cloud tenants see it too. * is informational content rather than an update action, so hosted
* customers see it too, and see it whether their administrator would have
* chosen to or not. Capability::NewsConfigure is what a self-hosted
* installation holds and a managed one does not: the choice is the
* edition difference, not the news.
* *
* The feed returns raw HTML in `content` (links, paragraphs) — sanitized * The feed returns raw HTML in `content` (links, paragraphs) — sanitized
* here, once, before it's ever cached or sent to the frontend, so the * here, once, before it's ever cached or sent to the frontend, so the
@@ -34,12 +40,36 @@ class FetchNewsCommand extends Command
public function __construct( public function __construct(
private readonly Settings $settings, private readonly Settings $settings,
private readonly CapabilityRegistry $capabilities,
) { ) {
parent::__construct(); parent::__construct();
} }
public function handle(): int public function handle(): int
{ {
// The setting only decides where the installation is allowed to
// make that choice. On a managed instance it is not: announcements
// about the product are what a hosted customer should be told, and
// one administrator switching them off for everybody on that
// instance is not a decision the platform hands over. So the news
// runs there regardless of what any row says — including a row
// left behind by an instance that used to be self-hosted.
//
// The opposite of the update check, which does not run on a
// managed instance at all because nobody there could act on it.
// The two look alike and point in different directions.
//
// Returns success rather than failure: a scheduled task that was
// asked not to run has not failed, and reporting it as a failure
// would put a red line in the scheduler history every night for
// an installation that is behaving exactly as configured.
if ($this->capabilities->has(Capability::NewsConfigure)
&& $this->settings->get(Setting::FetchNews) !== true) {
$this->info('The news feed is switched off for this installation.');
return self::SUCCESS;
}
$response = Http::withHeaders(['User-Agent' => 'ProjectSend']) $response = Http::withHeaders(['User-Agent' => 'ProjectSend'])
->timeout(10) ->timeout(10)
->get(self::FEED_URL); ->get(self::FEED_URL);
+13
View File
@@ -249,6 +249,17 @@ enum Setting: string
// is allowed to tell the admin a newer release exists. // is allowed to tell the admin a newer release exists.
case CheckForUpdates = 'check_for_updates'; case CheckForUpdates = 'check_for_updates';
// Whether this installation fetches the project's news feed for the
// dashboard card. Its own key rather than riding on CheckForUpdates
// above, because they are two different wants: "do not tell me about
// releases" and "do not show me the project's news" are asked
// separately, and an installation with no outbound access at all
// wants both off while an ordinary one may want updates and no feed.
//
// On by default, so nothing changes for an installation that has
// never seen this switch.
case FetchNews = 'fetch_news';
// Cached result of the last update check — never written directly by // Cached result of the last update check — never written directly by
// a settings form, only by CheckForUpdatesCommand. Empty string means // a settings form, only by CheckForUpdatesCommand. Empty string means
// "no successful check yet" (fresh install, or checks disabled). // "no successful check yet" (fresh install, or checks disabled).
@@ -367,6 +378,7 @@ enum Setting: string
self::ClientsCanPreviewFiles, self::ClientsCanPreviewFiles,
self::PublicListingPreviewEnabled, self::PublicListingPreviewEnabled,
self::CheckForUpdates, self::CheckForUpdates,
self::FetchNews,
self::ExpiredFilesAutoDeleteEnabled, self::ExpiredFilesAutoDeleteEnabled,
self::PublicCommentsEnabled, self::PublicCommentsEnabled,
self::CommentsGuestModeration, self::CommentsGuestModeration,
@@ -433,6 +445,7 @@ enum Setting: string
self::OrphanFilesAutoDeleteEnabled => false, self::OrphanFilesAutoDeleteEnabled => false,
self::CheckForUpdates, self::CheckForUpdates,
self::FetchNews,
self::CommentsGuestModeration, self::CommentsGuestModeration,
// On, so that an installation updating into these switches // On, so that an installation updating into these switches
// keeps the preview it already had rather than losing it to a // keeps the preview it already had rather than losing it to a
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Modules\Platform\Updates\Console; namespace App\Modules\Platform\Updates\Console;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting; use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings; use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Updates\CheckForUpdates; use App\Modules\Platform\Updates\CheckForUpdates;
@@ -32,12 +34,37 @@ class CheckForUpdatesCommand extends Command
public function __construct( public function __construct(
private readonly Settings $settings, private readonly Settings $settings,
private readonly CheckForUpdates $check, private readonly CheckForUpdates $check,
private readonly CapabilityRegistry $capabilities,
) { ) {
parent::__construct(); parent::__construct();
} }
public function handle(): int public function handle(): int
{ {
// Ahead of the setting, and deliberately not a setting itself.
//
// A Setting says "the operator does not want this". The true
// statement on a managed installation is "there is nowhere for
// this to appear and nothing they could do about it": the
// dashboard's System card is gated on Capability::SystemUpdates
// (DashboardController), which is Community-only, so the answer
// this command fetches cannot be drawn on any screen — and the
// update UI is closed by the same capability, so it could not be
// acted on if it were. The image is chosen by whoever provisioned
// the instance.
//
// Encoding that as a preference would leave it switchable back on
// per tenant, which buys a nightly call to GitHub for a number
// nobody can see, and would leave the reason in a provisioning
// script rather than beside the code. A self-hosted installation
// holds the capability and loses nothing: its own setting below
// still decides.
if (! $this->capabilities->has(Capability::SystemUpdates)) {
$this->info('Update checks do not apply to this installation.');
return self::SUCCESS;
}
if ($this->settings->get(Setting::CheckForUpdates) !== true) { if ($this->settings->get(Setting::CheckForUpdates) !== true) {
$this->info('Update checks are disabled.'); $this->info('Update checks are disabled.');
Generated
+12 -12
View File
@@ -2811,16 +2811,16 @@
}, },
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.9.0", "version": "2.10.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/thephpleague/commonmark.git", "url": "https://github.com/thephpleague/commonmark.git",
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529" "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529", "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -2857,7 +2857,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "2.10-dev" "dev-main": "2.11-dev"
} }
}, },
"autoload": { "autoload": {
@@ -2914,7 +2914,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-08-03T13:42:31+00:00" "time": "2026-08-11T16:06:25+00:00"
}, },
{ {
"name": "league/config", "name": "league/config",
@@ -3883,16 +3883,16 @@
}, },
{ {
"name": "nette/schema", "name": "nette/schema",
"version": "v1.3.5", "version": "v1.3.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nette/schema.git", "url": "https://github.com/nette/schema.git",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" "reference": "c54350438cd6914616f790a49cb424605f421562"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "reference": "c54350438cd6914616f790a49cb424605f421562",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3944,9 +3944,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/nette/schema/issues", "issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.5" "source": "https://github.com/nette/schema/tree/v1.3.6"
}, },
"time": "2026-02-23T03:47:12+00:00" "time": "2026-08-16T21:58:41+00:00"
}, },
{ {
"name": "nette/utils", "name": "nette/utils",
+1 -1
View File
@@ -161,7 +161,7 @@ return [
| |
*/ */
'version' => '2.3.0', 'version' => '2.4.0',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
+4 -2
View File
@@ -449,8 +449,10 @@ the core vocabulary. A slug must be unique and lowercase; a clash throws at boot
silently shadowing. silently shadowing.
Module endpoints are deliberately absent from the document above — `OpenApiContractTest` skips Module endpoints are deliberately absent from the document above — `OpenApiContractTest` skips
`api/v1/modules/*` — so each package documents its own surface in its own repository. The `api/v1/modules/*` — because that document is served unauthenticated and has to be identical on
`branding` module's endpoints are in `packages/cloud-modules/docs/api.md`. every installation. The modules that ship with the application document their endpoints in
[`api-modules.md`](api-modules.md); a module living in its own package documents its surface in its
own repository.
--- ---
+82
View File
@@ -0,0 +1,82 @@
# API — module endpoints
Optional modules add endpoints under `/api/v1/modules/{module}/…`. They are **not** in the committed
[`api/openapi.json`](api/openapi.json): `OpenApiContractTest` skips `api/v1/modules/*`, because that
document is served unauthenticated and has to be identical on every installation, while a module's
paths exist only where the module does. This file is the documentation for the modules that ship
with the application; a module living in its own package documents its surface in its own repository.
Everything [`api-guide.md`](api-guide.md) describes — bearer-token authentication, ability checks,
RFC 7807 errors, rate limits — applies unchanged. The core supplies all of it; none of it is
restated per module.
`GET /api/v1/me` lists the modules an installation actually carries, so an integration can check for
`branding` before calling anything below rather than guessing from a 404.
---
## Branding
Mounted at `/api/v1/modules/branding`, behind `capability:branding.customize`. Every edition has
that capability; a hosted plan can have it subtracted from its environment, in which case these
paths answer 403 like any other gated route.
Both endpoints are read-only. Uploading either image is a multipart flow whose content-sniffing
rules only make sense behind a file picker, and settings writes follow the rule that there is never
a generic `PATCH /settings`.
Hiding the attribution line is not here. That switch is Cloud-only, has no API surface, and its
column is written by the `cloud-modules` package.
### `GET /logo` — ability: `edit_settings`
The logo shown in place of the default sidebar icon.
```json
{
"data": {
"logo_url": "https://example.test/storage/branding/9f3c….png",
"updated_at": "2026-08-07T16:02:30+00:00"
}
}
```
`logo_url` is `null` when no logo has been uploaded, which is the normal state rather than an error.
### `GET /watermark` — ability: `edit_settings`
The mark stamped onto the thumbnails and previews clients and anonymous public visitors see. What
this installation's own staff see goes unmarked, and the stored files — including every download —
are never altered.
```json
{
"data": {
"enabled": true,
"image_url": "https://example.test/storage/branding/a68a….png",
"position": "bottom-right",
"size": 35,
"opacity": 55
}
}
```
| Field | Meaning |
|---|---|
| `enabled` | Whether client- and public-facing images are *actually* being watermarked. False whenever nothing is drawn — including when the toggle is on but its image has since been removed. It answers "is this installation watermarking?", not "which way is the switch pointing?" What staff see is never marked regardless. |
| `image_url` | The artwork, or `null` if none was ever chosen. |
| `position` | One of `top-left`, `top-center`, `top-right`, `middle-left`, `center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right`. |
| `size` | Percentage of the image the mark is scaled to fit inside, keeping its proportions — so a thumbnail and a preview carry the same design at different scales. 5–100. |
| `opacity` | Percentage. 1–100. |
An installation that has never opened the branding screen answers with the defaults it would start
from (`enabled: false`, `bottom-right`, `30`, `60`) rather than a payload of nulls.
---
## Deferred, on purpose
- **Writes for either image.** See above.
- **Rendered thumbnails themselves.** Already deferred by the host ([`api-todo.md`](api-todo.md));
the watermark endpoint exists so an integration generating its own derivative images can reproduce
the installation's mark, not as a step toward serving thumbnails over the API.
+2 -1
View File
@@ -4075,7 +4075,7 @@
"object", "object",
"null" "null"
], ],
"description": "Name only. The uploader is a user record; their email address\nis not part of what \"this file exists\" needs to say.", "description": "Name only. The uploader is a user record; their email address\nis not part of what \"this file exists\" needs to say. Null\nwhen the uploader is a client the token's owner is not\nscoped to; an unscoped account always gets the name.",
"properties": { "properties": {
"id": { "id": {
"type": "integer" "type": "integer"
@@ -4109,6 +4109,7 @@
}, },
"assignments": { "assignments": {
"type": "array", "type": "array",
"description": "Who the file is shared with, as far as this caller is\nconcerned: a recipient the token's owner is not scoped to is\nleft out rather than returned without a name.",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Per què està configurat així", "Why it is set this way": "Per què està configurat així",
"Why that is worth changing": "Per què val la pena canviar-ho", "Why that is worth changing": "Per què val la pena canviar-ho",
"Why this matters, and how to change it": "Per què és important i com canviar-ho", "Why this matters, and how to change it": "Per què és important i com canviar-ho",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Els teus fitxers es desen fora de l'arrel web, així que cada descàrrega passa primer per ProjectSend, que comprova que qui la demana hi tingui dret. Després d'aquesta comprovació, el PHP obre el fitxer i l'envia. L'alternativa és que el PHP digui al teu servidor web «envia aquest fitxer» i acabi immediatament." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Els teus fitxers es desen fora de l'arrel web, així que cada descàrrega passa primer per ProjectSend, que comprova que qui la demana hi tingui dret. Després d'aquesta comprovació, el PHP obre el fitxer i l'envia. L'alternativa és que el PHP digui al teu servidor web «envia aquest fitxer» i acabi immediatament.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" és una carpeta pública: qualsevol podrà obrir aquest fitxer sense iniciar sessió.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" s'eliminarà, juntament amb l'accés de tothom. Un administrador ho pot desfer.",
"Anyone with the link will be able to open and download it, without signing in.": "Qualsevol que tingui l'enllaç podrà obrir-lo i descarregar-lo sense iniciar sessió.",
"Back to my files": "Torna als meus fitxers",
"Edit file": "Edita el fitxer",
"Expires on": "Caduca el",
"Make this file public": "Fes públic aquest fitxer",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Aquest lloc encara no té cap pàgina pública configurada, així que no es veurà res fins que un administrador en configuri una.",
"Show ProjectSend news on the dashboard": "Mostra les novetats de ProjectSend al tauler",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera els anuncis del projecte des de projectsend.org un cop al dia per a la targeta del tauler. Si ho desactives, aquesta instal·lació deixa de contactar amb projectsend.org per a novetats.",
"Announcement": "Avís",
"More": "Més"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Proč je to nastavené takto", "Why it is set this way": "Proč je to nastavené takto",
"Why that is worth changing": "Proč se to vyplatí změnit", "Why that is worth changing": "Proč se to vyplatí změnit",
"Why this matters, and how to change it": "Proč na tom záleží a jak to změnit", "Why this matters, and how to change it": "Proč na tom záleží a jak to změnit",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tvoje soubory jsou uložené mimo webový kořen, takže každé stahování projde nejdřív ProjectSendem, který ověří, že na soubor má daný člověk nárok. Po této kontrole PHP soubor otevře a odešle. Druhá možnost je, že PHP řekne webovému serveru „pošli tenhle soubor“ a hned skončí." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tvoje soubory jsou uložené mimo webový kořen, takže každé stahování projde nejdřív ProjectSendem, který ověří, že na soubor má daný člověk nárok. Po této kontrole PHP soubor otevře a odešle. Druhá možnost je, že PHP řekne webovému serveru „pošli tenhle soubor“ a hned skončí.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" je veřejná složka – tento soubor bude moci otevřít kdokoli bez přihlášení.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" bude smazán, včetně přístupu všech ostatních. Administrátor to může vrátit zpět.",
"Anyone with the link will be able to open and download it, without signing in.": "Kdokoli s odkazem jej bude moci otevřít a stáhnout bez přihlášení.",
"Back to my files": "Zpět na moje soubory",
"Edit file": "Upravit soubor",
"Expires on": "Vyprší dne",
"Make this file public": "Zveřejnit tento soubor",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tento web zatím nemá nastavenou veřejnou stránku, takže nebude nic vidět, dokud ji administrátor nenastaví.",
"Show ProjectSend news on the dashboard": "Zobrazovat novinky ProjectSendu v přehledu",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Jednou denně načte oznámení projektu z projectsend.org pro kartu v přehledu. Když to vypnete, tato instalace se kvůli novinkám na projectsend.org už vůbec nepřipojí.",
"Announcement": "Oznámení",
"More": "Další"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Warum es so eingestellt ist", "Why it is set this way": "Warum es so eingestellt ist",
"Why that is worth changing": "Warum sich eine Änderung lohnt", "Why that is worth changing": "Warum sich eine Änderung lohnt",
"Why this matters, and how to change it": "Warum das wichtig ist und wie du es änderst", "Why this matters, and how to change it": "Warum das wichtig ist und wie du es änderst",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Deine Dateien liegen außerhalb des Web-Roots, also läuft jeder Download zuerst über ProjectSend, das prüft, ob die anfragende Person sie haben darf. Nach dieser Prüfung öffnet PHP die Datei und sendet sie. Die Alternative ist, dass PHP deinem Webserver sagt „sende diese Datei“ und sofort fertig ist." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Deine Dateien liegen außerhalb des Web-Roots, also läuft jeder Download zuerst über ProjectSend, das prüft, ob die anfragende Person sie haben darf. Nach dieser Prüfung öffnet PHP die Datei und sendet sie. Die Alternative ist, dass PHP deinem Webserver sagt „sende diese Datei“ und sofort fertig ist.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" ist ein öffentlicher Ordner – jeder kann diese Datei dann ohne Anmeldung öffnen.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" wird gelöscht, zusammen mit dem Zugriff aller anderen darauf. Ein Administrator kann das rückgängig machen.",
"Anyone with the link will be able to open and download it, without signing in.": "Jeder mit dem Link kann sie ohne Anmeldung öffnen und herunterladen.",
"Back to my files": "Zurück zu meinen Dateien",
"Edit file": "Datei bearbeiten",
"Expires on": "Läuft ab am",
"Make this file public": "Diese Datei öffentlich machen",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Für diese Website ist noch keine öffentliche Seite eingerichtet, daher ist nichts sichtbar, bis ein Administrator eine einrichtet.",
"Show ProjectSend news on the dashboard": "ProjectSend-Neuigkeiten in der Übersicht anzeigen",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Ruft einmal täglich Projektankündigungen von projectsend.org für die Karte in der Übersicht ab. Ausgeschaltet nimmt diese Installation für Neuigkeiten überhaupt keine Verbindung zu projectsend.org mehr auf.",
"Announcement": "Ankündigung",
"More": "Mehr"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Por qué está configurado así", "Why it is set this way": "Por qué está configurado así",
"Why that is worth changing": "Por qué vale la pena cambiarlo", "Why that is worth changing": "Por qué vale la pena cambiarlo",
"Why this matters, and how to change it": "Por qué importa y cómo cambiarlo", "Why this matters, and how to change it": "Por qué importa y cómo cambiarlo",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tus archivos se guardan fuera de la raíz web, así que cada descarga pasa primero por ProjectSend para comprobar que quien la pide tiene permiso. Después de esa comprobación, PHP abre el archivo y lo envía. La alternativa es que PHP le diga a tu servidor web «envía este archivo» y termine de inmediato." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tus archivos se guardan fuera de la raíz web, así que cada descarga pasa primero por ProjectSend para comprobar que quien la pide tiene permiso. Después de esa comprobación, PHP abre el archivo y lo envía. La alternativa es que PHP le diga a tu servidor web «envía este archivo» y termine de inmediato.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" es una carpeta pública: cualquiera podrá abrir este archivo sin iniciar sesión.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" será eliminado, junto con el acceso de todos a él. Un administrador puede deshacerlo.",
"Anyone with the link will be able to open and download it, without signing in.": "Cualquiera que tenga el enlace podrá abrirlo y descargarlo sin iniciar sesión.",
"Back to my files": "Volver a mis archivos",
"Edit file": "Editar archivo",
"Expires on": "Vence el",
"Make this file public": "Hacer público este archivo",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este sitio todavía no tiene una página pública configurada, así que no se verá nada hasta que un administrador la configure.",
"Show ProjectSend news on the dashboard": "Mostrar las noticias de ProjectSend en el panel de control",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Trae los anuncios del proyecto desde projectsend.org una vez al día para la tarjeta del panel. Si lo desactivas, esta instalación deja de contactar a projectsend.org por noticias.",
"Announcement": "Aviso",
"More": "Más"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Pourquoi c'est réglé ainsi", "Why it is set this way": "Pourquoi c'est réglé ainsi",
"Why that is worth changing": "Pourquoi cela vaut la peine d'être changé", "Why that is worth changing": "Pourquoi cela vaut la peine d'être changé",
"Why this matters, and how to change it": "Pourquoi c'est important, et comment le changer", "Why this matters, and how to change it": "Pourquoi c'est important, et comment le changer",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tes fichiers sont stockés hors de la racine web : chaque téléchargement passe donc d'abord par ProjectSend, qui vérifie que la personne a le droit de l'obtenir. Après cette vérification, PHP ouvre le fichier et l'envoie. L'autre solution est que PHP dise à ton serveur web « envoie ce fichier » et se termine aussitôt." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tes fichiers sont stockés hors de la racine web : chaque téléchargement passe donc d'abord par ProjectSend, qui vérifie que la personne a le droit de l'obtenir. Après cette vérification, PHP ouvre le fichier et l'envoie. L'autre solution est que PHP dise à ton serveur web « envoie ce fichier » et se termine aussitôt.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" est un dossier public : n'importe qui pourra ouvrir ce fichier sans se connecter.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" sera supprimé, ainsi que l'accès de tout le monde à ce fichier. Un administrateur peut annuler cette action.",
"Anyone with the link will be able to open and download it, without signing in.": "N'importe qui disposant du lien pourra l'ouvrir et le télécharger sans se connecter.",
"Back to my files": "Retour à mes fichiers",
"Edit file": "Modifier le fichier",
"Expires on": "Expire le",
"Make this file public": "Rendre ce fichier public",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ce site n'a pas encore de page publique configurée, donc rien ne sera visible tant qu'un administrateur n'en aura pas configuré une.",
"Show ProjectSend news on the dashboard": "Afficher les actualités ProjectSend sur le tableau de bord",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Récupère une fois par jour les annonces du projet depuis projectsend.org pour la carte du tableau de bord. Désactivé, cette installation ne contacte plus du tout projectsend.org pour les actualités.",
"Announcement": "Annonce",
"More": "Plus"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Mengapa disetel seperti ini", "Why it is set this way": "Mengapa disetel seperti ini",
"Why that is worth changing": "Mengapa ini layak diubah", "Why that is worth changing": "Mengapa ini layak diubah",
"Why this matters, and how to change it": "Mengapa ini penting, dan cara mengubahnya", "Why this matters, and how to change it": "Mengapa ini penting, dan cara mengubahnya",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Berkas Anda disimpan di luar akar web, jadi setiap unduhan melewati ProjectSend lebih dulu untuk memastikan orang tersebut memang berhak menerimanya. Setelah pemeriksaan itu, PHP membuka berkas dan mengirimkannya. Alternatifnya adalah PHP memberi tahu server web Anda “kirim berkas ini” lalu langsung selesai." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Berkas Anda disimpan di luar akar web, jadi setiap unduhan melewati ProjectSend lebih dulu untuk memastikan orang tersebut memang berhak menerimanya. Setelah pemeriksaan itu, PHP membuka berkas dan mengirimkannya. Alternatifnya adalah PHP memberi tahu server web Anda “kirim berkas ini” lalu langsung selesai.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "Folder \":name\" bersifat publik — siapa pun bisa membuka berkas ini tanpa masuk.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" akan dihapus, beserta akses semua orang ke berkas ini. Administrator bisa membatalkannya.",
"Anyone with the link will be able to open and download it, without signing in.": "Siapa pun yang punya tautannya bisa membuka dan mengunduhnya tanpa masuk.",
"Back to my files": "Kembali ke berkas saya",
"Edit file": "Sunting berkas",
"Expires on": "Kedaluwarsa pada",
"Make this file public": "Jadikan berkas ini publik",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Situs ini belum punya halaman publik, jadi tidak ada yang terlihat sampai administrator menyiapkannya.",
"Show ProjectSend news on the dashboard": "Tampilkan kabar terbaru ProjectSend di dasbor",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Mengambil pengumuman proyek dari projectsend.org sekali sehari untuk kartu di dasbor. Jika dimatikan, instalasi ini sama sekali tidak lagi menghubungi projectsend.org untuk kabar terbaru.",
"Announcement": "Pengumuman",
"More": "Lainnya"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Perché è impostato così", "Why it is set this way": "Perché è impostato così",
"Why that is worth changing": "Perché vale la pena cambiarlo", "Why that is worth changing": "Perché vale la pena cambiarlo",
"Why this matters, and how to change it": "Perché è importante e come cambiarlo", "Why this matters, and how to change it": "Perché è importante e come cambiarlo",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "I tuoi file sono archiviati fuori dalla radice web, quindi ogni download passa prima da ProjectSend, che verifica che chi lo chiede possa averlo. Dopo quel controllo, PHP apre il file e lo invia. L'alternativa è che PHP dica al tuo server web «invia questo file» e concluda subito." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "I tuoi file sono archiviati fuori dalla radice web, quindi ogni download passa prima da ProjectSend, che verifica che chi lo chiede possa averlo. Dopo quel controllo, PHP apre il file e lo invia. L'alternativa è che PHP dica al tuo server web «invia questo file» e concluda subito.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" è una cartella pubblica: chiunque potrà aprire questo file senza accedere.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" verrà eliminato, insieme all'accesso di tutti al file. Un amministratore può annullare l'operazione.",
"Anyone with the link will be able to open and download it, without signing in.": "Chiunque abbia il link potrà aprirlo e scaricarlo senza accedere.",
"Back to my files": "Torna ai miei file",
"Edit file": "Modifica file",
"Expires on": "Scade il",
"Make this file public": "Rendi pubblico questo file",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Questo sito non ha ancora una pagina pubblica, quindi non sarà visibile nulla finché un amministratore non ne configura una.",
"Show ProjectSend news on the dashboard": "Mostra le novità di ProjectSend nel pannello",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera gli annunci del progetto da projectsend.org una volta al giorno per la scheda del pannello. Disattivandolo, questa installazione smette del tutto di contattare projectsend.org per le novità.",
"Announcement": "Avviso",
"More": "Altro"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "なぜこの設定になっているか", "Why it is set this way": "なぜこの設定になっているか",
"Why that is worth changing": "変更する価値がある理由", "Why that is worth changing": "変更する価値がある理由",
"Why this matters, and how to change it": "これが重要な理由と変更方法", "Why this matters, and how to change it": "これが重要な理由と変更方法",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "ファイルは Web ルートの外に保存されているため、ダウンロードはまず ProjectSend を通り、要求した人に権限があるかを確認します。その確認のあと、PHP がファイルを開いて送信しています。もう一つの方法は、PHP が Web サーバーに「このファイルを送って」と伝えてすぐ処理を終えることです。" "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "ファイルは Web ルートの外に保存されているため、ダウンロードはまず ProjectSend を通り、要求した人に権限があるかを確認します。その確認のあと、PHP がファイルを開いて送信しています。もう一つの方法は、PHP が Web サーバーに「このファイルを送って」と伝えてすぐ処理を終えることです。",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "「:name」は公開フォルダです。ログインしていない人でもこのファイルを開けるようになります。",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "「:name」を削除します。ほかの人のアクセス権もなくなります。管理者なら元に戻せます。",
"Anyone with the link will be able to open and download it, without signing in.": "リンクを知っている人なら誰でも、ログインせずに開いてダウンロードできます。",
"Back to my files": "マイファイルに戻る",
"Edit file": "ファイルを編集",
"Expires on": "有効期限",
"Make this file public": "このファイルを公開する",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。",
"Show ProjectSend news on the dashboard": "ダッシュボードに ProjectSend のお知らせを表示する",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "ダッシュボードのカード用に、プロジェクトのお知らせを projectsend.org から1日1回取得します。オフにすると、このインストールはお知らせのために projectsend.org へ接続しなくなります。",
"Announcement": "お知らせ",
"More": "その他"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Waarom het zo staat ingesteld", "Why it is set this way": "Waarom het zo staat ingesteld",
"Why that is worth changing": "Waarom het de moeite waard is dit te veranderen", "Why that is worth changing": "Waarom het de moeite waard is dit te veranderen",
"Why this matters, and how to change it": "Waarom dit uitmaakt, en hoe je het verandert", "Why this matters, and how to change it": "Waarom dit uitmaakt, en hoe je het verandert",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Je bestanden staan buiten de webroot, dus elke download gaat eerst langs ProjectSend om te controleren of de persoon het bestand mag hebben. Na die controle opent PHP het bestand en verstuurt het. Het alternatief is dat PHP tegen je webserver zegt “stuur dit bestand” en meteen klaar is." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Je bestanden staan buiten de webroot, dus elke download gaat eerst langs ProjectSend om te controleren of de persoon het bestand mag hebben. Na die controle opent PHP het bestand en verstuurt het. Het alternatief is dat PHP tegen je webserver zegt “stuur dit bestand” en meteen klaar is.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" is een openbare map — iedereen kan dit bestand dan zonder in te loggen openen.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" wordt verwijderd, samen met de toegang van iedereen tot dit bestand. Een beheerder kan dit ongedaan maken.",
"Anyone with the link will be able to open and download it, without signing in.": "Iedereen met de link kan het openen en downloaden, zonder in te loggen.",
"Back to my files": "Terug naar mijn bestanden",
"Edit file": "Bestand bewerken",
"Expires on": "Verloopt op",
"Make this file public": "Dit bestand openbaar maken",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Deze site heeft nog geen openbare pagina, dus er is niets zichtbaar totdat een beheerder er een instelt.",
"Show ProjectSend news on the dashboard": "ProjectSend-nieuws op het overzicht tonen",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Haalt eens per dag projectaankondigingen op van projectsend.org voor de kaart op het overzicht. Uitgeschakeld neemt deze installatie voor nieuws helemaal geen contact meer op met projectsend.org.",
"Announcement": "Mededeling",
"More": "Meer"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Dlaczego jest tak ustawione", "Why it is set this way": "Dlaczego jest tak ustawione",
"Why that is worth changing": "Dlaczego warto to zmienić", "Why that is worth changing": "Dlaczego warto to zmienić",
"Why this matters, and how to change it": "Dlaczego to ma znaczenie i jak to zmienić", "Why this matters, and how to change it": "Dlaczego to ma znaczenie i jak to zmienić",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Twoje pliki są przechowywane poza katalogiem publicznym, więc każde pobranie przechodzi najpierw przez ProjectSend, który sprawdza, czy dana osoba ma do niego prawo. Po tym sprawdzeniu PHP otwiera plik i go wysyła. Alternatywą jest, by PHP powiedziało serwerowi WWW „wyślij ten plik” i od razu zakończyło pracę." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Twoje pliki są przechowywane poza katalogiem publicznym, więc każde pobranie przechodzi najpierw przez ProjectSend, który sprawdza, czy dana osoba ma do niego prawo. Po tym sprawdzeniu PHP otwiera plik i go wysyła. Alternatywą jest, by PHP powiedziało serwerowi WWW „wyślij ten plik” i od razu zakończyło pracę.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" to folder publiczny — każdy będzie mógł otworzyć ten plik bez logowania.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" zostanie usunięty, razem z dostępem wszystkich osób do niego. Administrator może to cofnąć.",
"Anyone with the link will be able to open and download it, without signing in.": "Każdy, kto ma link, będzie mógł go otworzyć i pobrać bez logowania.",
"Back to my files": "Wróć do moich plików",
"Edit file": "Edytuj plik",
"Expires on": "Wygasa",
"Make this file public": "Ustaw ten plik jako publiczny",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ta witryna nie ma jeszcze skonfigurowanej strony publicznej, więc nic nie będzie widoczne, dopóki administrator jej nie ustawi.",
"Show ProjectSend news on the dashboard": "Pokazuj aktualności ProjectSend na pulpicie",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Raz dziennie pobiera ogłoszenia projektu z projectsend.org na kartę pulpitu. Po wyłączeniu ta instalacja w ogóle przestaje łączyć się z projectsend.org po aktualności.",
"Announcement": "Ogłoszenie",
"More": "Więcej"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Por que está assim", "Why it is set this way": "Por que está assim",
"Why that is worth changing": "Por que vale a pena mudar", "Why that is worth changing": "Por que vale a pena mudar",
"Why this matters, and how to change it": "Por que isso importa e como mudar", "Why this matters, and how to change it": "Por que isso importa e como mudar",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Seus arquivos ficam fora da raiz web, então todo download passa primeiro pelo ProjectSend, que confere se a pessoa pode recebê-lo. Depois dessa checagem, o PHP abre o arquivo e o envia. A alternativa é o PHP dizer ao seu servidor web “envie este arquivo” e terminar na hora." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Seus arquivos ficam fora da raiz web, então todo download passa primeiro pelo ProjectSend, que confere se a pessoa pode recebê-lo. Depois dessa checagem, o PHP abre o arquivo e o envia. A alternativa é o PHP dizer ao seu servidor web “envie este arquivo” e terminar na hora.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" é uma pasta pública: qualquer pessoa poderá abrir este arquivo sem entrar na conta.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" será excluído, junto com o acesso de todos a ele. Um administrador pode desfazer isso.",
"Anyone with the link will be able to open and download it, without signing in.": "Qualquer pessoa com o link poderá abri-lo e baixá-lo sem entrar na conta.",
"Back to my files": "Voltar para meus arquivos",
"Edit file": "Editar arquivo",
"Expires on": "Expira em",
"Make this file public": "Tornar este arquivo público",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este site ainda não tem uma página pública configurada, então nada ficará visível até que um administrador configure uma.",
"Show ProjectSend news on the dashboard": "Mostrar novidades do ProjectSend no painel",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Busca os anúncios do projeto em projectsend.org uma vez por dia para o cartão do painel. Se você desativar, esta instalação para de contatar o projectsend.org por novidades.",
"Announcement": "Aviso",
"More": "Mais"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Почему выбран этот способ", "Why it is set this way": "Почему выбран этот способ",
"Why that is worth changing": "Почему это стоит изменить", "Why that is worth changing": "Почему это стоит изменить",
"Why this matters, and how to change it": "Почему это важно и как это изменить", "Why this matters, and how to change it": "Почему это важно и как это изменить",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Ваши файлы хранятся вне корня сайта, поэтому каждая загрузка сначала проходит через ProjectSend, который проверяет, имеет ли человек на неё право. После проверки PHP открывает файл и отправляет его. Другой вариант — PHP говорит веб-серверу «отправь этот файл» и сразу завершает работу." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Ваши файлы хранятся вне корня сайта, поэтому каждая загрузка сначала проходит через ProjectSend, который проверяет, имеет ли человек на неё право. После проверки PHP открывает файл и отправляет его. Другой вариант — PHP говорит веб-серверу «отправь этот файл» и сразу завершает работу.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "«:name» — публичная папка: любой сможет открыть этот файл без входа в систему.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "«:name» будет удалён вместе с доступом к нему у всех остальных. Администратор может это отменить.",
"Anyone with the link will be able to open and download it, without signing in.": "Любой, у кого есть ссылка, сможет открыть и скачать файл без входа в систему.",
"Back to my files": "Назад к моим файлам",
"Edit file": "Редактировать файл",
"Expires on": "Срок действия до",
"Make this file public": "Сделать этот файл публичным",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит.",
"Show ProjectSend news on the dashboard": "Показывать новости ProjectSend на панели",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Раз в день загружает анонсы проекта с projectsend.org для карточки на панели. Если выключить, эта установка вообще перестанет обращаться к projectsend.org за новостями.",
"Announcement": "Объявление",
"More": "Ещё"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Kwa nini imewekwa hivi", "Why it is set this way": "Kwa nini imewekwa hivi",
"Why that is worth changing": "Kwa nini inafaa kubadilishwa", "Why that is worth changing": "Kwa nini inafaa kubadilishwa",
"Why this matters, and how to change it": "Kwa nini hili ni muhimu, na jinsi ya kulibadilisha", "Why this matters, and how to change it": "Kwa nini hili ni muhimu, na jinsi ya kulibadilisha",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Faili zako huhifadhiwa nje ya mzizi wa wavuti, kwa hivyo kila upakuaji hupitia ProjectSend kwanza ili kuthibitisha kuwa mtu anayeomba anaruhusiwa kuipata. Baada ya ukaguzi huo, PHP hufungua faili na kuituma. Njia mbadala ni PHP kuiambia seva yako ya wavuti “tuma faili hii” kisha imalize mara moja." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Faili zako huhifadhiwa nje ya mzizi wa wavuti, kwa hivyo kila upakuaji hupitia ProjectSend kwanza ili kuthibitisha kuwa mtu anayeomba anaruhusiwa kuipata. Baada ya ukaguzi huo, PHP hufungua faili na kuituma. Njia mbadala ni PHP kuiambia seva yako ya wavuti “tuma faili hii” kisha imalize mara moja.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" ni folda ya umma — mtu yeyote ataweza kufungua faili hili bila kuingia.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" litafutwa, pamoja na ufikiaji wa kila mtu kwake. Msimamizi anaweza kutendua hatua hii.",
"Anyone with the link will be able to open and download it, without signing in.": "Mtu yeyote mwenye kiungo ataweza kulifungua na kulipakua bila kuingia.",
"Back to my files": "Rudi kwenye mafaili yangu",
"Edit file": "Hariri faili",
"Expires on": "Litaisha muda tarehe",
"Make this file public": "Fanya faili hili liwe la umma",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tovuti hii bado haina ukurasa wa umma, kwa hivyo hakuna kitakachoonekana hadi msimamizi aweke mmoja.",
"Show ProjectSend news on the dashboard": "Onyesha habari za ProjectSend kwenye dashibodi",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Huleta matangazo ya mradi kutoka projectsend.org mara moja kwa siku kwa ajili ya kadi ya dashibodi. Ukiizima, usakinishaji huu hautawasiliana kabisa na projectsend.org kwa habari.",
"Announcement": "Tangazo",
"More": "Zaidi"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Neden böyle ayarlı", "Why it is set this way": "Neden böyle ayarlı",
"Why that is worth changing": "Değiştirmeye neden değer", "Why that is worth changing": "Değiştirmeye neden değer",
"Why this matters, and how to change it": "Bu neden önemli ve nasıl değiştirilir", "Why this matters, and how to change it": "Bu neden önemli ve nasıl değiştirilir",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Dosyaların web kök dizininin dışında saklanır; bu yüzden her indirme önce ProjectSend'den geçer ve isteyen kişinin o dosyayı alma hakkı olup olmadığı denetlenir. Bu denetimden sonra dosyayı PHP açıp gönderir. Diğer seçenek, PHP'nin web sunucuna “bu dosyayı gönder” deyip hemen işini bitirmesidir." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Dosyaların web kök dizininin dışında saklanır; bu yüzden her indirme önce ProjectSend'den geçer ve isteyen kişinin o dosyayı alma hakkı olup olmadığı denetlenir. Bu denetimden sonra dosyayı PHP açıp gönderir. Diğer seçenek, PHP'nin web sunucuna “bu dosyayı gönder” deyip hemen işini bitirmesidir.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "\":name\" herkese açık bir klasör — bu dosyayı oturum açmadan herkes açabilecek.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "\":name\" silinecek, herkesin ona erişimiyle birlikte. Bir yönetici bunu geri alabilir.",
"Anyone with the link will be able to open and download it, without signing in.": "Bağlantıya sahip herkes oturum açmadan dosyayı açabilir ve indirebilir.",
"Back to my files": "Dosyalarıma dön",
"Edit file": "Dosyayı düzenle",
"Expires on": "Sona erme tarihi",
"Make this file public": "Bu dosyayı herkese açık yap",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Bu sitede henüz herkese açık bir sayfa ayarlanmamış, bu yüzden bir yönetici ayarlayana kadar hiçbir şey görünmeyecek.",
"Show ProjectSend news on the dashboard": "ProjectSend haberlerini panelde göster",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Panel kartı için proje duyurularını günde bir kez projectsend.org adresinden alır. Kapatıldığında bu kurulum haberler için projectsend.org ile hiç bağlantı kurmaz.",
"Announcement": "Duyuru",
"More": "Daha fazla"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "Vì sao lại được đặt như vậy", "Why it is set this way": "Vì sao lại được đặt như vậy",
"Why that is worth changing": "Vì sao nên thay đổi", "Why that is worth changing": "Vì sao nên thay đổi",
"Why this matters, and how to change it": "Vì sao điều này quan trọng và cách thay đổi", "Why this matters, and how to change it": "Vì sao điều này quan trọng và cách thay đổi",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tệp của bạn được lưu ngoài thư mục gốc web, nên mỗi lượt tải xuống đều đi qua ProjectSend trước để kiểm tra người yêu cầu có quyền nhận tệp hay không. Sau bước kiểm tra đó, PHP mở tệp và gửi đi. Cách còn lại là PHP báo cho máy chủ web «gửi tệp này» rồi kết thúc ngay." "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tệp của bạn được lưu ngoài thư mục gốc web, nên mỗi lượt tải xuống đều đi qua ProjectSend trước để kiểm tra người yêu cầu có quyền nhận tệp hay không. Sau bước kiểm tra đó, PHP mở tệp và gửi đi. Cách còn lại là PHP báo cho máy chủ web «gửi tệp này» rồi kết thúc ngay.",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "“:name” là thư mục công khai — bất kỳ ai cũng sẽ mở được tệp này mà không cần đăng nhập.",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "“:name” sẽ bị xóa, cùng với quyền truy cập của mọi người vào tệp. Quản trị viên có thể hoàn tác việc này.",
"Anyone with the link will be able to open and download it, without signing in.": "Bất kỳ ai có liên kết đều có thể mở và tải tệp xuống mà không cần đăng nhập.",
"Back to my files": "Quay lại tệp của tôi",
"Edit file": "Sửa tệp",
"Expires on": "Hết hạn vào",
"Make this file public": "Công khai tệp này",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Trang web này chưa thiết lập trang công khai, nên sẽ không có gì hiển thị cho đến khi quản trị viên thiết lập.",
"Show ProjectSend news on the dashboard": "Hiển thị tin tức ProjectSend trên bảng điều khiển",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Tải thông báo của dự án từ projectsend.org mỗi ngày một lần cho thẻ trên bảng điều khiển. Khi tắt, bản cài đặt này sẽ hoàn toàn không liên hệ với projectsend.org để lấy tin tức.",
"Announcement": "Thông báo",
"More": "Thêm"
} }
+13 -1
View File
@@ -2005,5 +2005,17 @@
"Why it is set this way": "为什么是这样设置的", "Why it is set this way": "为什么是这样设置的",
"Why that is worth changing": "为什么值得更改", "Why that is worth changing": "为什么值得更改",
"Why this matters, and how to change it": "为什么这很重要,以及如何更改", "Why this matters, and how to change it": "为什么这很重要,以及如何更改",
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "你的文件存放在 Web 根目录之外,因此每次下载都会先经过 ProjectSend,确认请求者有权获取该文件。通过检查之后,由 PHP 打开文件并发送。另一种方式是 PHP 告诉 Web 服务器“发送这个文件”,然后立即结束。" "Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "你的文件存放在 Web 根目录之外,因此每次下载都会先经过 ProjectSend,确认请求者有权获取该文件。通过检查之后,由 PHP 打开文件并发送。另一种方式是 PHP 告诉 Web 服务器“发送这个文件”,然后立即结束。",
"\":name\" is a public folder — anyone will be able to open this file, without signing in.": "“:name”是公开文件夹——任何人无需登录即可打开此文件。",
"\":name\" will be deleted, along with everyone's access to it. This can be undone by an administrator.": "“:name”将被删除,所有人对它的访问权限也会一并移除。管理员可以撤销此操作。",
"Anyone with the link will be able to open and download it, without signing in.": "任何拿到链接的人都可以无需登录即可打开并下载。",
"Back to my files": "返回我的文件",
"Edit file": "编辑文件",
"Expires on": "到期日",
"Make this file public": "将此文件设为公开",
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。",
"Show ProjectSend news on the dashboard": "在仪表板上显示 ProjectSend 动态",
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "每天一次从 projectsend.org 获取项目公告,用于仪表板卡片。关闭后,本安装将完全不再因动态而连接 projectsend.org。",
"Announcement": "公告",
"More": "更多"
} }
+27 -26
View File
@@ -4,6 +4,7 @@
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cloud",
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11", "@codemirror/lang-html": "^6.4.11",
@@ -1190,41 +1191,41 @@
} }
}, },
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": { "engines": {
"node": ">=18.18.0" "node": ">=18.18.0"
} }
}, },
"node_modules/@humanfs/node": { "node_modules/@humanfs/node": {
"version": "0.16.6", "version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@humanfs/core": "^0.19.1", "@humanfs/core": "^0.19.2",
"@humanwhocodes/retry": "^0.3.0" "@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
}, },
"engines": { "engines": {
"node": ">=18.18.0" "node": ">=18.18.0"
} }
}, },
"node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { "node_modules/@humanfs/types": {
"version": "0.3.1", "version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=18.18" "node": ">=18.18.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@humanwhocodes/module-importer": { "node_modules/@humanwhocodes/module-importer": {
@@ -4057,9 +4058,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6688,9 +6689,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.17", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -7220,9 +7221,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.15.3", "version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"es-define-property": "^1.0.1", "es-define-property": "^1.0.1",
+114
View File
@@ -0,0 +1,114 @@
import { type SharedData } from '@/types';
import { usePage } from '@inertiajs/react';
import { ExternalLink, Megaphone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { useTranslation } from '@/hooks/use-translation';
export interface Announcement {
title: string;
body: string;
action_label: string | null;
action_url: string | null;
tone: 'info' | 'warning' | string;
}
/**
* One message, shown two ways, from one shared prop — see
* ResolvingAnnouncement. The band is the dashboard; the icon beside the
* notification bell carries the same words to every other page, so
* somebody who never opens the dashboard still meets it once.
*
* Both live in this file deliberately. They have to say the same thing,
* and the reliable way to keep two renderings of one message identical is
* for them to share the component that renders it.
*
* Nothing here knows what it is saying. Title, body and button all arrive
* from whatever listened.
*
* Coloured enough to be read and not enough to alarm: a tinted left edge
* and a matching wash, rather than a saturated block. The first caller
* tells a hosted customer their plan has limits and a bigger one exists,
* which is worth noticing and not worth interrupting for — so it must not
* look like an outage. Both palettes are declared per tone rather than
* derived, so the dark variant is a deliberate colour rather than
* whatever the light one happens to become.
*/
const TONES: Record<string, string> = {
info: 'border-l-sky-500 bg-sky-50 dark:bg-sky-950/40',
warning: 'border-l-amber-500 bg-amber-50 dark:bg-amber-950/40',
};
const DOT_TONES: Record<string, string> = {
info: 'bg-sky-500',
warning: 'bg-amber-500',
};
/** The words, and the button if there is one. Shared by both surfaces. */
function AnnouncementBody({ announcement, compact = false }: { announcement: Announcement; compact?: boolean }) {
return (
<>
<div className="min-w-0">
<p className="text-sm font-semibold">{announcement.title}</p>
<p className="text-muted-foreground mt-1 text-sm">{announcement.body}</p>
</div>
{announcement.action_label && announcement.action_url && (
<Button asChild variant="outline" size="sm" className={compact ? 'w-full bg-transparent' : 'shrink-0 bg-transparent'}>
{/* Always a new tab: the destination is outside this
installation, and taking somebody out of the app
they are working in is not what this should do. */}
<a href={announcement.action_url} target="_blank" rel="noopener noreferrer">
{announcement.action_label}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</>
);
}
/** The dashboard band. */
export function AnnouncementBand({ announcement }: { announcement: Announcement }) {
const tone = TONES[announcement.tone] ?? TONES.info;
return (
<div className={`mb-6 flex flex-col gap-3 rounded-lg border border-l-4 p-4 sm:flex-row sm:items-center sm:justify-between ${tone}`}>
<AnnouncementBody announcement={announcement} />
</div>
);
}
/**
* The header icon, beside the notification bell.
*
* Same shape as UpdateAvailableIcon next to it: absent entirely when
* there is nothing to say, rather than a dead control. The dot marks it
* without a count — there is only ever one of these, and "1" on a badge
* would invite somebody to look for the second.
*/
export function AnnouncementIcon() {
const { t } = useTranslation();
const { announcement } = usePage<SharedData>().props;
if (!announcement) {
return null;
}
const dot = DOT_TONES[announcement.tone] ?? DOT_TONES.info;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative" aria-label={announcement.title || t('Announcement')}>
<Megaphone className="size-5" />
<span className={`absolute top-0.5 right-0.5 size-2.5 rounded-full ${dot}`} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="flex w-80 flex-col gap-3 p-4">
<AnnouncementBody announcement={announcement} compact />
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,3 +1,4 @@
import { AnnouncementIcon } from '@/components/announcement';
import { AppearanceSwitcher } from '@/components/appearance-switcher'; import { AppearanceSwitcher } from '@/components/appearance-switcher';
import { Breadcrumbs } from '@/components/breadcrumbs'; import { Breadcrumbs } from '@/components/breadcrumbs';
import { IconLocaleSwitcher } from '@/components/locale-switcher'; import { IconLocaleSwitcher } from '@/components/locale-switcher';
@@ -15,6 +16,7 @@ export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: Breadcrum
</div> </div>
<div className="ml-auto flex items-center gap-1"> <div className="ml-auto flex items-center gap-1">
<UpdateAvailableIcon /> <UpdateAvailableIcon />
<AnnouncementIcon />
<NotificationBell /> <NotificationBell />
<AppearanceSwitcher /> <AppearanceSwitcher />
<IconLocaleSwitcher /> <IconLocaleSwitcher />
+19 -2
View File
@@ -32,7 +32,7 @@ import AppLogo from './app-logo';
export function AppSidebar() { export function AppSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
const { auth, capabilities, pending, version } = usePage<SharedData>().props; const { auth, capabilities, extra_nav_links, pending, version } = usePage<SharedData>().props;
// Modules (Files, Clients, Groups…) add their own groups here as // Modules (Files, Clients, Groups…) add their own groups here as
// they land, mirroring v1's grouped admin menu. // they land, mirroring v1's grouped admin menu.
@@ -226,7 +226,7 @@ export function AppSidebar() {
{ title: t('Security'), url: '/system/settings/security', when: settings }, { title: t('Security'), url: '/system/settings/security', when: settings },
{ title: t('LDAP'), url: '/system/settings/ldap', when: settings }, { title: t('LDAP'), url: '/system/settings/ldap', when: settings },
{ title: t('Social login'), url: '/system/settings/social-login', when: settings }, { title: t('Social login'), url: '/system/settings/social-login', when: settings },
{ title: t('CAPTCHA'), url: '/system/settings/captcha', when: settings }, { title: t('CAPTCHA'), url: '/system/settings/captcha', when: settings && capabilities.includes('captcha.configure') },
// Files: where they land, how long they stay, who can see them. // Files: where they land, how long they stay, who can see them.
{ title: t('Uploads'), url: '/system/settings/uploads', when: settings }, { title: t('Uploads'), url: '/system/settings/uploads', when: settings },
@@ -262,6 +262,23 @@ export function AppSidebar() {
}); });
} }
// Contributed by whatever is installed — see ResolvingNavigationLinks.
// Their own group at the end rather than mixed into Administration:
// these leave the installation, and a link that takes somebody out of
// the app should not sit between two that do not. Empty on every
// installation with nothing listening, and the group disappears with
// it rather than rendering a heading over nothing.
if (extra_nav_links.length > 0) {
groups.push({
title: t('More'),
items: extra_nav_links.map((link) => ({
title: link.title,
url: link.url,
external: link.external,
})),
});
}
return ( return (
<Sidebar collapsible="icon" variant="inset"> <Sidebar collapsible="icon" variant="inset">
<SidebarHeader> <SidebarHeader>
+26 -6
View File
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { type NavGroup } from '@/types'; import { type NavGroup } from '@/types';
import { Link, usePage } from '@inertiajs/react'; import { Link, usePage } from '@inertiajs/react';
import { ChevronRight } from 'lucide-react'; import { ChevronRight, ExternalLink } from 'lucide-react';
export function NavMain({ groups = [] }: { groups: NavGroup[] }) { export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
const page = usePage(); const page = usePage();
@@ -58,11 +58,31 @@ export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
</Collapsible> </Collapsible>
) : ( ) : (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive(item.url)} tooltip={item.title}> <SidebarMenuButton
<Link href={item.url ?? '#'}> asChild
{item.icon && <item.icon />} isActive={item.external ? false : isActive(item.url)}
<span>{item.title}</span> tooltip={item.title}
</Link> >
{/* An external destination is a plain
anchor, 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 "active" — nothing
outside this app is the page you
are on. */}
{item.external ? (
<a href={item.url ?? '#'} target="_blank" rel="noopener noreferrer">
{item.icon && <item.icon />}
<span>{item.title}</span>
<ExternalLink className="ml-auto size-3.5 opacity-60" />
</a>
) : (
<Link href={item.url ?? '#'}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
)}
</SidebarMenuButton> </SidebarMenuButton>
{item.badge !== undefined && item.badge > 0 && ( {item.badge !== undefined && item.badge > 0 && (
<SidebarMenuBadge className="bg-primary text-primary-foreground peer-hover/menu-button:text-primary-foreground peer-data-[active=true]/menu-button:text-primary-foreground rounded-full"> <SidebarMenuBadge className="bg-primary text-primary-foreground peer-hover/menu-button:text-primary-foreground peer-data-[active=true]/menu-button:text-primary-foreground rounded-full">
@@ -0,0 +1,59 @@
import { Link, router } from '@inertiajs/react';
import { Pencil, X } from 'lucide-react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/use-translation';
import { type FileRow } from '@/types/portal';
interface FileRowActionsProps {
file: FileRow;
size?: 'default' | 'sm' | 'icon';
}
/**
* The edit and delete controls that sit on a file row.
*
* The file twin of FolderRowActions, and gated the same way: on the row's
* own `can_update`/`can_delete`, which the server decides per file. A
* client manages what they uploaded and not what was shared with them, and
* both kinds sit in the same list — so this is never `is_mine`, which
* answers only half the question. The role's keys are the other half.
*
* Deliberately no props for the handlers. Renaming a folder is a one-field
* dialog and each theme owns its own; a file has eight fields behind five
* separate permissions, so it gets a page (portal/edit-file.tsx) that every
* theme shares rather than a form each theme would have to carry.
*/
export function FileRowActions({ file, size = 'sm' }: FileRowActionsProps) {
const { t } = useTranslation();
return (
<>
{file.can_update && (
<Button variant="ghost" size={size} asChild>
<Link href={route('my-files.edit', file.id)}>
<Pencil className="size-4" />
<span className="sr-only">{t('Edit')}</span>
</Link>
</Button>
)}
{file.can_delete && (
<ConfirmDialog
trigger={
<Button variant="ghost" size={size} className="text-destructive hover:text-destructive">
<X className="size-4" />
<span className="sr-only">{t('Delete')}</span>
</Button>
}
title={t('Delete file?')}
description={t('":name" will be deleted, along with everyone\'s access to it. This can be undone by an administrator.', {
name: file.name,
})}
confirmLabel={t('Delete file')}
onConfirm={() => router.delete(route('my-files.destroy', file.id))}
/>
)}
</>
);
}
+8 -1
View File
@@ -28,6 +28,7 @@ import { TopClientsWidget, type TopClient } from '@/components/dashboard-widgets
import { TransfersRangeControls, TransfersWidget, type TransferPoint, type TransfersRange } from '@/components/dashboard-widgets/transfers-widget'; import { TransfersRangeControls, TransfersWidget, type TransferPoint, type TransfersRange } from '@/components/dashboard-widgets/transfers-widget';
import { WidgetBox } from '@/components/dashboard-widgets/widget-box'; import { WidgetBox } from '@/components/dashboard-widgets/widget-box';
import { WidgetsDialog } from '@/components/dashboard-widgets/widgets-dialog'; import { WidgetsDialog } from '@/components/dashboard-widgets/widgets-dialog';
import { AnnouncementBand } from '@/components/announcement';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -110,7 +111,7 @@ export default function Dashboard({
dashboard_columns, dashboard_columns,
}: DashboardProps) { }: DashboardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { update_notice } = usePage<SharedData>().props; const { announcement, update_notice } = usePage<SharedData>().props;
const [releaseDialogOpen, setReleaseDialogOpen] = useState(false); const [releaseDialogOpen, setReleaseDialogOpen] = useState(false);
const [widgetsDialogOpen, setWidgetsDialogOpen] = useState(false); const [widgetsDialogOpen, setWidgetsDialogOpen] = useState(false);
const [layout, setLayout] = useState<WidgetLayout>(widget_layout); const [layout, setLayout] = useState<WidgetLayout>(widget_layout);
@@ -336,6 +337,12 @@ export default function Dashboard({
<Head title={t('Dashboard')} /> <Head title={t('Dashboard')} />
<div className="space-y-6 px-4 py-6"> <div className="space-y-6 px-4 py-6">
{/* Above the heading, not inside the grid: whoever asked
for this wants it read, and the grid is arranged by
each viewer. Read from shared props, the same source
the header icon uses, so the two cannot disagree. */}
{announcement && <AnnouncementBand announcement={announcement} />}
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<Heading title={t('Dashboard')} description={t('An overview of this installation')} /> <Heading title={t('Dashboard')} description={t('An overview of this installation')} />
<Button variant="outline" size="sm" onClick={() => setWidgetsDialogOpen(true)}> <Button variant="outline" size="sm" onClick={() => setWidgetsDialogOpen(true)}>
+341
View File
@@ -0,0 +1,341 @@
import { Head, Link, router, useForm } from '@inertiajs/react';
import { ArrowLeft, Globe } from 'lucide-react';
import { type FormEventHandler } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
import PortalLayout from '@/layouts/portal-layout';
import { categoryColor } from '@/lib/category-colors';
import { type BreadcrumbItem } from '@/types';
import { type CategoryTag } from '@/types/portal';
interface FolderOption {
id: number;
name: string;
/** Files in here are readable by anyone, with or without the public switch. */
public: boolean;
}
interface PortalEditFileProps {
theme: string;
file: {
id: number;
name: string;
description: string | null;
original_name: string;
size: number;
public: boolean;
commentable: boolean;
expires_at: string | null;
download_limit: number | null;
download_limit_scope: string;
folder_id: number | null;
categories: number[];
};
can_delete: boolean;
can_publish: boolean;
can_set_expiration: boolean;
can_set_categories: boolean;
can_limit_downloads: boolean;
can_set_commentable: boolean;
categories: CategoryTag[];
folders: FolderOption[];
public_listing_slug: string | null;
}
/**
* The client's editor for a file they uploaded.
*
* One page for every theme, like portal/upload.tsx and for the same
* reason — a form rebuilt per theme is four places for a field to go
* missing. Only the shell differs, and the `theme` prop picks it.
*
* Every field here is behind the same permission the server will check
* when this posts. Hiding a control the client cannot use is a courtesy,
* not the enforcement: ApplyFileEdits leaves an ungranted field exactly as
* it was regardless of what arrives.
*/
export default function PortalEditFile({
theme,
file,
can_delete,
can_publish,
can_set_expiration,
can_set_categories,
can_limit_downloads,
can_set_commentable,
categories,
folders,
public_listing_slug,
}: PortalEditFileProps) {
const { t } = useTranslation();
const form = useForm({
name: file.name,
description: file.description ?? '',
folder_id: file.folder_id === null ? 'root' : String(file.folder_id),
public: file.public,
commentable: file.commentable,
expires_at: file.expires_at ?? '',
download_limit: file.download_limit === null ? '' : String(file.download_limit),
download_limit_scope: file.download_limit_scope,
categories: file.categories,
});
const { data, setData, processing, errors, recentlySuccessful } = form;
const submit: FormEventHandler = (e) => {
e.preventDefault();
// 'root' means no folder, and an empty box means no limit — the
// same transforms the staff editor makes, because the server reads
// one payload shape from both.
form.transform((payload) => ({
...payload,
folder_id: payload.folder_id === 'root' ? null : payload.folder_id,
expires_at: payload.expires_at || null,
download_limit: payload.download_limit === '' ? null : Number(payload.download_limit),
}));
form.patch(route('my-files.update', file.id), { preserveScroll: true });
};
const unassigned = categories.filter((category) => !data.categories.includes(category.id));
const selectedFolder = folders.find((folder) => String(folder.id) === data.folder_id) ?? null;
const content = (
<>
<Head title={t('Edit :name', { name: file.name })} />
<div className="px-4 py-6">
<Heading title={t('Edit file')} description={file.original_name} />
<Button variant="ghost" size="sm" asChild className="mb-4 -ml-2">
<Link href={route('my-files.index')}>
<ArrowLeft className="size-4" />
{t('Back to my files')}
</Link>
</Button>
<form onSubmit={submit} className="grid max-w-2xl gap-6">
<div className="grid gap-2">
<Label htmlFor="name">{t('Name')}</Label>
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} required />
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="description">{t('Description')}</Label>
<Textarea id="description" value={data.description} onChange={(e) => setData('description', e.target.value)} rows={3} />
<InputError message={errors.description} />
</div>
{folders.length > 0 && (
<div className="grid gap-2">
<Label htmlFor="folder_id">{t('Folder')}</Label>
<Select value={data.folder_id} onValueChange={(value) => setData('folder_id', value)}>
<SelectTrigger id="folder_id">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="root">{t('No folder')}</SelectItem>
{folders.map((folder) => (
<SelectItem key={folder.id} value={String(folder.id)}>
<span className="flex items-center gap-1.5">
{folder.name}
{folder.public && <Globe className="text-muted-foreground size-3.5 shrink-0" />}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<InputError message={errors.folder_id} />
{/* Moving into a public folder publishes the file
whether or not the public switch below is even
offered — so the warning belongs here, next to
the choice, not only next to that switch. */}
{selectedFolder?.public && (
<p className="text-muted-foreground text-xs">
{t('":name" is a public folder — anyone will be able to open this file, without signing in.', {
name: selectedFolder.name,
})}
</p>
)}
</div>
)}
{can_set_expiration && (
<div className="grid gap-2">
<Label htmlFor="expires_at">{t('Expires on')}</Label>
<Input id="expires_at" type="date" value={data.expires_at} onChange={(e) => setData('expires_at', e.target.value)} />
<InputError message={errors.expires_at} />
<p className="text-muted-foreground text-xs">{t('Leave empty for a file that never expires.')}</p>
</div>
)}
{can_limit_downloads && (
<div className="grid gap-2">
<Label htmlFor="download_limit">{t('Download limit')}</Label>
<Input
id="download_limit"
type="number"
min={1}
value={data.download_limit}
onChange={(e) => setData('download_limit', e.target.value)}
/>
<InputError message={errors.download_limit} />
{data.download_limit !== '' && (
<Select value={data.download_limit_scope} onValueChange={(value) => setData('download_limit_scope', value)}>
<SelectTrigger id="download_limit_scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="total">{t('In total, across everyone')}</SelectItem>
<SelectItem value="per_user">{t('Each person separately')}</SelectItem>
</SelectContent>
</Select>
)}
<p className="text-muted-foreground text-xs">
{t('Leave empty for a file that can be downloaded any number of times.')}
</p>
</div>
)}
{can_set_categories && (
<div className="grid gap-2">
<Label>{t('Categories')}</Label>
{data.categories.length > 0 && (
<div className="flex flex-wrap gap-1">
{data.categories.map((id) => {
const category = categories.find((c) => c.id === id);
return (
<button
key={id}
type="button"
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm ${category ? categoryColor(category.color).badge : 'bg-muted'}`}
onClick={() =>
setData(
'categories',
data.categories.filter((current) => current !== id),
)
}
>
{category?.name ?? id}
<span aria-hidden>×</span>
<span className="sr-only">{t('Remove')}</span>
</button>
);
})}
</div>
)}
{unassigned.length > 0 && (
<Select value="" onValueChange={(value) => setData('categories', [...data.categories, Number(value)])}>
<SelectTrigger>
<SelectValue placeholder={t('Add a category')} />
</SelectTrigger>
<SelectContent>
{unassigned.map((category) => (
<SelectItem key={category.id} value={String(category.id)}>
<span className="flex items-center gap-2">
<span className={`size-2 shrink-0 rounded-full ${categoryColor(category.color).swatch}`} />
{category.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{can_set_commentable && (
<div className="flex items-start gap-3">
<Checkbox
id="commentable"
checked={data.commentable}
onCheckedChange={(checked) => setData('commentable', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="commentable">{t('Allow comments on this file')}</Label>
</div>
</div>
)}
{can_publish && (
<div className="flex items-start gap-3">
<Checkbox
id="public"
checked={data.public}
onCheckedChange={(checked) => setData('public', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="public" className="flex items-center gap-1.5">
<Globe className="size-4" />
{t('Make this file public')}
</Label>
{/* Said plainly, because it is the one switch
here that reaches past the people this
file was shared with. */}
<p className="text-muted-foreground text-xs">
{public_listing_slug
? t('Anyone with the link will be able to open and download it, without signing in.')
: t('This site has no public page set up yet, so nothing will be visible until an administrator sets one.')}
</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<Button type="submit" disabled={processing}>
{t('Save')}
</Button>
{recentlySuccessful && <p className="text-muted-foreground text-sm">{t('Saved.')}</p>}
{can_delete && (
<ConfirmDialog
trigger={
<Button type="button" variant="ghost" className="text-destructive hover:text-destructive ml-auto">
{t('Delete')}
</Button>
}
title={t('Delete file?')}
description={t('":name" will be deleted, along with everyone\'s access to it. This can be undone by an administrator.', {
name: file.name,
})}
confirmLabel={t('Delete file')}
onConfirm={() => router.delete(route('my-files.destroy', file.id))}
/>
)}
</div>
</form>
</div>
</>
);
// Same shell dispatch as portal/upload.tsx: the "default" theme uses
// the app's own sidebar, every other portal theme uses PortalLayout.
if (theme === 'default') {
const breadcrumbs: BreadcrumbItem[] = [
{ title: t('My files'), href: '/my-files' },
{ title: file.name, href: route('my-files.edit', file.id) },
];
return <AppLayout breadcrumbs={breadcrumbs}>{content}</AppLayout>;
}
return <PortalLayout>{content}</PortalLayout>;
}
@@ -9,6 +9,7 @@ import { CategoryBadges } from '@/components/files/category-badges';
import { VersionBadge } from '@/components/files/version-badge'; import { VersionBadge } from '@/components/files/version-badge';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import { Pagination } from '@/components/pagination'; import { Pagination } from '@/components/pagination';
import { FileRowActions } from '@/components/portal/file-row-actions';
import { FolderRowActions } from '@/components/portal/folder-row-actions'; import { FolderRowActions } from '@/components/portal/folder-row-actions';
import { NewFolderButton } from '@/components/portal/new-folder-button'; import { NewFolderButton } from '@/components/portal/new-folder-button';
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb'; import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
@@ -257,6 +258,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
iconClassName="size-3.5" iconClassName="size-3.5"
iconOnly iconOnly
/> />
<FileRowActions file={file} />
</div> </div>
</td> </td>
</tr> </tr>
@@ -10,6 +10,7 @@ import { CategoryBadges } from '@/components/files/category-badges';
import { VersionBadge } from '@/components/files/version-badge'; import { VersionBadge } from '@/components/files/version-badge';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import { Pagination } from '@/components/pagination'; import { Pagination } from '@/components/pagination';
import { FileRowActions } from '@/components/portal/file-row-actions';
import { FolderRowActions } from '@/components/portal/folder-row-actions'; import { FolderRowActions } from '@/components/portal/folder-row-actions';
import { NewFolderButton } from '@/components/portal/new-folder-button'; import { NewFolderButton } from '@/components/portal/new-folder-button';
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb'; import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
@@ -232,6 +233,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
size="sm" size="sm"
/> />
<DownloadAction href={route('files.download', file.id)} limit={file.download_limit} variant="outline" size="sm" /> <DownloadAction href={route('files.download', file.id)} limit={file.download_limit} variant="outline" size="sm" />
<FileRowActions file={file} />
</div> </div>
</div> </div>
))} ))}
@@ -356,6 +358,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
size="sm" size="sm"
iconOnly iconOnly
/> />
<FileRowActions file={file} />
</div> </div>
</div> </div>
</div> </div>
@@ -9,6 +9,7 @@ import { CategoryBadges } from '@/components/files/category-badges';
import { VersionBadge } from '@/components/files/version-badge'; import { VersionBadge } from '@/components/files/version-badge';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import { Pagination } from '@/components/pagination'; import { Pagination } from '@/components/pagination';
import { FileRowActions } from '@/components/portal/file-row-actions';
import { FolderRowActions } from '@/components/portal/folder-row-actions'; import { FolderRowActions } from '@/components/portal/folder-row-actions';
import { NewFolderButton } from '@/components/portal/new-folder-button'; import { NewFolderButton } from '@/components/portal/new-folder-button';
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb'; import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
@@ -262,6 +263,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
iconClassName="size-4 text-blue-600" iconClassName="size-4 text-blue-600"
iconOnly iconOnly
/> />
<FileRowActions file={file} />
</div> </div>
); );
})} })}
@@ -9,6 +9,7 @@ import { CategoryBadges } from '@/components/files/category-badges';
import { VersionBadge } from '@/components/files/version-badge'; import { VersionBadge } from '@/components/files/version-badge';
import Heading from '@/components/heading'; import Heading from '@/components/heading';
import { Pagination } from '@/components/pagination'; import { Pagination } from '@/components/pagination';
import { FileRowActions } from '@/components/portal/file-row-actions';
import { FolderRowActions } from '@/components/portal/folder-row-actions'; import { FolderRowActions } from '@/components/portal/folder-row-actions';
import { NewFolderButton } from '@/components/portal/new-folder-button'; import { NewFolderButton } from '@/components/portal/new-folder-button';
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb'; import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
@@ -257,6 +258,7 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
size="sm" size="sm"
iconOnly iconOnly
/> />
<FileRowActions file={file} />
</div> </div>
</div> </div>
</div> </div>
@@ -23,6 +23,8 @@ interface SystemSettingsProps {
viewer_timezone: string | null; viewer_timezone: string | null;
can_manage_updates: boolean; can_manage_updates: boolean;
check_for_updates: boolean | null; check_for_updates: boolean | null;
fetch_news: boolean | null;
can_configure_news: boolean;
/** When the release feed was last asked, by anybody. Null until it has been. */ /** When the release feed was last asked, by anybody. Null until it has been. */
last_checked_at: string | null; last_checked_at: string | null;
/** The answer to a "check now" press, for the one render after it. */ /** The answer to a "check now" press, for the one render after it. */
@@ -36,6 +38,8 @@ export default function SystemSettings({
viewer_timezone, viewer_timezone,
can_manage_updates, can_manage_updates,
check_for_updates, check_for_updates,
fetch_news,
can_configure_news,
last_checked_at, last_checked_at,
check_result, check_result,
}: SystemSettingsProps) { }: SystemSettingsProps) {
@@ -75,6 +79,7 @@ export default function SystemSettings({
site_name: site_name, site_name: site_name,
timezone: timezone, timezone: timezone,
check_for_updates: check_for_updates ?? false, check_for_updates: check_for_updates ?? false,
fetch_news: fetch_news ?? true,
}); });
const submit: FormEventHandler = (e) => { const submit: FormEventHandler = (e) => {
@@ -132,6 +137,30 @@ export default function SystemSettings({
<InputError className="mt-2" message={errors.timezone} /> <InputError className="mt-2" message={errors.timezone} />
</div> </div>
{/* Its own capability, not can_manage_updates: that block
disappears on a managed installation because nobody
there can act on an update notice, while this one
disappears because the news has to keep arriving
whether or not that installation's administrator
would have chosen it. */}
{can_configure_news && (
<div className="grid gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="fetch_news"
checked={data.fetch_news}
onCheckedChange={(checked) => setData('fetch_news', checked === true)}
/>
<Label htmlFor="fetch_news">{t('Show ProjectSend news on the dashboard')}</Label>
</div>
<p className="text-muted-foreground text-sm">
{t(
'Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.',
)}
</p>
</div>
)}
{can_manage_updates && ( {can_manage_updates && (
<div className="grid gap-2"> <div className="grid gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+30
View File
@@ -1,3 +1,4 @@
import { type Announcement } from '@/components/announcement';
import { type InstallKind } from '@/components/update-instructions'; import { type InstallKind } from '@/components/update-instructions';
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
@@ -23,6 +24,12 @@ export interface NavItem {
isActive?: boolean; isActive?: boolean;
items?: NavItem[]; items?: NavItem[];
badge?: number; badge?: number;
/**
* Leaves this installation. Rendered as a plain anchor opening in a
* new tab rather than an Inertia <Link>, which would try to fetch a
* page component from another origin and fail silently.
*/
external?: boolean;
} }
export type Edition = 'community' | 'cloud'; export type Edition = 'community' | 'cloud';
@@ -32,12 +39,27 @@ export type Capability =
| 'storage.configure' | 'storage.configure'
| 'email.transport.configure' | 'email.transport.configure'
| 'system.updates' | 'system.updates'
| 'news.configure'
| 'scheduler.monitoring' | 'scheduler.monitoring'
| 'custom_assets.manage' | 'custom_assets.manage'
| 'branding.customize' | 'branding.customize'
| 'attribution.hide' | 'attribution.hide'
| 'captcha.configure'
| 'captcha.managed_keys'; | 'captcha.managed_keys';
/**
* A sidebar entry contributed by a package — see
* ResolvingNavigationLinks. Staff-only and already filtered server-side,
* so the sidebar renders these without re-deciding who may see them.
*/
export interface ExtraNavLink {
title: string;
url: string;
/** Opens in a new tab and shows that it leaves this installation. */
external: boolean;
icon: string | null;
}
export interface SocialLoginProvider { export interface SocialLoginProvider {
provider: string; provider: string;
label: string; label: string;
@@ -89,6 +111,14 @@ export interface SharedData {
*/ */
attribution: boolean; attribution: boolean;
capabilities: Capability[]; capabilities: Capability[];
/** Sidebar entries contributed by packages, already staff-filtered. */
extra_nav_links: ExtraNavLink[];
/**
* One message to put in front of staff, or null. Rendered as a band
* on the dashboard and behind the header icon everywhere else — see
* ResolvingAnnouncement. Shared so both say the same thing.
*/
announcement: Announcement | null;
/** Identity providers that are switched on and fully configured. */ /** Identity providers that are switched on and fully configured. */
social_login: SocialLoginProvider[]; social_login: SocialLoginProvider[];
/** The CAPTCHA in force, or null when this installation has none. */ /** The CAPTCHA in force, or null when this installation has none. */
+8
View File
@@ -38,6 +38,14 @@ export interface FileRow {
created_at: string | null; created_at: string | null;
is_mine: boolean; is_mine: boolean;
public: boolean; public: boolean;
/**
* Whether this client may edit / delete this row, decided per file by
* FilePolicy. Not the same question as `is_mine`: holding the file is
* half of it and the role's keys are the other half, so a theme reads
* these and never derives them.
*/
can_update: boolean;
can_delete: boolean;
/** Comments this client can see on the file — the number on its row. */ /** Comments this client can see on the file — the number on its row. */
comments_count: number; comments_count: number;
/** How many of those they have not read yet. */ /** How many of those they have not read yet. */
+24 -6
View File
@@ -215,12 +215,30 @@ Route::middleware('auth')->group(function () {
Route::patch('system/settings/social-login/{provider}', [SocialLoginSettingsController::class, 'update']) Route::patch('system/settings/social-login/{provider}', [SocialLoginSettingsController::class, 'update'])
->name('system-settings.social-login.update'); ->name('system-settings.social-login.update');
// Outside any capability: group for the same reason as LDAP above. // Unlike LDAP and social login above, this one does get a
// Only the option of using the platform's own keys is an edition // capability: group — present in both editions, so a self-hosted
// difference, and that is gated per field inside the controller. // installation keeps the screen it has always had, and only
Route::get('system/settings/captcha', [CaptchaSettingsController::class, 'edit'])->name('system-settings.captcha.edit'); // removed when an operator names captcha.configure in
Route::patch('system/settings/captcha', [CaptchaSettingsController::class, 'update'])->name('system-settings.captcha.update'); // PROJECTSEND_CAPABILITIES_DISABLED.
Route::post('system/settings/captcha/test', [CaptchaSettingsController::class, 'test'])->name('system-settings.captcha.test'); //
// The reason a managed platform would: its tenants share one
// parent domain and one sending reputation, so an administrator
// switching their own CAPTCHA off spends everybody else's. Same
// shape as Storage below — all-or-nothing on the route, read
// included. Per-field gating in the controller would leave the
// hole open, because turning the CAPTCHA off (provider `none`, or
// just unticking the four per-form switches) needs none of the
// gated fields. The middleware covers the PATCH as well as the
// GET, which is what closes it.
//
// Which keys the screen may offer is a second, narrower question,
// still answered per field inside the controller by
// Capability::CaptchaManagedKeys.
Route::middleware('capability:captcha.configure')->group(function () {
Route::get('system/settings/captcha', [CaptchaSettingsController::class, 'edit'])->name('system-settings.captcha.edit');
Route::patch('system/settings/captcha', [CaptchaSettingsController::class, 'update'])->name('system-settings.captcha.update');
Route::post('system/settings/captcha/test', [CaptchaSettingsController::class, 'test'])->name('system-settings.captcha.test');
});
Route::get('system/settings/privacy', [PrivacySettingsController::class, 'edit'])->name('system-settings.privacy.edit'); Route::get('system/settings/privacy', [PrivacySettingsController::class, 'edit'])->name('system-settings.privacy.edit');
Route::patch('system/settings/privacy', [PrivacySettingsController::class, 'update'])->name('system-settings.privacy.update'); Route::patch('system/settings/privacy', [PrivacySettingsController::class, 'update'])->name('system-settings.privacy.update');
+18
View File
@@ -237,6 +237,24 @@ Route::middleware(['auth'])->group(function () {
Route::get('my-files/upload', [MyFilesController::class, 'upload'])->middleware('can:upload')->name('my-files.upload.create'); Route::get('my-files/upload', [MyFilesController::class, 'upload'])->middleware('can:upload')->name('my-files.upload.create');
Route::get('my-files/version-candidates', [MyFilesController::class, 'versionCandidates'])->middleware('can:upload')->name('my-files.version-candidates'); Route::get('my-files/version-candidates', [MyFilesController::class, 'versionCandidates'])->middleware('can:upload')->name('my-files.version-candidates');
// A client editing and deleting their OWN uploads. Deliberately not the
// staff files.* routes, which are `staff`-gated because they carry
// assignments, share links and activity — and whose folder guard asks
// StaffLibraryScope, which answers "allowed" for any client (see
// FilePolicy::update()).
//
// No `can:` middleware here on purpose: `edit_files` and `delete_files`
// mean "your own" for a client and "anyone's, if you also hold the
// others_ key" for staff, and only FilePolicy knows which. A route-level
// gate would let a client through on the key alone, before anything had
// asked whose file it is. MyFilesController authorizes both.
// Registered after the literal my-files/* GETs above, which would
// otherwise be swallowed by {file} — the same ordering rule as
// files/orphans.
Route::get('my-files/{file}/edit', [MyFilesController::class, 'edit'])->name('my-files.edit');
Route::patch('my-files/{file}', [MyFilesController::class, 'update'])->name('my-files.update');
Route::delete('my-files/{file}', [MyFilesController::class, 'destroy'])->name('my-files.destroy');
// Client-created folders — MyFoldersController double-checks isClient() // Client-created folders — MyFoldersController double-checks isClient()
// and create_own_folders itself; the route-level gate here just keeps // and create_own_folders itself; the route-level gate here just keeps
// staff from ever hitting these (they use folders.* instead). // staff from ever hitting these (they use folders.* instead).
+41
View File
@@ -3,6 +3,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Gate;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Permissions\Permission; use App\Modules\Identity\Permissions\Permission;
/* /*
@@ -38,6 +40,45 @@ test('a client token is refused everywhere', function () {
$this->withToken($token)->getJson('/api/v1/me')->assertForbidden(); $this->withToken($token)->getJson('/api/v1/me')->assertForbidden();
}); });
/*
* The write half of the same boundary, and it needs its own test now that
* FilePolicy has a client branch.
*
* Since clients may edit and delete their own uploads in the portal,
* `Gate::authorize('update', $file)` inside Api\FilesController *passes*
* for a client holding the key on a file they uploaded. The only thing
* standing between a client token and the API's write endpoints is the
* `staff-token` middleware. That was always true, but until the portal
* work it was belt-and-braces: the policy refused as well. It no longer
* does, so this pins the one remaining door rather than leaving the whole
* boundary resting on a middleware nothing tests against a *passing*
* policy.
*
* If client tokens are ever issued (see docs/api-todo.md), this test is
* where that decision has to be made deliberately.
*/
test('a client token cannot write through the API even to its own file', function () {
$client = User::factory()->client()->create();
foreach (['edit_files', 'delete_files'] as $permission) {
$client->role->permissions()->create(['permission' => $permission]);
}
$file = File::factory()->create(['uploaded_by' => $client->id]);
// The policy itself now says yes — this is the premise, not an aside.
expect(Gate::forUser($client)->allows('update', $file))->toBeTrue()
->and(Gate::forUser($client)->allows('delete', $file))->toBeTrue();
$token = $client->createToken('t', ['edit_files', 'delete_files'])->plainTextToken;
$this->withToken($token)->patchJson("/api/v1/files/{$file->id}", ['name' => 'taken'])->assertForbidden();
$this->withToken($token)->deleteJson("/api/v1/files/{$file->id}")->assertForbidden();
expect($file->refresh()->name)->not->toBe('taken')
->and($file->trashed())->toBeFalse();
});
test('a deactivated account loses API access on the next request', function () { test('a deactivated account loses API access on the next request', function () {
$token = $this->staff->createToken('t', [Permission::Upload->value])->plainTextToken; $token = $this->staff->createToken('t', [Permission::Upload->value])->plainTextToken;
@@ -329,6 +329,61 @@ test('a storage backend that refuses the write fails the upload instead of recor
expect(File::query()->count())->toBe($before); expect(File::query()->count())->toBe($before);
}); });
// What survives a completion that failed. The lock in complete() promises
// the client may try again once whatever went wrong is fixed -- "the
// lock's TTL releases the claim if a completion dies mid-flight, so a
// later retry still works" -- and a retry has nothing to work from but
// the parts.
test('a refused write leaves the parts for the retry the lock promises', function () {
$this->actingAs($this->admin);
$sessionId = createSession(11, 'assembled.txt');
putPart($sessionId, 1, 'hello-');
putPart($sessionId, 2, 'world');
$refusing = Mockery::mock(Illuminate\Contracts\Filesystem\Filesystem::class);
$refusing->shouldReceive('writeStream')->once()->andReturnFalse();
Storage::set('files', $refusing);
$this->postJson("/uploads/{$sessionId}/complete")->assertStatus(422);
// Both parts are still there, and the half-written copy is not.
expect($this->getJson("/uploads/{$sessionId}/parts")->json())->toHaveCount(2)
->and(file_exists(partsRoot().'/'.$sessionId.'/assembled'))->toBeFalse();
// The operator fixes the bucket; the same session completes.
Storage::fake('files');
$this->postJson("/uploads/{$sessionId}/complete")->assertOk();
$file = File::query()->latest('id')->firstOrFail();
expect(Storage::disk('files')->get($file->path))->toBe('hello-world')
->and($file->size)->toBe(11);
});
test('a temporary directory that cannot be written fails the upload, and says so', function () {
// /dev/full accepts an open and refuses every write with ENOSPC, which
// is the failure this guards against without needing a full volume.
// Unchecked, the byte count and the checksum describe what was read
// rather than what landed; checked, it reads like the other storage
// failure a few lines below it in the same method.
$this->actingAs($this->admin);
$sessionId = createSession(11, 'assembled.txt');
putPart($sessionId, 1, 'hello-');
putPart($sessionId, 2, 'world');
symlink('/dev/full', partsRoot().'/'.$sessionId.'/assembled');
$this->postJson("/uploads/{$sessionId}/complete")
->assertStatus(422)
->assertJsonPath('errors.parts.0', fn (string $message): bool => str_contains($message, 'Could not assemble the upload'));
// Nothing recorded, and the parts are still the client's to retry with.
expect(File::query()->count())->toBe(0)
->and($this->getJson("/uploads/{$sessionId}/parts")->json())->toHaveCount(2);
})->skip(! file_exists('/dev/full'), 'needs /dev/full, which only exists on Linux');
// A chunked upload is two requests, and store()'s rule only ever sees the // A chunked upload is two requests, and store()'s rule only ever sees the
// first one. Delete the folder while the bytes are in flight and the // first one. Delete the folder while the bytes are in flight and the
// session still names it -- the version of this that nobody can ask for // session still names it -- the version of this that nobody can ask for
@@ -0,0 +1,534 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
});
/** A client whose role carries exactly the given permission keys. */
function clientWithPermissions(array $permissions): User
{
$role = Role::query()->create(['name' => 'Client Role '.Str::random(6)]);
foreach ($permissions as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
}
return User::factory()->client()->create(['role_id' => $role->id]);
}
/** A stored file owned by $owner, with real bytes on the fake disk. */
function ownedFile(User $owner, array $overrides = []): File
{
$path = 'uploads/'.Str::uuid()->toString().'.pdf';
Storage::disk('files')->put($path, 'hello-world');
return File::factory()->create([
'uploaded_by' => $owner->id,
'name' => 'report',
'original_name' => 'report.pdf',
'mime_type' => 'application/pdf',
'size' => 11,
...$overrides,
'path' => $path,
'disk' => 'files',
]);
}
/** The full form payload the portal editor posts, overridable per test. */
function clientEditPayload(array $overrides = []): array
{
return array_merge(['name' => 'renamed'], $overrides);
}
/*
|--------------------------------------------------------------------------
| The rule
|--------------------------------------------------------------------------
|
| A client owns what they uploaded, and owning it is what lets them edit
| and delete it — subject to the same per-field keys staff are subject to.
*/
test('a client with edit_files can rename their own upload', function () {
$client = clientWithPermissions(['edit_files']);
$file = ownedFile($client);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['description' => 'now with a description']))
->assertRedirect();
expect($file->refresh()->name)->toBe('renamed')
->and($file->description)->toBe('now with a description');
});
test('a client with delete_files can delete their own upload, and the bytes go with it', function () {
$client = clientWithPermissions(['delete_files']);
$file = ownedFile($client);
expect(app(ClientStorageUsage::class)->usedBytes($client))->toBe(11);
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertRedirect('/my-files');
expect(File::withTrashed()->findOrFail($file->id)->trashed())->toBeTrue();
Storage::disk('files')->assertMissing($file->path);
// The quota frees by exactly what the disk did. These two have to agree
// or a client pays rent on bytes that are gone — nothing ever
// forceDelete()s a File row, so "temporarily" would mean forever.
expect(app(ClientStorageUsage::class)->usedBytes($client))->toBe(0);
});
/*
|--------------------------------------------------------------------------
| Ownership is the boundary
|--------------------------------------------------------------------------
*/
test('a client cannot edit or delete another client\'s file', function () {
$client = clientWithPermissions(['edit_files', 'delete_files']);
$stranger = User::factory()->client()->create();
$file = ownedFile($stranger);
$this->actingAs($client)->patch("/my-files/{$file->id}", clientEditPayload())->assertForbidden();
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertForbidden();
expect($file->refresh()->name)->toBe('report')
->and($file->trashed())->toBeFalse();
});
// The keys exist for staff, where "others' files" is a real category. A
// client has no others' files — only files somebody showed them — so these
// two must buy nothing at all. FilePolicy's client branch never reads them.
test('edit_others_files and delete_others_files buy a client nothing', function () {
$client = clientWithPermissions([
'edit_files', 'delete_files', 'edit_others_files', 'delete_others_files',
]);
$stranger = User::factory()->client()->create();
$file = ownedFile($stranger);
$this->actingAs($client)->patch("/my-files/{$file->id}", clientEditPayload())->assertForbidden();
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertForbidden();
});
// Being shown a file is not being given it. This is the case a client is
// most likely to try, because the file is sitting right there in their list.
test('a client cannot edit a file staff merely shared with them', function () {
$client = clientWithPermissions(['edit_files', 'delete_files']);
$file = ownedFile($this->admin);
$this->actingAs($this->admin)
->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id])
->assertRedirect();
// Visible to them...
$this->actingAs($client)->get('/my-files')->assertOk();
// ...and still not theirs.
$this->actingAs($client)->patch("/my-files/{$file->id}", clientEditPayload())->assertForbidden();
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertForbidden();
});
test('a client without edit_files cannot edit their own upload', function () {
$client = clientWithPermissions([]);
$file = ownedFile($client);
$this->actingAs($client)->patch("/my-files/{$file->id}", clientEditPayload())->assertForbidden();
expect($file->refresh()->name)->toBe('report');
});
test('a client without delete_files cannot delete their own upload', function () {
$client = clientWithPermissions(['edit_files']);
$file = ownedFile($client);
$this->actingAs($client)->delete("/my-files/{$file->id}")->assertForbidden();
expect($file->refresh()->trashed())->toBeFalse();
});
test('staff cannot reach the portal routes, and a client cannot reach the staff ones', function () {
$client = clientWithPermissions(['edit_files', 'delete_files']);
$file = ownedFile($client);
// The staff editor is `staff` middleware, not a permission — so holding
// the key changes nothing. A GET is sent home rather than refused
// (EnsureStaff: staff pages are not part of a client's world); the
// writes are a hard 403.
$this->actingAs($client)->get("/files/{$file->id}")->assertRedirect(route('dashboard'));
$this->actingAs($client)->patch("/files/{$file->id}", clientEditPayload())->assertForbidden();
$this->actingAs($client)->delete("/files/{$file->id}")->assertForbidden();
// And the portal route refuses a staff account rather than quietly
// giving it a second way to edit.
$this->actingAs($this->admin)->patch("/my-files/{$file->id}", clientEditPayload())->assertNotFound();
});
/*
|--------------------------------------------------------------------------
| Per-field keys
|--------------------------------------------------------------------------
|
| Lacking the key leaves the field alone and the rest of the edit still
| saves — the rule the staff editor and the API already follow. A client
| must not be the one surface where a missing key fails the request.
*/
test('a client without upload_public cannot publish, and the rename still saves', function () {
$client = clientWithPermissions(['edit_files']);
$file = ownedFile($client);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['public' => true]))
->assertRedirect();
expect($file->refresh()->public)->toBeFalse()
->and($file->name)->toBe('renamed');
});
test('a client with upload_public can publish their own upload', function () {
$client = clientWithPermissions(['edit_files', 'upload_public']);
$file = ownedFile($client);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['public' => true]))
->assertRedirect();
expect($file->refresh()->public)->toBeTrue()
->and($file->slug)->not->toBe('');
});
// The slug is derived, never chosen. An installation-wide unique slug a
// client picks is a name to squat and an oracle to probe with.
test('a client cannot choose the public slug', function () {
$client = clientWithPermissions(['edit_files', 'upload_public']);
$file = ownedFile($client);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload([
'public' => true,
'slug' => 'front-page',
]))
->assertRedirect();
expect($file->refresh()->slug)->not->toBe('front-page');
});
test('a client without set_file_categories cannot categorise', function () {
$client = clientWithPermissions(['edit_files']);
$file = ownedFile($client);
$category = Category::query()->create(['name' => 'Docs']);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['categories' => [$category->id]]))
->assertRedirect();
expect($file->refresh()->categories)->toHaveCount(0);
});
test('a client without set_file_expiration_date cannot set an expiry', function () {
$client = clientWithPermissions(['edit_files']);
$file = ownedFile($client);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['expires_at' => now()->addWeek()->toDateString()]))
->assertRedirect();
expect($file->refresh()->expires_at)->toBeNull();
});
test('a client without limit_downloads cannot cap downloads', function () {
$client = clientWithPermissions(['edit_files']);
$file = ownedFile($client);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['download_limit' => 3]))
->assertRedirect();
expect($file->refresh()->download_limit)->toBeNull();
});
test('a client holding the keys can set an expiry, categories and a download cap', function () {
$client = clientWithPermissions([
'edit_files', 'set_file_expiration_date', 'set_file_categories', 'limit_downloads',
]);
$file = ownedFile($client);
$category = Category::query()->create(['name' => 'Docs']);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload([
'expires_at' => now()->addWeek()->toDateString(),
'categories' => [$category->id],
'download_limit' => 3,
]))
->assertRedirect();
$file->refresh();
expect($file->expires_at)->not->toBeNull()
->and($file->categories)->toHaveCount(1)
->and($file->download_limit)->toBe(3);
});
/*
|--------------------------------------------------------------------------
| The folder trap
|--------------------------------------------------------------------------
|
| StaffLibraryScope::allowsFolder() returns true for anyone who is not
| client-*scoped* staff, and User::isClientScoped() is false for every
| client. A client reaching the staff guard would be handed every folder on
| the installation. These pin that they never do.
*/
test('a client cannot move their file into a folder they could not upload to', function () {
$client = clientWithPermissions(['edit_files']);
$file = ownedFile($client);
$staffOnly = makeFolder('Internal');
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['folder_id' => $staffOnly->id]))
->assertForbidden();
expect($file->refresh()->folder_id)->toBeNull();
});
test('a client cannot move their file into another client\'s folder', function () {
$client = clientWithPermissions(['edit_files']);
$stranger = User::factory()->client()->create();
$file = ownedFile($client);
$this->actingAs($stranger)->post('/my-folders', ['name' => 'Theirs'])->assertRedirect();
$theirs = Folder::query()->where('name', 'Theirs')->sole();
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['folder_id' => $theirs->id]))
->assertForbidden();
expect($file->refresh()->folder_id)->toBeNull();
});
test('a client can move their file into a folder they created', function () {
$client = clientWithPermissions(['edit_files', 'create_own_folders', 'upload']);
$file = ownedFile($client);
$this->actingAs($client)->post('/my-folders', ['name' => 'Mine'])->assertRedirect();
$mine = Folder::query()->where('name', 'Mine')->sole();
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['folder_id' => $mine->id]))
->assertRedirect();
expect($file->refresh()->folder_id)->toBe($mine->id);
});
/*
|--------------------------------------------------------------------------
| What the payload must not reach
|--------------------------------------------------------------------------
*/
test('a client cannot hand their file to somebody else, or repoint its bytes', function () {
$client = clientWithPermissions(['edit_files']);
$stranger = User::factory()->client()->create();
$file = ownedFile($client);
$originalPath = $file->path;
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload([
'uploaded_by' => $stranger->id,
'path' => 'uploads/somebody-elses-file.pdf',
'disk' => 'nonexistent-disk',
'size' => 999999999,
]))
->assertRedirect();
$file->refresh();
expect($file->uploaded_by)->toBe($client->id)
->and($file->path)->toBe($originalPath)
->and($file->disk)->toBe('files')
->and($file->size)->toBe(11);
});
/*
|--------------------------------------------------------------------------
| The page and the rows
|--------------------------------------------------------------------------
|
| Hiding a control is a courtesy, never the enforcement — every assertion
| above already proves the server refuses. These pin that a client is not
| shown a switch that would silently do nothing.
*/
test('the editor opens for an owner and refuses everyone else', function () {
$client = clientWithPermissions(['edit_files']);
$stranger = User::factory()->client()->create();
$file = ownedFile($client);
$this->actingAs($client)->get("/my-files/{$file->id}/edit")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('portal/edit-file')
->where('file.name', 'report'));
$this->actingAs($stranger)->get("/my-files/{$file->id}/edit")->assertForbidden();
// And a staff account gets the staff editor, not this one.
$this->actingAs($this->admin)->get("/my-files/{$file->id}/edit")->assertNotFound();
});
test('the editor offers only the fields the role actually grants', function () {
$bare = clientWithPermissions(['edit_files']);
$file = ownedFile($bare);
$this->actingAs($bare)->get("/my-files/{$file->id}/edit")->assertInertia(
fn (AssertableInertia $page) => $page
->where('can_publish', false)
->where('can_set_expiration', false)
->where('can_set_categories', false)
->where('can_limit_downloads', false)
->where('can_delete', false),
);
$full = clientWithPermissions([
'edit_files', 'delete_files', 'upload_public',
'set_file_expiration_date', 'set_file_categories', 'limit_downloads',
]);
$theirs = ownedFile($full);
$this->actingAs($full)->get("/my-files/{$theirs->id}/edit")->assertInertia(
fn (AssertableInertia $page) => $page
->where('can_publish', true)
->where('can_set_expiration', true)
->where('can_set_categories', true)
->where('can_limit_downloads', true)
->where('can_delete', true),
);
});
// The folder picker must not offer a destination the save would refuse —
// otherwise a client picks a folder, saves, and gets a 403 for choosing
// something they were shown.
test('the folder picker offers only folders the client could upload to', function () {
$client = clientWithPermissions(['edit_files', 'create_own_folders', 'upload']);
$file = ownedFile($client);
makeFolder('Internal');
$this->actingAs($client)->post('/my-folders', ['name' => 'Mine'])->assertRedirect();
$this->actingAs($client)->get("/my-files/{$file->id}/edit")->assertInertia(
fn (AssertableInertia $page) => $page
->has('folders', 1)
->where('folders.0.name', 'Mine'),
);
});
test('file rows carry the same answer the server will give', function () {
$client = clientWithPermissions(['edit_files', 'delete_files']);
$own = ownedFile($client, ['name' => 'mine']);
$shared = ownedFile($this->admin, ['name' => 'theirs']);
$this->actingAs($this->admin)
->post("/files/{$shared->id}/assignments", ['type' => 'client', 'id' => $client->id])
->assertRedirect();
$this->actingAs($client)->get('/my-files')->assertInertia(function (AssertableInertia $page) {
$files = collect($page->toArray()['props']['files'])->keyBy('name');
expect($files['mine']['can_update'])->toBeTrue()
->and($files['mine']['can_delete'])->toBeTrue()
// Shared with them, and still not theirs — the same answer the
// PATCH gives, so the row never offers what the save refuses.
->and($files['theirs']['can_update'])->toBeFalse()
->and($files['theirs']['can_delete'])->toBeFalse();
});
});
test('a client without the keys sees no controls on their own rows', function () {
$client = clientWithPermissions([]);
ownedFile($client, ['name' => 'mine']);
$this->actingAs($client)->get('/my-files')->assertInertia(
fn (AssertableInertia $page) => $page
->where('files.0.can_update', false)
->where('files.0.can_delete', false),
);
});
/*
|--------------------------------------------------------------------------
| Publishing by the side door
|--------------------------------------------------------------------------
|
| File::isEffectivelyPublic() is "my own flag OR my folder's", and
| Folder::uploadableBy() lets a client into a public folder on
| upload_to_public_folders — a different key from upload_public. So a
| client can make a file world-readable without ever touching the public
| switch, and without holding the key that switch is behind.
|
| That is the established meaning of the two keys and exactly what
| uploading into such a folder has always done, so the editor does not
| refuse it. What it must not do is let it happen silently: in a picker of
| bare folder names the consequence would be invisible, which is the one
| thing that would be new here.
*/
test('moving into a public folder publishes the file, and the picker says so', function () {
$client = clientWithPermissions(['edit_files', 'upload_to_public_folders']);
$file = ownedFile($client);
$open = makeFolder('Open Drop Box');
$open->forceFill(['public' => true, 'allow_client_uploads' => true, 'slug' => 'open-drop-box'])->save();
$this->actingAs($this->admin)
->post("/folders/{$open->id}/assignments", ['type' => 'client', 'id' => $client->id])
->assertRedirect();
// The picker offers it — and carries the consequence with the name.
$this->actingAs($client)->get("/my-files/{$file->id}/edit")->assertInertia(
fn (AssertableInertia $page) => $page
->where('can_publish', false)
->has('folders', 1)
->where('folders.0.name', 'Open Drop Box')
->where('folders.0.public', true),
);
$this->actingAs($client)
->patch("/my-files/{$file->id}", clientEditPayload(['folder_id' => $open->id]))
->assertRedirect();
$file->refresh();
// The file's own flag never moved — the client does not hold the key
// for that — but the folder makes it readable all the same.
expect($file->public)->toBeFalse()
->and($file->isEffectivelyPublic())->toBeTrue();
});
// The other half: a private folder must never be labelled public, or the
// warning becomes noise people learn to ignore.
test('a private folder is not flagged public in the picker', function () {
$client = clientWithPermissions(['edit_files', 'create_own_folders', 'upload']);
$file = ownedFile($client);
$this->actingAs($client)->post('/my-folders', ['name' => 'Mine'])->assertRedirect();
$this->actingAs($client)->get("/my-files/{$file->id}/edit")->assertInertia(
fn (AssertableInertia $page) => $page
->where('folders.0.name', 'Mine')
->where('folders.0.public', false),
);
});
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Models\File;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Facades\Storage;
/**
* A client-scoped staff member may hold a file whose uploader, or whose
* other recipients, are clients off their roster — that is a legitimate
* consequence of group and folder sharing, and the file boundary is right
* to allow it. What is not legitimate is learning those clients' names and
* ids from the file's metadata.
*
* Every one of these asserts on the rendered response body rather than on a
* particular key, because the leak was never in one field: the same client
* name arrived through the uploader, through the recipient list, and
* through four different screens. A body that does not contain the name
* anywhere is the only assertion that would have caught all of them.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$this->onRoster = User::factory()->client()->create(['name' => 'Roster Client']);
$this->offRoster = User::factory()->client()->create(['name' => 'Offroster Client']);
$this->manager = User::factory()->role(SystemRole::ClientManager)->create();
$this->manager->assignedClients()->sync([$this->onRoster->id]);
$this->token = $this->manager->createToken('t', [Permission::Upload->value])->plainTextToken;
});
/** A file the manager may read, uploaded by a client they may not identify. */
function fileFromStranger(): File
{
$file = File::factory()->create([
'uploaded_by' => test()->offRoster->id,
'name' => 'shared-onward',
]);
shareFileWith($file, test()->onRoster);
return $file;
}
/**
* A client-scoped staff member holding wider permissions than the built-in
* Client Manager — the roles that can open a colleague's file for editing
* and browse a client's own library. Scoping and permissions are separate
* axes, and the leak is a property of the scoping.
*
* @param list<string> $permissions
*/
function scopedStaffWith(array $permissions): User
{
$role = Role::query()->create(['name' => 'Scoped '.uniqid(), 'client_scoped' => true]);
foreach ($permissions as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
}
$staff = User::factory()->create(['role_id' => $role->id]);
$staff->assignedClients()->sync([test()->onRoster->id]);
return $staff;
}
/** A file the manager may read that is also shared with a stranger client. */
function fileWithStrangerCoRecipient(): File
{
$file = File::factory()->create(['uploaded_by' => test()->admin->id, 'name' => 'shared-both']);
shareFileWith($file, test()->onRoster);
shareFileWith($file, test()->offRoster);
return $file;
}
test('the file boundary itself is unchanged: a stranger-only file is still refused', function () {
$file = File::factory()->create(['uploaded_by' => $this->offRoster->id]);
shareFileWith($file, $this->offRoster);
$this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")->assertForbidden();
});
test('the file boundary itself is unchanged on the web', function () {
$file = File::factory()->create(['uploaded_by' => $this->offRoster->id]);
shareFileWith($file, $this->offRoster);
$this->actingAs($this->manager)->get("/files/{$file->id}/details")->assertForbidden();
});
test('the API does not name a stranger uploader of a file the caller may read', function () {
$file = fileFromStranger();
$show = $this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")->assertOk();
$index = $this->withToken($this->token)->getJson('/api/v1/files')->assertOk();
expect($show->getContent())->not->toContain('Offroster Client')
->and($index->getContent())->not->toContain('Offroster Client')
->and($show->json('data.uploaded_by'))->toBeNull()
// The file is still readable — this narrows the answer, it does
// not withdraw it.
->and($show->json('data.id'))->toBe($file->id);
});
test('the API does not list a stranger co-recipient', function () {
$file = fileWithStrangerCoRecipient();
$show = $this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")->assertOk();
expect($show->getContent())->not->toContain('Offroster Client')
->and($show->json('data.assignments'))->toHaveCount(1)
->and($show->json('data.assignments.0.name'))->toBe('Roster Client');
});
test('a stranger co-recipient is not named in the reply to a write', function () {
$file = fileWithStrangerCoRecipient();
// The assignment endpoints re-load assignments.assignable and hand the
// result straight back, which is a second serialisation path — and one
// that a fix applied only to the read controllers would have missed.
// The 200 is asserted on purpose: without it this passes on a 403,
// whose body names nobody either.
$writer = scopedStaffWith([Permission::Upload->value, Permission::EditFiles->value, Permission::EditOthersFiles->value]);
$token = $writer->createToken('w', [Permission::EditFiles->value, Permission::EditOthersFiles->value])->plainTextToken;
$response = $this->withToken($token)
->postJson("/api/v1/files/{$file->id}/assignments", ['type' => 'client', 'id' => $this->onRoster->id])
->assertOk();
expect($response->getContent())->not->toContain('Offroster Client')
->and($response->json('data.assignments'))->toHaveCount(1);
});
test('uploaded_by cannot be used to probe for a client the caller may not identify', function () {
fileFromStranger();
$probe = $this->withToken($this->token)->getJson("/api/v1/files?uploaded_by={$this->offRoster->id}")->assertOk();
expect($probe->json('data'))->toBeEmpty();
});
test('uploaded_by still filters by a client on the roster', function () {
$own = File::factory()->create(['uploaded_by' => $this->onRoster->id, 'name' => 'theirs']);
shareFileWith($own, $this->onRoster);
fileFromStranger();
$hit = $this->withToken($this->token)->getJson("/api/v1/files?uploaded_by={$this->onRoster->id}")->assertOk();
expect($hit->json('data'))->toHaveCount(1)
->and($hit->json('data.0.id'))->toBe($own->id);
});
test('uploaded_by still filters by a staff member', function () {
$file = fileWithStrangerCoRecipient();
$hit = $this->withToken($this->token)->getJson("/api/v1/files?uploaded_by={$this->admin->id}")->assertOk();
expect($hit->json('data'))->toHaveCount(1)
->and($hit->json('data.0.id'))->toBe($file->id);
});
test('the details panel names neither a stranger uploader nor a stranger recipient', function () {
$stranger = fileFromStranger();
$both = fileWithStrangerCoRecipient();
$one = $this->actingAs($this->manager)->get("/files/{$stranger->id}/details")->assertOk();
$two = $this->actingAs($this->manager)->get("/files/{$both->id}/details")->assertOk();
expect($one->getContent())->not->toContain('Offroster Client')
->and($one->json('uploader'))->toBeNull()
->and($two->getContent())->not->toContain('Offroster Client')
->and($two->json('shares.clients'))->toHaveCount(1);
});
test('the library listing does not describe a stranger uploader', function () {
fileFromStranger();
$body = $this->actingAs($this->manager)->get('/files')->assertOk()->getContent();
// Not the name, and not the "a client uploaded this" shape either.
expect($body)->not->toContain('Offroster Client');
});
test('the edit page does not name a stranger uploader or recipient', function () {
$file = fileWithStrangerCoRecipient();
$file->update(['uploaded_by' => $this->offRoster->id]);
$editor = scopedStaffWith([Permission::Upload->value, Permission::EditFiles->value, Permission::EditOthersFiles->value]);
// route() rather than a built path: File binds by slug, so an id in
// the URL is a 404 rather than the page under test.
$body = $this->actingAs($editor)->get(route('files.edit', $file))->assertOk()->getContent();
expect($body)->not->toContain('Offroster Client');
});
test('the per-client file listing does not name a stranger uploader', function () {
fileFromStranger();
$browser = scopedStaffWith([Permission::Upload->value, Permission::EditClients->value]);
$body = $this->actingAs($browser)
->get("/clients/{$this->onRoster->id}/files")->assertOk()->getContent();
expect($body)->not->toContain('Offroster Client');
});
test('a group holding none of the viewer clients is not named', function () {
$group = Group::query()->create(['name' => 'Offroster Group']);
$group->members()->sync([$this->offRoster->id]);
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
shareFileWith($file, $this->onRoster);
shareFileWithGroup($file, $group);
$api = $this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")->assertOk();
expect($api->getContent())->not->toContain('Offroster Group')
->and($api->json('data.assignments'))->toHaveCount(1);
});
test('a group holding none of the viewer clients is not named on the web', function () {
$group = Group::query()->create(['name' => 'Offroster Group']);
$group->members()->sync([$this->offRoster->id]);
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
shareFileWith($file, $this->onRoster);
shareFileWithGroup($file, $group);
$details = $this->actingAs($this->manager)->get("/files/{$file->id}/details")->assertOk();
expect($details->getContent())->not->toContain('Offroster Group')
->and($details->json('shares.groups'))->toBeEmpty();
});
test('an unscoped administrator still sees every name', function () {
$file = fileWithStrangerCoRecipient();
$file->update(['uploaded_by' => $this->offRoster->id]);
$token = $this->admin->createToken('a', [Permission::Upload->value])->plainTextToken;
$api = $this->withToken($token)->getJson("/api/v1/files/{$file->id}")->assertOk();
expect($api->json('data.uploaded_by.name'))->toBe('Offroster Client')
->and($api->json('data.assignments'))->toHaveCount(2);
});
test('an unscoped administrator still sees every name on the web', function () {
$file = fileWithStrangerCoRecipient();
$file->update(['uploaded_by' => $this->offRoster->id]);
$details = $this->actingAs($this->admin)->get("/files/{$file->id}/details")->assertOk();
expect($details->json('uploader'))->toBe('Offroster Client')
->and($details->json('shares.clients'))->toHaveCount(2);
});
test('a scoped viewer is still told about their own roster and their own uploads', function () {
$mine = File::factory()->create(['uploaded_by' => $this->manager->id, 'name' => 'mine']);
shareFileWith($mine, $this->onRoster);
$api = $this->withToken($this->token)->getJson("/api/v1/files/{$mine->id}")->assertOk();
expect($api->json('data.uploaded_by.name'))->toBe($this->manager->name)
->and($api->json('data.assignments.0.name'))->toBe('Roster Client');
});
test('the rule itself: staff are never hidden, strangers always are', function () {
$identity = app(ClientIdentityScope::class);
expect($identity->permits($this->manager, $this->admin))->toBeTrue()
->and($identity->permits($this->manager, $this->onRoster))->toBeTrue()
->and($identity->permits($this->manager, $this->offRoster))->toBeFalse()
// A client may always be told who they themselves are.
->and($identity->permits($this->offRoster, $this->offRoster))->toBeTrue()
// An unscoped viewer is narrowed by nothing.
->and($identity->permits($this->admin, $this->offRoster))->toBeTrue()
->and($identity->isNarrowed($this->admin))->toBeFalse()
->and($identity->isNarrowed($this->manager))->toBeTrue()
// No viewer at all is the closed case, not the open one.
->and($identity->permits(null, $this->offRoster))->toBeFalse()
->and($identity->permits(null, $this->admin))->toBeTrue();
});
@@ -3,7 +3,9 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\User; use App\Models\User;
use App\Modules\Files\Models\File;
use App\Support\ContentDisposition; use App\Support\ContentDisposition;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
beforeEach(function () { beforeEach(function () {
@@ -61,3 +63,70 @@ test('downloads of files with non-ascii names send both header forms on the wire
->toContain('attachment; filename="') ->toContain('attachment; filename="')
->toContain("filename*=utf-8''".rawurlencode('año contable — resumen.pdf')); ->toContain("filename*=utf-8''".rawurlencode('año contable — resumen.pdf'));
}); });
/*
|--------------------------------------------------------------------------
| How long a presigned URL stays usable
|--------------------------------------------------------------------------
|
| On the local disk a delivery is an X-Accel-Redirect: it authorises one
| response, to one request. On external storage it is a presigned URL,
| which is a bearer credential -- forwardable, and valid whatever happens
| to the file or the caller's checks in the meantime. Nothing can revoke
| one, so its lifetime is the only dial there is.
|
*/
test('a download link outlives the redirect and not much else', function () {
$seen = [];
Storage::fake('files_external');
Storage::disk('files_external')->buildTemporaryUrlsUsing(
function (string $path, $expiration, array $options) use (&$seen): string {
$seen[] = $expiration;
return 'https://storage.example.test/'.$path;
}
);
$file = uploadDocumentFile($this->admin, 'report.pdf');
$file->update(['disk' => 'files_external']);
$this->actingAs($this->admin)->get("/files/{$file->id}/download")->assertRedirect();
expect($seen)->toHaveCount(1)
->and($seen[0]->getTimestamp())->toBeLessThanOrEqual(now()->addSeconds(60)->getTimestamp())
->and($seen[0]->getTimestamp())->toBeGreaterThan(now()->addSeconds(30)->getTimestamp());
});
test('a preview link lasts as long as somebody might watch', function () {
// The other half of the trade: a player holds this URL and asks it for
// ranges every time the viewer seeks past the buffer, so a minute would
// break playback of anything longer than a minute.
$seen = [];
Storage::fake('files_external');
Storage::disk('files_external')->buildTemporaryUrlsUsing(
function (string $path, $expiration, array $options) use (&$seen): string {
$seen[] = $expiration;
return 'https://storage.example.test/'.$path;
}
);
// Uploaded rather than factory-made, so it is a real previewable file;
// FilePreviewTest's helper is private to that file (Pest globals).
$this->actingAs($this->admin)->post('/files', [
'file' => UploadedFile::fake()->create('clip.mp4', 16, 'video/mp4'),
'name' => '',
'description' => '',
]);
$file = File::query()->latest('id')->firstOrFail();
$file->update(['disk' => 'files_external']);
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertRedirect();
expect($seen)->toHaveCount(1)
->and($seen[0]->getTimestamp())->toBeGreaterThan(now()->addMinutes(50)->getTimestamp());
});
@@ -269,3 +269,68 @@ test('saving clears a stale outage warning', function () {
expect(CaptchaVerifier::lastError())->toBeNull(); expect(CaptchaVerifier::lastError())->toBeNull();
}); });
// Capability::CaptchaConfigure. Present in both editions, so nothing here
// changes for anybody until an operator subtracts it — which is the whole
// point of the key: a hosted fleet shares one parent domain and one
// sending reputation, and a tenant switching its own CAPTCHA off spends
// the rest of the fleet's.
test('the screen is open by default in both editions', function () {
foreach ([Edition::Community, Edition::Cloud] as $edition) {
config()->set('projectsend.edition', $edition);
$this->actingAs($this->admin)->get('/system/settings/captcha')->assertOk();
}
});
test('withdrawing the capability closes the screen', function () {
config()->set('projectsend.edition', Edition::Cloud);
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
$this->actingAs($this->admin)->get('/system/settings/captcha')->assertNotFound();
});
// The half that actually protects the fleet. Closing the read alone would
// leave a hand-crafted PATCH able to do the damage, and turning the
// CAPTCHA off needs none of the fields the controller gates per field —
// `provider: none` does it, and so does unticking the four form switches
// while leaving perfectly good keys in place.
test('withdrawing the capability closes the write, keys or no keys', function () {
config()->set('projectsend.edition', Edition::Cloud);
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
app(Settings::class)->set(Setting::CaptchaProvider, 'turnstile');
$this->actingAs($this->admin)
->patch('/system/settings/captcha', captchaPayload(['provider' => 'none']))
->assertNotFound();
$this->actingAs($this->admin)
->patch('/system/settings/captcha', captchaPayload([
'provider' => 'turnstile',
'site_key' => 'site-abc',
'secret_key' => 'secret-abc',
'on_login' => false,
'on_registration' => false,
'on_password_reset' => false,
'on_public_comments' => false,
]))
->assertNotFound();
$settings = app(Settings::class);
expect($settings->get(Setting::CaptchaProvider))->toBe('turnstile')
->and($settings->get(Setting::CaptchaOnLogin))->toBeTrue()
->and($settings->get(Setting::CaptchaOnRegistration))->toBeTrue()
->and($settings->get(Setting::CaptchaOnPasswordReset))->toBeTrue()
->and($settings->get(Setting::CaptchaOnPublicComments))->toBeTrue();
});
test('withdrawing the capability closes the test button too', function () {
config()->set('projectsend.edition', Edition::Cloud);
config()->set('projectsend.capabilities_disabled', 'captcha.configure');
$this->actingAs($this->admin)
->post('/system/settings/captcha/test', ['provider' => 'turnstile', 'secret_key' => 'secret-abc'])
->assertNotFound();
});
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Captcha\Captcha;
use App\Modules\Platform\Captcha\CaptchaProvider;
use App\Modules\Platform\Captcha\CaptchaSettings;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
beforeEach(function () {
// Settings outlive the per-test rollback, so nothing here may assume a
// default — see the same note in CaptchaSettingsTest.
$settings = app(Settings::class);
$settings->set(Setting::CaptchaProvider, 'turnstile');
$settings->set(Setting::CaptchaKeySource, 'own');
CaptchaSettings::for(CaptchaProvider::Turnstile)
->fill(['site_key' => 'site-abc', 'secret_key' => 'secret-abc'])
->save();
config()->set('projectsend.edition', Edition::Community);
config()->set('projectsend.captcha.disabled', false);
config()->set('projectsend.captcha.managed', ['provider' => null, 'site_key' => null, 'secret_key' => null, 'score_threshold' => 0.5]);
Captcha::forgetDisplayCache();
});
test('it switches the captcha off and keeps the keys', function () {
expect(app(Captcha::class)->active())->not->toBeNull();
$this->artisan('projectsend:captcha-off')
->expectsOutputToContain('CAPTCHA is off')
->assertSuccessful();
expect(app(Captcha::class)->active())->toBeNull()
// The whole point of the command: a way back in, not a way to lose
// a credential somebody wants again in ten minutes.
->and(CaptchaSettings::for(CaptchaProvider::Turnstile)->secret_key)->toBe('secret-abc');
});
// The setting this command writes is not where managed keys come from.
// Captcha::resolve() returns managedConfig() before it ever reads
// Setting::CaptchaProvider, so on a managed installation the write lands
// somewhere nothing reads — and the old success message sent an operator
// who was still being challenged away from the only thing that would have
// explained why.
test('it says plainly that it changed nothing when the platform supplies the keys', function () {
config()->set('projectsend.edition', Edition::Cloud);
config()->set('projectsend.captcha.managed', [
'provider' => 'turnstile',
'site_key' => 'managed-site',
'secret_key' => 'managed-secret',
'score_threshold' => 0.5,
]);
app(Settings::class)->set(Setting::CaptchaKeySource, 'managed');
Captcha::forgetDisplayCache();
$this->artisan('projectsend:captcha-off')
->expectsOutputToContain('Nothing changed')
->expectsOutputToContain('PROJECTSEND_CAPTCHA_DISABLED')
->doesntExpectOutputToContain('CAPTCHA is off')
->assertSuccessful();
// And it really did change nothing: the forms are still protected.
expect(app(Captcha::class)->active())->not->toBeNull();
});
// The env switch is checked ahead of the key source, which is what makes
// it the one that works on a managed installation. If that ordering ever
// moves, a locked-out operator loses their last way in.
test('the environment switch turns off even the platform keys', function () {
config()->set('projectsend.edition', Edition::Cloud);
config()->set('projectsend.captcha.managed', [
'provider' => 'turnstile',
'site_key' => 'managed-site',
'secret_key' => 'managed-secret',
'score_threshold' => 0.5,
]);
app(Settings::class)->set(Setting::CaptchaKeySource, 'managed');
config()->set('projectsend.captcha.disabled', true);
Captcha::forgetDisplayCache();
expect(app(Captcha::class)->active())->toBeNull();
});
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
use Illuminate\Support\Facades\Event;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->admin = User::factory()->create();
});
/*
|--------------------------------------------------------------------------
| Two seams a package fills, and core does not
|--------------------------------------------------------------------------
|
| Both are dispatched unconditionally, and with nothing listening the
| documented default holds — no links, no callout. That is what makes them
| safe to add to a community installation that will never have a listener.
*/
test('with nothing listening the dashboard is exactly what it was', function () {
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement', null),
);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('extra_nav_links', []),
);
});
test('a listener can put a message in front of staff', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Heads up', 'Something worth reading.', 'Do the thing', 'https://example.test/', 'warning');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('announcement.title', 'Heads up')
->where('announcement.action_url', 'https://example.test/')
->where('announcement.tone', 'warning'),
);
});
test('a listener can add a sidebar link', function () {
Event::listen(ResolvingNavigationLinks::class, function (ResolvingNavigationLinks $event): void {
$event->add('Somewhere else', 'https://example.test/', external: true);
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('extra_nav_links.0.title', 'Somewhere else')
->where('extra_nav_links.0.external', true),
);
});
// The sidebar is the administration area. A client's portal shows their
// own files and nothing about the installation, so these must not reach
// them however careless a listener is.
test('a client gets no contributed links, even from a listener that adds unconditionally', function () {
Event::listen(ResolvingNavigationLinks::class, function (ResolvingNavigationLinks $event): void {
$event->add('Staff only really', 'https://example.test/');
});
$client = User::factory()->client()->create();
$this->actingAs($client)->get('/my-files')->assertInertia(
fn (AssertableInertia $page) => $page->where('extra_nav_links', []),
);
});
// One band. A dashboard that can accumulate banners accumulates them, and
// the second is what teaches people to skip the first.
test('the first listener to set a callout keeps it', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('First', 'Set first.');
});
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Second', 'Should not win.');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement.title', 'First'),
);
});
test('an unknown tone falls back rather than rendering unstyled', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('T', 'B', tone: 'chartreuse');
});
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement.tone', 'info'),
);
});
// The header icon and the dashboard band read one shared prop, so a
// message reaches somebody who never opens the dashboard. Two props would
// have drifted the first time anybody edited one.
test('the same message is available away from the dashboard', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
$event->show('Everywhere', 'Not only on the dashboard.');
});
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement.title', 'Everywhere'),
);
});
// A client's header carries the bell too. Nothing addressed to staff may
// appear there, however careless the listener.
test('a client is never shown one, even from a listener that sets it unconditionally', function () {
Event::listen(ResolvingAnnouncement::class, function (ResolvingAnnouncement $event): void {
if (! $event->isStaff) {
return;
}
$event->show('Staff only', 'Not for clients.');
});
$client = User::factory()->client()->create();
$this->actingAs($client)->get('/my-files')->assertInertia(
fn (AssertableInertia $page) => $page->where('announcement', null),
);
});
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Platform\Capabilities\Edition;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->admin = User::factory()->create();
// Settings survive the per-test rollback, so nothing here may assume
// a default — see the note in CaptchaSettingsTest.
$settings = app(Settings::class);
$settings->set(Setting::CheckForUpdates, true);
$settings->set(Setting::FetchNews, true);
config()->set('projectsend.edition', Edition::Community);
});
/*
|--------------------------------------------------------------------------
| Two daily calls out of the container, and who may stop them
|--------------------------------------------------------------------------
|
| An operator could stop neither. The news feed had no switch of any kind,
| and the update check had one whose default is on — so a managed fleet
| believed it had disabled updates through an environment variable that
| nothing in this application reads.
|
| They are not the same case, and are not fixed the same way. Which
| mechanism each gets is the point of these tests.
*/
test('the news feed can be switched off, and says so rather than failing', function () {
Http::fake();
app(Settings::class)->set(Setting::FetchNews, false);
$this->artisan('projectsend:fetch-news')
->expectsOutputToContain('switched off')
->assertSuccessful();
// Not merely "no items stored" — the request never left.
Http::assertNothingSent();
});
test('the news feed is on by default, so nothing changes for an existing install', function () {
Http::fake(['*' => Http::response([])]);
$this->artisan('projectsend:fetch-news')->assertSuccessful();
Http::assertSentCount(1);
});
// The news itself is both editions — a Cloud client with view_news sees
// that card. What is Community-only is the *choice*: announcements about
// the product are what a hosted customer should be told, and one
// administrator switching them off for everybody on that instance is not
// a decision the platform hands over.
//
// The exact opposite of the update check below, which does not run on a
// managed instance at all. The two look alike and point in different
// directions, so both directions are pinned.
test('a managed instance fetches the news whatever its setting says', function () {
Http::fake(['*' => Http::response([])]);
config()->set('projectsend.edition', Edition::Cloud);
// Off — including a row left behind by an instance that used to be
// self-hosted, which is the case that would otherwise go silent.
app(Settings::class)->set(Setting::FetchNews, false);
$this->artisan('projectsend:fetch-news')->assertSuccessful();
Http::assertSentCount(1);
});
test('a managed instance is not offered the switch, and cannot be sent it', function () {
config()->set('projectsend.edition', Edition::Cloud);
app(Settings::class)->set(Setting::FetchNews, true);
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
fn (Inertia\Testing\AssertableInertia $page) => $page
->where('can_configure_news', false)
->where('fetch_news', null),
);
// A hand-crafted PATCH must not do what the absent checkbox could not.
$this->actingAs($this->admin)
->patch('/system/settings/general', generalPayload(['fetch_news' => false]))
->assertRedirect();
expect(app(Settings::class)->get(Setting::FetchNews))->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| The update check is the other kind
|--------------------------------------------------------------------------
|
| On a managed installation the result is unreachable rather than
| unwanted: the dashboard's System card and the update UI are both gated
| on Capability::SystemUpdates, which is Community-only, and the image is
| chosen by whoever provisioned the instance. That is a fact about the
| edition, not a preference — so it is a capability, not a Setting.
*/
test('the update check does not run where its answer could never be seen', function () {
Http::fake();
config()->set('projectsend.edition', Edition::Cloud);
// On, and it still must not call out: the capability decides first.
app(Settings::class)->set(Setting::CheckForUpdates, true);
$this->artisan('projectsend:check-for-updates')
->expectsOutputToContain('do not apply')
->assertSuccessful();
Http::assertNothingSent();
});
test('a self-hosted install keeps its own switch, both ways', function () {
Http::fake(['*' => Http::response([])]);
app(Settings::class)->set(Setting::CheckForUpdates, false);
$this->artisan('projectsend:check-for-updates')
->expectsOutputToContain('disabled')
->assertSuccessful();
Http::assertNothingSent();
app(Settings::class)->set(Setting::CheckForUpdates, true);
$this->artisan('projectsend:check-for-updates')->assertSuccessful();
Http::assertSentCount(1);
});
/*
|--------------------------------------------------------------------------
| Reachable without a shell
|--------------------------------------------------------------------------
|
| A setting an operator cannot find is not a switch, it is a row. The
| update toggle beside it is hidden where the capability is absent; this
| one must not be, because the card it controls is shown in both editions.
*/
test('a self-hosted installation is offered the switch', function () {
$this->actingAs($this->admin)->get('/system/settings/general')->assertInertia(
fn (Inertia\Testing\AssertableInertia $page) => $page
->where('can_configure_news', true)
->where('fetch_news', true),
);
});
test('saving the settings page can turn the feed off and on', function () {
Http::fake();
$this->actingAs($this->admin)
->patch('/system/settings/general', generalPayload(['fetch_news' => false]))
->assertRedirect();
expect(app(Settings::class)->get(Setting::FetchNews))->toBeFalse();
$this->artisan('projectsend:fetch-news')->assertSuccessful();
Http::assertNothingSent();
$this->actingAs($this->admin)
->patch('/system/settings/general', generalPayload(['fetch_news' => true]))
->assertRedirect();
expect(app(Settings::class)->get(Setting::FetchNews))->toBeTrue();
});
/** The general form posts every field it owns; only the interesting one varies. */
function generalPayload(array $overrides = []): array
{
return array_merge([
'site_name' => 'ProjectSend',
'timezone' => 'UTC',
], $overrides);
}
+13 -1
View File
@@ -31,7 +31,11 @@ test('community edition has the self-management capabilities and no cloud exclus
->and($registry->has(Capability::AttributionHide))->toBeFalse() ->and($registry->has(Capability::AttributionHide))->toBeFalse()
// The counterpart of StorageConfigure above: a self-hosted install // The counterpart of StorageConfigure above: a self-hosted install
// configures its own bucket and is never handed one. // configures its own bucket and is never handed one.
->and($registry->has(Capability::StorageManaged))->toBeFalse(); ->and($registry->has(Capability::StorageManaged))->toBeFalse()
// Both editions, and the self-hosted side is the reason it must
// stay present by default: nobody else supplies this
// installation's CAPTCHA keys.
->and($registry->has(Capability::CaptchaConfigure))->toBeTrue();
}); });
test('cloud edition has cloud exclusives and none of the community-only capabilities', function () { test('cloud edition has cloud exclusives and none of the community-only capabilities', function () {
@@ -43,6 +47,9 @@ test('cloud edition has cloud exclusives and none of the community-only capabili
// not decide who fills them. See the case's own comment. // not decide who fills them. See the case's own comment.
->and($registry->has(Capability::UsersManage))->toBeTrue() ->and($registry->has(Capability::UsersManage))->toBeTrue()
->and($registry->has(Capability::StorageManaged))->toBeTrue() ->and($registry->has(Capability::StorageManaged))->toBeTrue()
// Granted here too. A hosted platform closes the CAPTCHA screen by
// subtracting this key, not by the edition withholding it.
->and($registry->has(Capability::CaptchaConfigure))->toBeTrue()
->and($registry->has(Capability::StorageConfigure))->toBeFalse() ->and($registry->has(Capability::StorageConfigure))->toBeFalse()
->and($registry->has(Capability::EmailTransportConfigure))->toBeFalse() ->and($registry->has(Capability::EmailTransportConfigure))->toBeFalse()
->and($registry->has(Capability::SystemUpdates))->toBeFalse() ->and($registry->has(Capability::SystemUpdates))->toBeFalse()
@@ -60,6 +67,11 @@ test('enabledKeys returns the string keys of enabled capabilities', function ()
'branding.customize', 'branding.customize',
'attribution.hide', 'attribution.hide',
'storage.managed', 'storage.managed',
// Both editions, present by default. It appears in a Cloud
// instance's keys until the platform names it in
// PROJECTSEND_CAPABILITIES_DISABLED, which is how the fleet keeps
// one tenant from switching its CAPTCHA off.
'captcha.configure',
'captcha.managed_keys', 'captcha.managed_keys',
'platform.managed', 'platform.managed',
'ai.connector', 'ai.connector',