39 Commits

Author SHA1 Message Date
ignacionelson b8050b36ca Release 2.4.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:59:02 -03:00
ignacionelson da5aadd1f3 Cut the 2.4.1 entry down to what changed, and add what was missing
Three entries were absent. A sweep of every commit since v2.4.0 for code
changes with no changelog line returned thirteen; ten were correctly absent
— the announcement work is a seam with no content on a self-hosted install,
the client share link is always null unless a platform module mints one, the
quota floor is a platform environment variable, and the email_verified_at
change is inert while MustVerifyEmail is off. The other three were real:

  - the IAM-role feature, a visible control on the storage settings screen
    that every self-hosted administrator can reach, with no entry at all;
  - #1770, a Docker upgrade that fails outright when external storage is
    already configured, which is exactly what a changelog is for;
  - download counts on a client's own files, which OwnFileDownloads gates
    on nothing, so it is live everywhere.

Every entry is now one line. The explanatory paragraph, the "who this
affected" note and the upgrade advice are gone from the change list; what an
operator must actually do was already collected at the top and stays there,
because a title alone cannot be acted on.

Reordered so the account takeovers lead rather than sitting ninth and
eleventh behind a thumbnail-rendering fix, and the two public-folder entries
sit together — they are one boundary reported in two halves, and had six
entries between them.

All seven reporters keep their credit, moved inline.

Version is the user's call, recorded here rather than argued: 2.4.1. Note
that the file's own rule above says the last number moves when there are
only fixes, and this entry has an Added section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:55:06 -03:00
ignacionelson 386cb32ebb Translate the four strings that accumulated since 2.4.0
Sixteen locales, four strings each, which is the whole of what had drifted:
three from the IAM-role work (the toggle, its explainer, and the warning
that saving clears a stored key and secret) and one from this week's upload
limits ("Too many uploads are already in progress").

Terminology was taken from each catalogue rather than chosen: every locale
already had "Access key" and "Secret key", and the new strings reuse those
words exactly, so the explainer reads in the same vocabulary as the two
fields directly beneath it. Product nouns stay as they are — AWS, IAM, ECS,
EC2, EKS/IRSA, MinIO, Backblaze, Wasabi, ProjectSend.

Formality was read off each file instead of assumed: informal in ca, es, it,
nl and zh_CN, formal in cs, de, fr, id, pl, pt_BR, ru and tr, matching what
the neighbouring sentences already do. Spanish has three voseo entries among
thirty-three tuteo ones — they arrived with the password-reset work on
2026-09-08 (df44c46a, 6339ae15) and are the outliers, so these follow the
tuteo the rest of the file uses. Worth settling one way or the other by
somebody who speaks it, which is not a job for this commit.

The edits are additive: no entry reordered or rewritten, so the diff is the
four new lines per file and the comma the previous last line grew.

Verified past the point where a JSON file merely parses. The scanner reports
0 missing across all sixteen, the Locale suite passes, and the Spanish
strings were read off a rendered page in a browser — signed in, interface
switched to Spanish through the app's own control, with the explainer
wrapping to four lines inside its column and reading in the same words as
the "Clave de acceso" and "Clave secreta" labels under it. That screen only
draws its S3 half when the provider is S3 and the dev instance is on GCS, so
the provider was flipped for the reading and put back; it was snapshotted
first and restored to what it was, not to a default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:26:08 -03:00
ignacionelson 38400956bc Put the installation's own logo on the pages people sign in through
Requested by @Zodiac1978 in #1777.

The logo already replaced ours in the staff sidebar, on the public listing
and in the client portal. The sign-in screen still wore the ProjectSend
wordmark — and that is the first page of yours most people ever see, and
often the only one a client sees, because it is where the link in a
notification email lands them.

One layout serves every screen reached before signing in, so this covers
login, registration, both password-reset pages, the two-factor challenge,
first-run setup and the page a share link opens. That breadth is the reason
to change the layout rather than the login page: the same visitor moves
between several of them in one sitting, and a logo that appeared on one and
not the next would read as a different site.

Nothing needed gating. `branding.logo_url` is already shared on every
request and is already null wherever the Branding capability is absent, so
an installation that has withheld branding, or never uploaded anything,
renders exactly what it rendered before.

Three tests cover the server's half — the prop reaching a page nobody has
signed in to see, the null fallback, and the capability being taken away.
None of them can say whether the component mounted or the image resolved,
so that was checked in a real browser: headless Chrome against the dev
instance with a logo installed reports the <img> present, naturalWidth 360
(so it decoded rather than sitting broken) and a rendered height of 48px,
and with the logo removed reports no <img> and the fallback SVG in its
place. The branding row was snapshotted before and restored after.

One thing worth knowing, unchanged by this and not introduced by it: a logo
drawn for a white background is hard to read on the dark theme, here and on
every other surface that shows it, because none of them filter the artwork.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:00:38 -03:00
ignacionelson 8aef6e5b5a Tell an Apache install what it needs, and where its 500 is written
Reported by @Zodiac1978 in #1778, on IONOS.

Step 6 was nginx and only nginx, while "What you need" says Apache is fine.
It is fine, but not without being told two things — document root at
public/, and AllowOverride All with mod_rewrite on, or the .htaccess we ship
does nothing and every address but the home page is a 404. There is now an
Apache vhost beside the nginx one.

The 500 in the report is its own troubleshooting entry, because the entry we
had sends people to storage/logs/ and for this class of failure that
directory is empty — Apache never reached PHP, so ProjectSend had nothing to
write, and an empty log reads as a dead end rather than as the clue it is.
The error is in Apache's log. Two causes cover nearly all of them: Options
refused by AllowOverride, and the internal-redirect loop this reporter hit,
where Apache cannot derive the per-directory base and the front-controller
rule rewrites to a path that is not there, repeatedly.

public/.htaccess now carries a commented-out RewriteBase with the
explanation next to it, which is where somebody debugging a 500 is already
looking.

Only RewriteBase is documented, not the report's second change — making the
substitution absolute (`/index.php`). With the base set correctly the
relative form resolves to the same place, and the absolute one would send a
subdirectory install to the domain root's index.php instead.

The trap underneath all of this is worth its own paragraph, and nothing said
it before: update.sh merge-copies the release over the install, so an edit
to public/.htaccess is reverted on the next update and the site 500s again.
Put the directives in the vhost if it is yours to edit, since an update
cannot reach there — and on shared hosting, where it is not, keep a note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:45:26 -03:00
ignacionelson 8372f42525 Ask the picker's own question of what comes back from it
Reported by @skeletonsec as GHSA-w29w-pj29-x7ww.

Deleting an account that owns files makes the admin choose who inherits
them. The picker narrows that list for a client-scoped staff member to their
own roster, and says why two methods up: "a client-scoped staff member is
not shown the name of somebody they can reach nothing of, and a picker is no
more a reason to hand one over than a listing is."

The write asked something else entirely — exists, active, and not the
account being deleted. All three are true of every account on the
installation. So a scoped staffer could name an id the picker had
deliberately kept off the list, and a roster client's files and folders
landed with a client on somebody else's roster: readable, editable and
deletable there, because a client owns what they uploaded and
visibleToClient() includes uploaded_by.

The entry doors scope the source account and always did — guardTarget goes
through canAssignClient. It is the destination nobody scoped.

candidates() and validate() now run one predicate, reachableTargets(),
rather than two that happened to agree. Two that agree by inspection is what
this was: the narrowing existed, was correct, and was only ever applied to
the list.

The refusal deliberately reads as "no such account". An out-of-roster id and
an id belonging to nobody now produce the same message, because a refusal
that distinguishes them lets a scoped staffer walk the id space and learn
which accounts exist outside their roster. That is why Rule::exists is gone
rather than kept alongside: one code path, one answer. A test pins the two
messages as identical instead of naming either.

Both the web screen and the API twin come through this one validate(), so
both are fixed by it — and the test file proves each separately rather than
assuming the sharing holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:19:28 -03:00
ignacionelson 50f8b578df Ask the publication question wherever content lands, not just on upload
Reported by @skeletonsec as GHSA-rxf8-wh8v-jm9j.

A file in a public folder is public: isEffectivelyPublic() is "my own flag,
or my folder's", read up the whole ancestry. GHSA-237r-jx85-j3hr settled
that three days ago, put the rule in Folder::uploadableBy(), and wired it
into the upload paths.

Content arrives in a folder four other ways. move() drags one file in,
bulkUpdate() moves a selection, update() reparents through the edit form,
and FoldersController::move() drags a whole folder — every file in its
subtree — under a public parent. Each of them asked whether the destination
was *visible* to the mover and then wrote folder_id. Visible is not the same
question as publishable, and the difference is the entire permission: a
staff member given editing rights and deliberately not given upload_public
could publish confidential files to the anonymous site by choosing where
they landed. The API twin of update() had the same gap.

Both earlier advisories named these paths in their own "suggested fix"
sections. Neither demonstrated them, so neither was followed. The fix to a
report wants the scrutiny the report got, and this one did not get it.

The predicate did not need changing — it needed calling. Four sinks now ask
it, plus the API twin. The check stays split in two deliberately: the
destination is resolved through StaffLibraryScope as before, so a folder
somebody cannot see is still a 404 and not an existence oracle, and the
publication clause is a separate 403 on top. They agree by construction —
allowsFolder() is folders()->whereKey()->exists() — so nothing that used to
resolve can now fail the first half.

On the file paths the check fires only when folder_id actually changes,
which is the convention already there: re-saving a file that sits in a
folder out of the saver's scope must keep working. bulkUpdate() checks its
destination once instead, before the loop, because there is one destination
for the batch and if it publishes then no file in the batch may go.

Folder::uploadableBy()'s docblock now says to read the name as "may place
into", with why: the name is what made this easy to miss, and the next
folder_id or parent_id write will be written by somebody reading it.

Ten tests, one per sink with a private-destination control beside it, plus
an editor who *can* publish to show the boundary is about publishing and not
about moving. The last one follows the advisory's own chain to the end and
asserts the thing actually claimed — a stranger with no session, no token
and no assignment fetching the anonymous download URL. It returns 200 on the
code before this commit and 404 after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:16:23 -03:00
ignacionelson 6ad26bb61e Hold an upload to the size it said it was sending
Reported by @ry2811 as GHSA-6jh6-gvj5-pv8v.

A resumable upload declares its size, and that declaration is what store()
weighs against the maximum file size and the client's storage quota. Only
the assembled file was ever held to it. The parts in between were bounded
one request at a time and never added up, so a client could declare one
byte and then stream parts: ten thousand part numbers at twice a 20 MB
part is about 400 GB, per session, and the number of sessions was not
bounded either. None of it counted against anything, because nothing
becomes a File row until the upload completes and ClientStorageUsage sums
File rows. A client with a 1 MB quota could fill the volume and repeat.

putPart()'s own comment described this defect and treated the per-part cap
as the answer to it: "without a cap here the exposure is a day's worth of
disk". A cap on one request bounds one request. The exposure was a day's
worth of disk multiplied by however many requests somebody cared to make.

Three limits, and each one exists because the other two do not cover it.

A session may not stage more than it declared. The room for a part is
claimed before the body is read — a body's length is not known until it
has arrived, and by then it is on the disk being protected — and the write
is then capped at exactly what was claimed, so an over-long body is cut
off mid-stream as it always was, against a smaller number. The claim is a
read and a conditional update under a per-session lock, the same shape
complete() already uses: the protocol sends parts in parallel and how many
is the client's choice, so an unlocked read lets every part in flight
claim the same room, while an atomic claim alone refuses the honest
parallel upload instead. Whatever the part really weighs is settled back
afterwards, in a finally, or a client's own retries would exhaust a
session with room to spare.

Open sessions count against the quota at the size they declared. A quota
measured against finished files alone is spent twice by opening sessions
one after another — each is told there is room, because the ones before it
have not finished. The cost is that an abandoned transfer holds its share
until it is cancelled or swept, so the sweeper now runs hourly rather than
daily: that gap is now somebody unable to upload, which it was not before.

And a cap on open sessions, because for anyone with no quota to spend —
staff, and clients on an installation that sets none — the session count
is the only thing between a declared size and any multiple of it.

Four tests fail on the unfixed code, and three existing ones had to change:
they declared a tiny size and sent a large part deliberately, to reach the
re-checks at complete(). That route is now closed at putPart(), so they
reach those re-checks the way a real install would instead — the file-size
limit or the quota moving while a long transfer is running, which is the
reason complete() re-asks rather than trusting what store() decided.

The staged-byte total is BIGINT UNSIGNED, and the suite runs SQLite, which
has no unsigned integers. The first version of the bounds read
`staged_bytes + :delta BETWEEN 0 AND size` and raised SQLSTATE 22003 on
MySQL for any refund — in the comparison, so the bound written to prevent
the underflow was the statement that underflowed. Every SQLite test passed
on it. Both bounds are now arranged so the column is never inside a
subtraction, and UploadSessionStagedBytesMysqlTest skips loudly unless the
connection is MySQL. Verified against 8.4, as was the report itself: three
sessions declaring one byte each put 6 MB on the volume of a client with a
1 MB quota before, and nothing at all after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:05:41 -03:00
ignacionelson 83a8fe2288 Claim the installation instead of checking whether it is free
Reported by @ry2811 as GHSA-w3w9-prpw-qx77, with a working two-worker
reproducer.

Setup asked the database whether any staff user existed, and created one
some time later, in a separate statement with nothing joining the two. So
two POSTs arriving together both read "no staff" and both inserted a System
Administrator. Different addresses do not collide; `users.email` is the only
unique key and it has nothing to say about there being one first
administrator.

The gap is not narrow. Between the check and the insert sits password
hashing at BCRYPT_ROUNDS=12, which is slow on purpose, so the window is
hundreds of milliseconds wide and observable without trying.

What makes this worth fixing is not that a stranger can set up an
unconfigured installation — first-run setup is open to whoever reaches it
first, and always was. It is that racing the operator is *quiet*. The
operator's own request also succeeds, also redirects to /setup/success, and
the installation they get looks exactly like the one they expected. The
second administrator is discovered later or not at all, and closing setup
afterwards does not revoke it.

FirstAdministrator::claim() makes it one operation. The row it locks is the
System Administrator role, because the obvious candidate cannot work: there
are no staff rows on a fresh install and a lock over an empty result
serialises nothing. That role row is written by the roles migration and
rewritten on every boot, so it is always there to be locked. The second
caller waits on it, and by the time it has the lock the first caller's user
is committed and visible to the re-check it then makes.

Everything the request writes moved inside the claim, including the site
name. A request that loses now writes nothing at all, rather than renaming
the installation on its way to the login screen.

`projectsend:admin --if-none` had the same shape and is fixed the same way
— two containers coming up against one database is the version of this that
needs no attacker. The early check stays where it is so an unattended boot
does not prompt for a password it is about to discard; it is simply asked
again under the lock.

Both tests fail on the unfixed code. They stage the interleaving rather than
attempting real concurrency, creating the winning administrator from a query
listener after the request has made its first check — which is exactly the
window, and the re-check is the only thing that closes it. The lock itself
is invisible to them: the suite runs SQLite, where lockForUpdate() compiles
to nothing. That half was verified against MySQL 8.4 by running the
reporter's race for real, two processes through the full HTTP kernel: two
administrators before, one after, repeatably.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 11:34:26 -03:00
ignacionelson 62c763d04e Put a floor under a client quota nobody set
Setting::DefaultClientStorageQuotaMb defaults to 0, and 0 means
unlimited. That is the right default for somebody setting up their own
installation and the wrong one for an installation a platform operates
on other people's behalf: an account that arrived without an explicit
quota has no ceiling at all, and it does not have to be an account the
platform created.

So a platform may set a floor in the environment
(PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB), exactly as it sets the
seat caps, and for the same reason those are not settings: it is the
shape of what was sold rather than a preference the installation's
administrator is expressing. It applies only where the setting says
nothing, so an administrator who chose a number keeps it, and an install
with no platform behind it is unaffected.

ClientStorageUsage::defaultQuotaMb() is where the three sources resolve,
and every screen that presents the answer now reads it there:

  - The client create and edit screens. The edit screen mirrors that
    resolution client-side to draw the usage bar, so handed the raw
    setting on a floored installation it computed an effective quota of
    zero, printed "unlimited" and hid the bar entirely -- for a client
    whose next upload was about to be rejected for exceeding a limit the
    screen said did not exist.

  - projectsend:status, which gains clients_can_register and
    default_client_storage_quota_mb. Both defaults are the permissive
    ones, both are invisible from outside, and a document reporting the
    setting while uploads obeyed the floor would say the ceiling was
    missing on an installation that has one.

The Client settings form deliberately still reads the raw setting: that
field is read and written back on save, so prefilling it with the floor
would write the platform's number into the setting as the
administrator's own choice, where it would outlive the floor.
2026-09-11 00:41:31 -03:00
ignacionelson 0671848bfa Read settings written before the columns they name existed
Reported by @apps3000 in #1770. Upgrading a container from 2.0 or 2.1
with external storage configured restart-loops, and says the database is
unreachable while the database is fine.

A row hydrated from the database does not get the model's column
defaults — only a new model does. So a row written before
external_storage_settings.provider existed reads that column as null,
and the enum match in isConfigured() throws UnhandledMatchError.

That would be a small bug anywhere else. It is not here, because
PlatformServiceProvider::boot() reads these settings on every process
boot, and boot happens before `artisan migrate` runs. During an upgrade
the code is new and the schema is still old, so every artisan command in
that window dies — including `projectsend:update`, the one that would
have added the column. Reordering the entrypoint or using a lighter
readiness probe does not help for that reason; the crash is in the
bootstrap, not in the probe.

current() now applies the model's declared defaults to any column the
hydrated row does not have. That closes the window for every column with
a default rather than for the one where it was found, and goes inert the
moment the schema is current. The match in isConfigured() is left total
on purpose: a default arm would swallow a real unhandled case, and the
invariant it needs now holds at the one place the row is read.

The probe's message is the other half. It boots the whole application,
so it fails both when the database is absent and when the application
cannot start, and it reported the second as the first — sending an
operator off checking credentials that were never wrong. It now prints
the error it actually hit and says which of the two it looks like.

Verified end to end against a 2.1-shaped database: `artisan migrate`
dies with UnhandledMatchError before the change and completes after it,
leaving the row reading as S3 with its bucket intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
2026-09-10 17:23:26 -03:00
Ignacio Nelson 469297893b Merge pull request #1775 from projectsend/s3-instance-role
Support AWS IAM roles for S3 storage
2026-09-10 16:38:50 -03:00
ignacionelson eaba7ff633 Let an AWS-hosted install authenticate as its own IAM role
Requested by @ToMMy86 in #1773: an install running on ECS, EC2 or EKS
already has a role attached, and making it also create an IAM user with
a long-lived access key is both extra work and a worse security posture
than the one AWS offers.

The AWS SDK resolves credentials from its default provider chain
whenever none is supplied, and Laravel's FilesystemManager already omits
the `credentials` entry when the key and secret are empty — so the
upload path needed almost nothing. What blocked it was ours:

- `isConfigured()` demanded a key and a secret for S3, so a
  credential-less row was never "configured" and every upload silently
  stayed on the local disk.
- `access_key` was `required_if:provider,s3` on both the save and the
  connection test.
- `probeS3()` built an explicit `credentials` array, so Test connection
  would have failed even once uploads worked.

An explicit `use_instance_role` column rather than "the key was left
blank", because blank already means "keep the credential you have" on
this form — neither the secret nor the GCS key file is ever sent back to
the browser. Ticking it deletes the stored key and secret rather than
leaving them in the row for the next database dump.

Unchanged for everyone else: MinIO, Backblaze, Wasabi and any other
S3-compatible service still authenticate with a key and secret, and the
region is still required — the chain resolves credentials, not regions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
2026-09-10 16:16:31 -03:00
ignacionelson 6339ae1514 Answer a failed reset the same way whatever failed
The screen leaked account existence a second way, through the write, and
this one is older than last night's: it is the scaffolding. Laravel
answers a failed reset with passwords.user for an address it cannot find
and passwords.token for a real one whose token is dead, and the controller
surfaced __($status) straight through. Two sentences, one difference, and
the difference is whether the account is here.

passwords.throttled is the third and the sharpest. The broker throttles
per user, so an address nobody holds can never be throttled — being told
to wait is being told the account exists.

All of them collapse to one sentence now. Nothing is lost: the action is
the same in every case, and /forgot-password one step earlier already
refuses to say whether an address has an account. Keeping three messages
was only ever more precise about a thing we had decided not to say.

Found because the portal session went looking for the GET oracle I had
just fixed, found theirs, and also found a POST variant I had not thought
to check. I had it too.

The test asserts the two refusals are identical rather than naming the
sentence, so it survives the wording changing.
2026-09-09 08:04:54 -03:00
ignacionelson 7c4b582d25 Stop the expired-link notice saying whether an account exists
I built the oracle in the commit whose docblock describes preventing it.
The comment said a page answering "expired" for a real address and
something else for an unknown one would tell anybody who typed a guess
whether an account is here — and then the method returned false for an
unknown address and true for a known one. Two branches, two answers, and
the difference was the account.

/forgot-password deliberately says "a link will be sent if the account
exists". This undid that on the next screen along.

Both branches answer the same now: anything that will not validate reads
as expired, whether the address is known, unknown or absent. The message
stays right in every case somebody real will meet — a mistyped address
gets "ask for a new link", which is what they should do anyway — and the
page reveals nothing.

The test is written as "these two are the same answer" rather than "both
are false", so it keeps holding if somebody later changes which answer it
is. The two tests that encoded the oracle asserted `expired` was false for
an unknown address; they were pinning the bug.

Found by asking my own question of my own code. The portal session had
checked whether their collation folded addresses, which sent me back to
the reset screen to see what it does with an address it cannot place.
2026-09-09 07:53:30 -03:00
ignacionelson b96d060ad8 Compare an address ourselves, instead of asking the collation
Reported by @choewonwoo1817 as GHSA-wgxf-v8cr-37mj, with a working
end-to-end reproducer against Keycloak.

`where('email', $address)` is not an exact match. It is whatever the
database says equality means, and the collation INSTALL.md tells people to
create — utf8mb4_unicode_ci — folds accents:

    administrator@example.com = administrator@éxample.com   -> 1

Those are two different domains. The second is xn--xample-9ua.com, which
somebody else can register and honestly verify at an OIDC provider. So an
attacker with no account here could sign in as themselves and be handed
the first account: SocialAuthenticator found it, linked their subject to
it permanently, and started a session. No password, no interaction from
the owner, an administrator session where that account was one.

Comparison now happens in PHP, in one place, on every driver. Case is
still folded because that is a real requirement — addresses are stored
lowercased and a provider may send any case — and mb_strtolower folds case
without folding accents, which is exactly the line to draw.

Three call sites move to it and two deliberately do not. Loose matching is
right when *refusing* and wrong when *selecting*: AvailableEmailRule and
ClientProvisioning ask "is this address free", where a collation that says
no to a near-miss refuses more registrations, which is the safe direction.
The three that ask "which account is this" are the social path, the login
form (where a password still gated it, so it was confusion rather than
takeover) and the erasure command (irreversible, and the wrong row is the
wrong person).

The test story is the part worth reading. The suite runs on SQLite, whose
`=` is byte-exact, so this defect does not exist there and never did —
which is how it survived six releases with everything green. A test
written the obvious way passes on unfixed code. So the comparison is
pinned by driver-independent tests that always run, and the chain is
proved by AccountLookupCollationTest, which skips unless the connection is
MySQL and carries the command to run it. Run against real MySQL with the
real collation: it fails on the old code and passes on the new.
2026-09-09 07:32:26 -03:00
ignacionelson 6d7d80f62f Give the announcement band its own row, and put it on the dashboard too
All four themes had it as a flex child of the row holding the heading and
the buttons, so it was never full width: it took part of the line and
squeezed "My files" and "Upload a file" into a narrow column beside it. It
now sits above that row, which is where the staff dashboard has always put
it and where the comment claimed it was.

Worth noting why four themes shipped it wrong. The band renders nothing
for almost every viewer — it needs a hosted instance, a free plan, and a
client looking — so the broken layout was invisible to the suite, to the
build, and to anybody working on those pages. Seen only once somebody on
the real free tier looked at it.

Also adds it to the client's dashboard. That screen is about the account
rather than about the files, which is the more natural place for an offer
about the plan, and both read the same shared prop so they cannot disagree
about what is said or to whom.

Checked with real screenshots in all four themes and on the dashboard,
against a temporary listener standing in for cloud-modules, since this
install is community and would otherwise render nothing. The listener and
the theme setting were both put back.
2026-09-09 01:04:15 -03:00
ignacionelson bc559ade3f Prove a client's upload announces itself, not just a staff one
The test above this said "whichever path stored it" and only exercised
the plain staff POST. The path the hosted free tier hangs on is the other
one: a client, through the resumable flow, whose upload is what
cloud-modules listens for to mint the public link.

Worth its own test rather than trusting the shared StoreUploadedFile,
because the package's suite structurally cannot tell us. It fakes both the
event and the link-minting, so a chunked path that stopped dispatching
would leave all 140 of its tests green and the free tier silently inert on
a real instance. That gap is why the listener was checked against
sim-cloud by hand rather than believed; this is the half of it that
belongs in core and runs on every commit.

The counter-check is worth a note. The first attempt at it changed
nothing — `\$file` inside a sed pattern is a literal, so the substitution
never matched and all six tests passed, which reads exactly like a fix
that is not load-bearing. Confirmed the mutation landed by counting the
line before re-running: three tests fail without the dispatch, the new one
among them.
2026-09-08 21:12:50 -03:00
ignacionelson df44c46a12 Say a reset link has expired before asking for the work
The page rendered the form without looking at the token, so somebody
opening a link an hour late typed a password, typed it again to confirm,
and was then told "this password reset token is invalid" — a word nobody
outside the code knows, at the end rather than the start. Links last an
hour and people open them late. That is ordinary, not an error to be
scolded for.

store() still validates and is still the rule; there is a test that a
spent token is refused there whatever the page drew. This is only the
screen being honest a minute earlier.

An address that is missing, or belongs to nobody, is drawn as the form was
before. Partly because an unanswerable question is not an expired link,
but mostly because a page that said "expired" for a real address and
something else for an unknown one would answer whether an account exists
here to anybody typing guesses — the exact property /forgot-password
protects by saying "a link will be sent if the account exists". Two tests
pin that.

Worth having now rather than later: the advisories publishing with this
release will send more people than usual through this screen, in a hurry
and some of them frightened.

Found by the portal session's user, who opened a real link an hour and
forty minutes after it was sent.
2026-09-08 20:08:28 -03:00
ignacionelson a1f59e133b Translate the strings the security fixes added
Three, sixteen locales: the Entra optional-claim instruction on the
social-login screen, and the two the profile screen gained when changing
an address started asking for a password.

The catalogues gate the release build, so these had to land before the
version could be stamped.
2026-09-08 19:59:31 -03:00
ignacionelson 0a3410140d Keep an erased staff member's library away from a client
The reassignment target is one installation-wide id used for every
erasure, and the picker offers clients deliberately: erasing a client and
handing their files to another client is what the setting is for.

Applied to a staff account the same id means something else. A staff
library is usually the whole installation's, so a client named there
inherits all of it — through an unattended scheduled job, with no
per-account confirmation, because this is the default rather than a choice
somebody makes at the moment of deleting.

So a staff account's content may only go to staff. With nobody valid to
hand it to, handleContent() already cascades, which keeps the existing
promise that content is never orphaned — it now also never becomes a
disclosure.

Not in the settings validation, which is where it looks like it belongs.
That runs when the target is chosen, and whose account will be erased
later is not knowable then. Both halves are only in hand here.

Found while checking a list from the portal session, who had it as one
where() on `type`. That would have been too broad: it would also have
stopped a client's files reaching another client, which is the case the
setting exists to serve. The condition is on the account being erased,
not on the target alone.
2026-09-08 19:36:48 -03:00
ignacionelson 80cf99d80e Put what the operator must do at the top, and mark it
The upgrade notes sat at the bottom of a release entry, after every list
of what changed. That is the wrong end of the page: somebody deciding
whether to upgrade reads the first screen and stops, and that is exactly
the reader who needs to know a permission stopped working or a value
changed meaning.

So the section moves to the top of the entry, gets a heading nobody
skims past, and opens by saying what these items have in common —
everything else in a release happens on its own, and these do not. It also
says plainly that none of them stops the upgrade, because an operator who
cannot tell "the installation will not start" from "one role gets a 403"
plans the wrong maintenance window.

Three items for this release: the CAPTCHA flag that now means the
opposite for anybody who typed something other than true or 1, the public
folder permission that is now asked of staff, and the Entra claim.

Releases before this keep the old "Upgrade notes" heading — rewriting
published entries would change what people were told at the time. The
file's own header names both, and the release skill now carries the new
shape so the next one does not drift back.
2026-09-08 19:15:10 -03:00
ignacionelson cbc6760a93 Warn that a mistyped CAPTCHA flag now means the opposite
The fix reads only true and 1 as "disabled". An operator who wrote
"off" and has been running without a CAPTCHA gets it back on this
upgrade, which is correct and is still a surprise if nobody says so.
2026-09-08 19:10:13 -03:00
ignacionelson d8ca41ae0c Credit the reporters by their GitHub handles
One of the four said 'the same researcher as the group finding above',
which was wrong: @skeletonsec reported the public-folder and upload-target
findings, @Drescargot the group one. The other two were credited by name
rather than by handle, which is not how anybody finds them.
2026-09-08 19:08:27 -03:00
ignacionelson 1149df277b Ask for the public-folder key before publishing through a folder
Reported as GHSA-237r-jx85-j3hr.

A file is public if its own flag is on or its folder's is, so the upload
destination reaches the property `upload_public` guards without touching
the switch. A staff member allowed to upload but deliberately not allowed
to publish could publish to the anonymous public site by choosing where
the file landed.

No new key. `upload_to_public_folders` already exists, already appears on
every role's checkboxes, and already means exactly this on the client
branch of the same method — MyFilesController's picker calls it the
established meaning of the two keys. It was never asked of staff, so on a
staff role that checkbox did nothing at all: an unenforced permission, the
class this project audited and closed once already.

Effectively public rather than the folder's own flag, because the flag is
inherited down a subtree: a private folder inside a public one publishes
just the same, and a check on the folder's own column walks past it. There
is a test for that case specifically.

One place, because every upload path — the plain POST, the chunked flow,
the API and the client portal — already asks Folder::uploadableBy(). The
sibling report about the target folder not being scope-checked at all
(GHSA-56qr-cq56-qg66) was fixed in 2c2b86ff and is what put the scope
check on the line above this one.
2026-09-08 19:05:53 -03:00
ignacionelson ab5fa2da8b Make Entra prove the address, not just the directory
Reported by Dickson Massawe as GHSA-2rfh-v3j2-2jg7.

Pinning the tenant was half an answer. It defeats the classic
cross-tenant nOAuth, where a stranger's own directory asserts your
address, because a foreign tenant carries a different tid. It does
nothing about the same attack from inside the pinned tenant: Entra's
email claim is user-mutable — a B2B guest's otherMails among its sources
— so a colleague or an invited guest could present an administrator's
address and have their subject bound to that account.

Tenant-pinning answers "which directory said this". It never answered
"does this person own that address". xms_edov is Microsoft's own answer
to the second, and their guidance says to require it wherever email
identifies an account. Absent counts as unverified, which is the only
safe reading given it is absent by default.

Nobody is locked out by this, which is worth saying because it looked
like a breaking change until I read SocialAuthenticator::resolve in
order. An account already linked resolves by subject at step 3, before
trust is consulted at all — those keep working untouched. A first-time
link to an existing account is refused with the message that already
exists for exactly this case, which names the way through: sign in with
your password and connect the provider from your settings. A brand-new
account is still created; it goes to the approval queue rather than
auto-approving.

The settings screen and docs/testing-social-login.md now tell an
operator to add the claim, and there is an upgrade note.

The tests exercise fromSocialite() on raw claims, which nothing did
before: tests/Feature/Auth/SocialLoginTest.php builds a SocialIdentity by
hand and so never reaches this mapping. That is how the branch could
trust a tenant match alone with a full suite passing.
2026-09-08 19:00:46 -03:00
ignacionelson 3244be6bac Ask for the password before changing the address a reset goes to
Reported by Nooraldden Khalel as GHSA-f32x-fgmp-q353.

The profile screen let a signed-in session change its own email address
with nothing else, and that address is where a password reset is sent. So
a stolen session was enough: point the account at your own inbox, ask for
a reset, set a password, and temporary access is permanent ownership.
Clearing email_verified_at did not stand in the way, because the model
does not implement MustVerifyEmail and the reset broker never asks.

destroy(), thirty lines further down the same controller, has always
required the current password, and its comment says why: "the rule every
other door into this already asks". This door leads to the same place and
was not asking.

Only a *different* address asks. A name, a timezone or a custom field is
not a credential, so the rest of the screen saves with nothing extra —
which is why the rule is excluded rather than flat, and why the comparison
is trimmed and lowercased: re-saving a profile with the address typed in a
different case must not demand a password for nothing.

An account whose credentials live in a directory or at an identity
provider is refused outright and told why, rather than being asked for a
password it does not have. LdapProvisioner stores Str::password(64)
exactly so that local password can never be used, so asking would be a
dead end dressed as a form error — and the address is not theirs to change
here anyway: it is what the directory says it is.

The test walks the whole chain rather than checking the field is
validated, because the chain is what made this high: change the address,
ask for a reset there, and confirm nothing is sent and no such account
exists.
2026-09-08 18:57:01 -03:00
ignacionelson 5fb17388cd Stop scoped staff reaching groups that are not theirs
Reported by @Drescargot as GHSA-r3hg-3fxw-rcmr, in two halves.

The groups listing never narrowed at all. Every other action in that
controller is guarded with allowsGroupChange(), and index() — web and API
alike — built a bare Group::query(), so a client-scoped staff member was
shown every group on the installation with its name, description and
member count. StaffLibraryScope::groups() is that narrowing, and
assignableGroupIds() now reads from it rather than restating the same
rule a second time, which is how the two drifted apart to begin with.

The second half is the one that mattered. allowsGroupChange() asked only
groupReachesNoFurther() — "is anything shared with this group outside my
library" — which a group with nothing shared with it yet passes
vacuously. So a scoped staff member could rename, delete or publish a
group whose every member was somebody else's client. Publishing is the
sharp end: whatever is shared with the group afterwards is reachable
without signing in.

The reporter suggested putting the membership check inside
groupReachesNoFurther(). Tried, and it breaks two things. That predicate
is shared with allowsGroupMembership(), where a group nobody has joined
must stay usable so its creator can add the first member. And "every
member must be mine" is the obvious reading of the rule and is wrong: it
turns GHSA-whmp-p9hv-r7j7's narrowing — a mixed group's edit screen
loads and simply does not name the stranger — back into a 404, undoing
that fix. Four tests from it fail that way.

So the check sits in allowsGroupChange() alone, and asks whether the
group is wholly somebody else's rather than whether it is wholly theirs.
A mixed group stays workable and is still covered by the reach check; an
empty one stays nameable by whoever just made it; a group with members
and none of them theirs is refused.
2026-09-08 18:40:33 -03:00
ignacionelson 2be423d685 Translate the download counts and the copy-link control
Four strings, sixteen locales. Everything else was already complete, so
this is the whole of the debt from the client-portal work.

Czech, Polish and Russian do not get "downloaded :count times": those
languages inflect the noun according to the number in front of it, so no
single string can be right for every value. They read as a labelled count
instead — "Pobrania: :count — ostatnie :date" — which is the shape the
other numeric strings in those catalogues already use.

German and Turkish stay formal, Spanish, Dutch, Polish and Chinese stay
informal, as the rest of each catalogue does.
2026-09-08 18:22:25 -03:00
ignacionelson 6560346280 Mark the first administrator's address verified, as intended
The last two paths that passed email_verified_at into User::create() and
lost it: the setup screen, and projectsend:admin for a container that
comes up from environment variables. It is deliberately absent from
$fillable, so mass assignment drops it without a word, and both meant to
set it.

The intent is plain in both cases — the first administrator typed their
own address into the form in front of them, and whoever provisioned the
container supplied it themselves. There is nobody to confirm it to.

Inert today, since MustVerifyEmail is not enabled on the model, but the
column is what a later switch would read: turning verification on would
have locked out the one account that cannot be helped by another
administrator.

Both are now pinned by a test that fails when the forceFill is removed.
StaffAccounts had already fixed this for staff and named the rest; with
client accounts done earlier today, that list is empty.

Also says on User::$fillable what absence from it buys and what it does
not. It stops a request smuggling a value in; it does not tell code that
meant to set the value that it failed. Four separate paths made the same
mistake against the same comment.
2026-09-08 17:07:35 -03:00
ignacionelson e187513cdd Stop a mistyped CAPTCHA flag from switching the CAPTCHA off
`env()` recognises the words "true" and "false" and returns everything
else as the string it was — and every non-empty string is truthy in PHP.
So `(bool) env('PROJECTSEND_CAPTCHA_DISABLED')` read all of these as "yes,
disabled":

    PROJECTSEND_CAPTCHA_DISABLED=no
    PROJECTSEND_CAPTCHA_DISABLED=off
    PROJECTSEND_CAPTCHA_DISABLED=fasle

An operator who meant to say no took the bot protection off their login
and registration forms and had nothing to tell them so — the setting
screen still shows the CAPTCHA configured, because this is the escape
hatch that runs ahead of it.

For most settings the cast is a shrug: somebody notices the feature is on
and fixes the line. It stops being a shrug when the wrong answer is the
unsafe one, and this is one of those. EnvFlag lists what counts as yes —
`true` and `1`, either case, either type — and reads everything else,
recognised or not, as no. A value typed as `disabled` turns nothing off:
a configuration mistake to be found rather than guessed at.

Found while fixing the same bug in a new cloud-modules flag, where the
unsafe direction was publishing a customer's files rather than dropping a
CAPTCHA. Two of the four remaining `(bool) env()` casts are left alone on
purpose: a wrong S3 path-style value breaks storage loudly, and the
migration tool's direct mode defaults to true anyway, so neither fails
into an unsafe state.
2026-09-08 17:05:30 -03:00
ignacionelson 896675d631 Tell a client whether their own file arrived
"Did it arrive?" is the question somebody asks about a file they sent, and
on a hosted free account — where a link is the whole of the sharing — the
count is the only evidence either way. Every file a client uploaded now
shows how often it has gone out and when it last did, in every render mode
of every theme.

Only their own. A download entry says somebody fetched the file, so a
count on a file shared with several clients tells each of them about the
others' activity, and nobody is entitled to that but the person who put
the file there. A file shared *with* this client carries null, not zero:
a zero would itself be a claim, and the two have to be distinguishable
because zero is an answer the owner came looking for and is shown as
words.

Counted from the activity log through the same three actions
DownloadAllowance uses, so a file leaving by the public site counts as
much as one leaving by its link. One query for a listing, none at all for
a client with no files of their own. Both filters have a test that fails
when only that filter is removed.

Two things a render check caught that types and a green build did not.
`t()` does no plural selection — the catalogues are flat key/value — so a
"one|many" string reached the screen with its pipe intact; the strings are
whole sentences now, with the singular spelled out. And the gallery card
was already laying its text out beside the action icons in a 200px column,
truncating the filename to "Q…" and the size to "75 …" on main today;
stacking them gives every line its full width.
2026-09-08 16:56:54 -03:00
ignacionelson 92bb807849 Show a client the public link to their own file
A client's portal lists two kinds of file side by side: what they
uploaded, and what somebody shared with them. Where a link exists on one
of their own, they can now copy it from the row — which is what makes the
hosted free plan a product rather than a place to put files, since a
customer there has no staff screen on which to make one.

The rule is narrow, and both halves are load-bearing: a link this client
created, on a file this client uploaded.

Not "a link on a file shared with them" — that link is the sharer's
decision about who may reach the file, and handing the recipient the URL
would quietly turn "you may download this" into "you may pass this on to
anyone".

And not "any link on their own file" either — a link staff minted on a
file a client uploaded exists for a reason the client may be no part of,
and on the shared instance it would sit beside the one link they were
promised. Ordering is by id, so an unfiltered lookup would hand them
whichever was minted first.

Both halves have a test that fails when only that half is removed. The
first draft did not: every case was carried by the ownership filter
alone, so the creator check was green for the wrong reason.

Links that no longer work are left out rather than shown greyed. The only
thing a client can do here is copy it, and a URL that answers "this link
has expired" is worse than no URL at all.

One query per listing, not one per row, and none at all for a client with
no files of their own.
2026-09-08 16:47:57 -03:00
ignacionelson 0a28e239d6 One home for what a client account is
Three surfaces create client accounts now: the staff screens,
/api/v1/clients, and the platform control plane in the private package.
Two of them held their own copy of the type, the role, the active flag,
the "0 means inherit the site default" quota, the verified stamp, the
activity entry and the seat guard — and the third could not have a copy
at all, because a package cannot import a host class.

ClientAccounts is that one definition, reached by name from outside.
What stays with each caller is what genuinely differs: its validation,
its response, its custom fields, and who is asking.

Two things changed rather than moved:

The seat cap is now checked inside create(), before anything is written,
instead of at the top of each controller. That is what makes a leaked
platform token an incident rather than an unbounded one — a guard that
ran only where somebody remembered it is not a guard.

email_verified_at is written with forceFill. It is deliberately absent
from User::$fillable, so every client-creation path passed it into a mass
assignment and lost it in silence. StaffAccounts already noted this and
named the other paths; this closes the client half.
2026-09-08 16:23:34 -03:00
ignacionelson 3d923188d9 Show an announcement on the client's own file portal
The band existed only on the staff dashboard, which is a screen a client
never opens. On a shared instance the customer *is* a client account —
they sign in, upload, and share by link, and the administrator is the
operator — so a message for them has to reach the page they actually use.

ViewerAnnouncement reads the shared prop itself rather than taking one,
so each theme adds it in a single line without threading a prop through a
page that has no other reason to know about it. All four portal themes
render it, because a message that only appears in the theme somebody
remembered to wire is a message that quietly does not exist.

Above the heading, not inside the list: it is not one of the things the
client came to do, and burying it under the files would defeat the point
of having it at all.

No theme decides who sees it. Core drops anything not aimed at the
viewer, so a theme renders whatever it is handed and cannot leak a staff
message to a client by being careless.
2026-09-08 15:55:47 -03:00
ignacionelson b128b114b5 Make an announcement say who it is for
The first version refused clients outright. That was right for the only
message that existed — a hosted instance telling its administrator about
their plan — and it stopped being right the moment a message needed to
reach the *clients* of a shared instance, where the administrator is the
operator and the customers are client accounts.

The unsafe fix would have been to drop the guard and let each listener
check `isStaff`. The safe one is to make every caller say who it is
talking to and have core enforce it: `show()` now takes a required
`audience` with no default, and a message aimed elsewhere is dropped
before it reaches the props. A listener that forgets therefore reaches
nobody rather than everybody, which is the direction a mistake should
fall.

An unrecognised audience reaches nobody either, and is ignored rather
than thrown — a listener aimed at the wrong people should show nothing,
not break the page it was decorating.

The old "a client is never shown one" test became "a message for staff
reaches no client, even from a listener that never checks", which is the
property that actually matters and the one the enforcement provides. Two
more pin the other directions: a client message reaches clients and no
staff, and an unknown audience reaches neither.

cloud-modules declares `staff` for the free-plan band, and its test fake
enforces the same rule, so a listener aimed at the wrong audience fails
in the package's own suite rather than passing there and misbehaving in
the host.
2026-09-08 15:48:29 -03:00
ignacionelson 757fba19ca Give uploads a seam, and link-minting one home
Two pieces of groundwork, no behaviour change.

FileWasStored is dispatched from StoreUploadedFile, which every upload
path converges on — the chunked flow staff and clients share, and the
synchronous POST beside it. A listener therefore sees each upload once
without knowing which route produced it, which is the property that makes
it usable from outside this repository. A notification, not a filter:
nothing on it is mutable, and anything that needs to influence an upload
has to do so before the bytes land, which is what ResolvingUploadDisk is
already for.

CreateShareLink is the other half. Minting a link was a ShareLinksController
private concern, and the controller is an HTTP handler behind `staff`
middleware — so a link now needs making from outside a request as well.
Two copies of "make a token, write the row, log it" would drift, and the
half most likely to drift is the token, which is the entire authorization
for /s/{token}: there is no session behind it and no second factor, so
being unguessable is its only defence. Anything minted through the action
gets Str::random(32) — about 190 bits, more than a UUID's 122 — and never
a chosen value. The chosen-token path stays in the controller, where a
person is typing one into a form and its minimum length can be argued
about in a validation rule.

The permission questions stay in the controller too. Whether somebody may
set an expiry or a download cap is a fact about them, and the action has
no viewer to ask; it takes both already resolved, including the expiry,
because "the end of the 12th" depends on whose timezone you are in.

Five tests, including that the file a listener receives is complete and
readable rather than half-built, and that the staff form still refuses an
expiry to somebody without the permission after the extraction.
2026-09-08 15:39:55 -03:00
ignacionelson 763e7b0e2e Render one image once, however many requests ask at the same time
Renditions are generated on demand and cached by existence, and nothing
between the callers stopped two requests decoding the same image at once.
The atomic rename settled which file survived; it never stopped both from
doing the work. So N concurrent requests for one cold rendition were N
full-size decodes, each holding four bytes per source pixel — up to 160 MB
at the 40-megapixel ceiling.

That is not an attack. A public listing emits a thumbnail URL per file, a
browser opens six or more connections at once, and the first visit to a
gallery of ordinary camera images was six simultaneous decodes on a
container sized for one. PublicGroupsController reaches the generator with
no account at all, so nothing about it required a customer to be signed
in, and the 240/min throttle bounds rate rather than concurrency.

Worse than a crash, it did not resolve itself: a render killed mid-flight
renames nothing, so the cache warmed only by whatever finished before the
kill and the page died again on the next visit.

A lock keyed on the destination path — which already encodes the file, the
audience and the rendition, so two requests collide exactly when they
would have written the same path. The waiter re-reads after acquiring,
which is what turns a wait into a cache hit rather than a second decode of
the same image.

Waiting rather than refusing, because the arithmetic says so: a waiting
request holds an idle worker at about 35 MB, a rendering one holds that
plus the whole source bitmap. Six waiters cost what one renderer costs.

On timeout it refuses instead of rendering anyway. Falling through would
reinstate the pile-on at the moment the system is already struggling, and
one failed thumbnail is a better outcome than a container that dies and
takes the warm cache with it.

The wait is configurable because the right number is a property of the
machine — a small VPS reading a large source off a slow disk wants longer
— and clamped to at least a second, since a stray empty variable would
otherwise make every concurrent request fail instantly, which is the
opposite of the point.

Eight tests. Two go red without the lock, and the clamp is asserted on the
resolved value rather than the clock, because block() measures in whole
seconds and a timing assertion there would be flaky rather than wrong.

Found by the session sizing free-tier containers, from the outside.
2026-09-08 15:29:20 -03:00
ignacionelson a5496d24cd Stop describe() vouching for a detection it could not make
`FileDelivery::describe()` from a console returned
`{"method":"php","detected":true}` on every installation, whatever its web
server. detect() reads SERVER_SOFTWARE, which only exists inside a
request, so a console process has nothing to look at and falls to the
`php` default — and `detected: true` then vouched for it.

The value is right for that process and wrong as a statement about the
installation, which is how anybody running it from `artisan tinker` will
read it. Somebody verifying a healthy nginx tenant hit exactly that, spent
an afternoon on it, and only recognised it as an artefact of *where* the
question was asked after reading `nginx -T` in the container.

There is now a third field. `observed` is false only outside a request,
where `method` is a default rather than a finding. Both screens that read
this run in a request and always see true; it exists for whoever asks from
a shell, which is the one place the answer could mislead.

The two web paths are unchanged and were never wrong — `projectsend:status`
does not report delivery at all, so no fleet ever reported this
incorrectly. What was wrong was a confident answer to a question that
could not be answered from where it was asked.

Three tests: a console reading says not observed and still says php,
because php is what that process would actually do; a reading during a
request observes nginx; and a stated method is observed wherever it is
read, since a decision needs nothing detected to be true.

Found by the session verifying the 2.4.0 canary, not by me.
2026-09-08 10:21:02 -03:00
110 changed files with 5588 additions and 234 deletions
+5
View File
@@ -4,6 +4,11 @@ PROJECTSEND_EDITION=community
# Emergency off switch for the CAPTCHA on public forms, for an operator who
# has a shell but no working login. Everything else about the feature is
# configured at /system/settings/captcha.
#
# Only "true" or "1" switches it off. Anything else -- including "no",
# "off", and a misspelling -- leaves the CAPTCHA on, deliberately: a flag
# that takes a protection away should not do so because a value was typed
# wrong.
# PROJECTSEND_CAPTCHA_DISABLED=true
# How downloads leave the server. Left unset (or "auto"), ProjectSend hands
+66 -4
View File
@@ -6,13 +6,75 @@ Versions follow [SemVer](https://semver.org/): the middle number moves when ther
the last one when there are only fixes, and the first one when an upgrade needs more from you than
dropping in the new files and running the migrations.
Anything under **Upgrade notes** is something you have to do, not something we did.
Anything under **⚠️ Important — do these yourself** is something you have to do, not something
we did. It sits at the top of a release for that reason. Older entries call the same section
**Upgrade notes**.
## Unreleased
## 2.4.1 — 11 September 2026
This section collects changes as they land; the release process turns it into a numbered entry
when a version is cut.
### ⚠️ Important — do these yourself
Everything else in this release happens on its own. These do not: each one leaves something working
differently from how you expect until you act on it. Nothing here stops the upgrade or the
installation from starting.
- **If you set `PROJECTSEND_CAPTCHA_DISABLED`, check what you set it to.** Only `true` or `1`
switches the CAPTCHA off now. Anything else — including `no`, `off`, `yes` and a misspelling —
used to be read as "yes, disabled" and is now read as "leave it on". If you meant it off, write
`true`.
- **If a staff role uploads into public folders, give it "Upload to public folders".** That
permission was not being asked of staff, and now is. Roles holding "Upload public files" are
unaffected, and ordinary uploads need nothing new.
- **If you use Microsoft sign-in, add the `xms_edov` optional claim to your app registration.** In
the Entra portal: your app registration → Token configuration → Add optional claim → ID →
`xms_edov`. Until you do, Microsoft sign-in keeps working and keeps creating new accounts, but it
will no longer attach itself to an account that already exists.
**Added**
- **Your logo now appears on the sign-in screen.** Requested by
[@Zodiac1978](https://github.com/Zodiac1978) in
[#1777](https://github.com/projectsend/projectsend/issues/1777).
- **An installation on AWS can authenticate as its own IAM role instead of storing an access key.**
- **Clients can now see how often their own files were downloaded, and when.**
**Fixed**
- **A lookalike domain can no longer hand somebody else's account to an OIDC sign-in.** Reported by
[@choewonwoo1817](https://github.com/choewonwoo1817).
- **Changing your own email address now asks for your password.** Reported by
[@Noorkhalel](https://github.com/Noorkhalel).
- **Microsoft sign-in now checks that the person owns the address they presented.** Reported by
[@archnexus707](https://github.com/archnexus707).
- **Two people filling in the first-run setup screen at the same moment can no longer both become
administrators.** Reported by [@ry2811](https://github.com/ry2811).
- **Uploading into a public folder now needs a permission that says so.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **Moving a file into a public folder now needs that same permission.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **A staff member limited to some clients can no longer see or change other people's groups.**
Reported by [@Drescargot](https://github.com/Drescargot).
- **Deleting a client can no longer hand their files to a client you do not manage.** Reported by
[@skeletonsec](https://github.com/skeletonsec).
- **Erasing a staff account no longer hands their files to a client.**
- **An interrupted upload can no longer park unlimited bytes on the server.** Reported by
[@ry2811](https://github.com/ry2811).
- **The password reset screen no longer says whether an email address has an account here.**
- **An expired password reset link now says so before asking for a new password.**
- **A Docker upgrade no longer fails when external storage is already configured.**
[#1770](https://github.com/projectsend/projectsend/issues/1770).
- **A public gallery no longer renders the same thumbnail several times at once.**
- **`PROJECTSEND_CAPTCHA_DISABLED` no longer reads a "no" as a "yes".**
### Issues closed since 2.4.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1768](https://github.com/projectsend/projectsend/issues/1768) — Search in file not restricted in directory
- [#1773](https://github.com/projectsend/projectsend/issues/1773) — Feature Request : Support AWS IAM roles / default credential provider chain for S3 storage
- [#1774](https://github.com/projectsend/projectsend/issues/1774) — HTTP Error by upload on R2098
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — [Documentation] Error 500 on install
## 2.4.0 — 8 September 2026
+57 -2
View File
@@ -324,8 +324,9 @@ like your logo reachable from the web.
## Step 6 — Point your web server at it
A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever
you unpacked the files (there are **three** places, including one inside `/protected-files/`):
A complete nginx server block below; [Apache is further down](#if-you-are-using-apache). Change
`server_name`, and change `/var/www/projectsend` to wherever you unpacked the files (there are
**three** places, including one inside `/protected-files/`):
```nginx
server {
@@ -374,6 +375,38 @@ server {
}
```
### If you are using Apache
Two things matter, and both are easy to get wrong:
- **The document root is the `public/` directory**, not the directory you unpacked into. Everything
above `public/` — your `.env`, your uploaded files, the application code — has to stay out of
reach of any URL.
- **`AllowOverride All`, and `mod_rewrite` enabled** (`sudo a2enmod rewrite`). ProjectSend ships a
`public/.htaccess` that sends every address to the front controller. If Apache is told to ignore
it, every page except the home page is a 404.
```apache
<VirtualHost *:80>
ServerName files.example.com
DocumentRoot /var/www/projectsend/public
<Directory /var/www/projectsend/public>
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/projectsend-error.log
CustomLog ${APACHE_LOG_DIR}/projectsend-access.log combined
</VirtualHost>
```
Downloads work as they are: PHP sends the bytes. If that becomes a capacity problem, `mod_xsendfile`
hands the job to Apache — see [How downloads are sent](#how-downloads-are-sent).
On shared hosting you usually cannot edit any of this, and `public/.htaccess` is all you have. If
the site returns a 500 on every page, see [When something goes wrong](#when-something-goes-wrong).
Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a
whole 5 GB file at once — but the pieces still need room. In your `php.ini`:
@@ -581,6 +614,28 @@ names the exact command to run; do that, then reload.
Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is
folder permissions (step 4) or a wrong database password (step 3).
**Every page is a 500, and `storage/logs/` is empty.**
The empty log is the answer, not a dead end: nothing reached PHP, so ProjectSend had nothing to
write. The error is your web server's, and it is in your web server's log — on Apache
`/var/log/apache2/error.log`, or wherever your host puts it. On Apache two causes account for
almost all of these, and both are about `public/.htaccess`:
- **`Options not allowed here`.** The file starts by turning off directory listings and content
negotiation, and your `AllowOverride` does not permit that. Allow it (`AllowOverride All`), or
delete the `Options` line — it is hardening, not a requirement.
- **`Request exceeded the limit of 10 internal redirects`.** Apache cannot work out which directory
the file is serving, so the rule that sends every address to `index.php` rewrites to a path that
does not exist, and tries again. Uncomment the `RewriteBase` line in `public/.htaccess` and set it
to the path ProjectSend is served from — `/` at the domain root, `/projectsend` in a subdirectory.
Reported on IONOS by [@Zodiac1978](https://github.com/Zodiac1978) in
[#1778](https://github.com/projectsend/projectsend/issues/1778).
**If you edit `public/.htaccess`, write down what you changed.** Updating replaces every file the
release ships, that one included, so a change that made your site work will be gone after the next
update and the 500 will come back. If the Apache configuration is yours to edit, put the directives
in a `<Directory>` block in the vhost instead: they do the same job there, and no update can touch
them. On shared hosting, where it is not yours, keep the note and re-apply it.
**"Please provide a valid cache path" or "failed to open stream".**
`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4.
@@ -30,9 +30,53 @@ class NewPasswordController extends Controller
return Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
'expired' => $this->linkIsSpent(
(string) $request->string('email'),
(string) $request->route('token'),
),
]);
}
/**
* Whether this link is one store() is certain to refuse.
*
* The scaffolding renders the form without looking at the token, so an
* expired link asked for a new password, asked for it a second time to
* confirm, and only then answered "this password reset token is
* invalid" — naming a word nobody outside the code knows, after the
* work rather than before it. Reset links last an hour and people open
* them late; that is ordinary, not an error to be scolded for.
*
* store() still validates and remains the rule. This is the screen
* being honest a minute earlier.
*
* **Anything that will not validate reads as expired, whether or not
* the address is one we know.** That is the whole of the rule and it
* exists for one reason: a page answering "expired" for a real address
* and drawing the form for an unknown one tells anybody who types a
* guess whether an account is here — the exact property
* /forgot-password protects by saying "a link will be sent if the
* account exists".
*
* The first version of this method described that oracle in a comment
* and then built it: unknown address returned false and drew the form,
* known address returned true and said expired. Two branches, two
* answers, and the difference *was* the account. Now both answer the
* same, so the page reveals nothing and the message is still right in
* every case somebody real will meet — a mistyped address gets "ask
* for a new link", which is what they should do anyway.
*/
private function linkIsSpent(string $email, string $token): bool
{
$broker = Password::broker();
$user = $email === '' ? null : $broker->getUser(['email' => $email]);
// One answer for "no such account", "wrong token" and "spent
// token", because telling them apart is telling somebody which
// addresses exist here.
return $user === null || ! $broker->tokenExists($user, $token);
}
/**
* Handle an incoming new password request.
*
@@ -113,8 +157,21 @@ class NewPasswordController extends Controller
return to_route('login')->with('status', __($status));
}
// One sentence for every way this can fail, and deliberately not
// Laravel's own. The scaffolding answers `passwords.user` for an
// address it cannot find and `passwords.token` for a real one whose
// token is dead — two different sentences, which is the same
// account-enumeration oracle the screen above was fixed for,
// reachable through the write instead. `passwords.throttled` is the
// third and the sharpest: the broker throttles per *user*, so an
// address nobody holds can never be throttled, and being told to
// wait is being told the account is there.
//
// Nothing is lost by collapsing them. The action is the same in
// every case — ask for a new link — and /forgot-password already
// refuses to say whether an address has an account.
throw ValidationException::withMessages([
'email' => [__($status)],
'email' => [__('This password reset link is no longer valid. Ask for a new one and try again.')],
]);
}
}
@@ -350,6 +350,11 @@ class HandleInertiaRequests extends Middleware
return null;
}
// Dispatched for clients too, unlike the sidebar links beside it.
// A client is somebody a shared instance may legitimately need to
// address — about their own account, not about the installation —
// and the event refuses anything not aimed at them, so widening
// this does not widen what reaches them.
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
Event::dispatch($event);
+8 -1
View File
@@ -3,6 +3,7 @@
namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Modules\Identity\AccountLookup;
use App\Modules\Identity\Ldap\LdapProvisioner;
use App\Modules\Identity\PasswordVerification;
use App\Modules\Identity\SignIn;
@@ -71,7 +72,13 @@ class LoginRequest extends FormRequest
{
$this->ensureIsNotRateLimited();
$user = User::query()->where('email', $this->string('email'))->first();
// Exact, for the reason SocialAuthenticator is: a collation that
// folds accents would otherwise let somebody typing
// admin@éxample.com be *identified* as admin@example.com. A
// password still gates this one, so it was never the takeover the
// social path was — but identifying the wrong account is the bug,
// and the credential check is a second line rather than the rule.
$user = app(AccountLookup::class)->byEmail((string) $this->string('email'));
// A directory identity with no local account yet. Returns null
// unless LDAP is on, auto-provisioning is on, and the bind
@@ -5,9 +5,11 @@ namespace App\Http\Requests\Settings;
use App\Models\User;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Identity\AuthSource;
use App\Support\Rules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Closure;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
@@ -31,6 +33,26 @@ class ProfileUpdateRequest extends FormRequest
Rule::unique(User::class)->ignore($this->user()?->id),
],
// Changing this address is a credential change, not a detail:
// it is where a password reset is sent, so whoever can change
// it owns the account from the next reset onwards. A stolen
// session used to be enough (GHSA-f32x-fgmp-q353) — temporary
// access became permanent ownership with one PATCH.
//
// `exclude_if` rather than a flat rule, so the rest of the
// screen keeps saving with nothing extra: a name, a timezone
// or a custom field is not a credential and must not start
// asking for a password. Only a *different* address does.
//
// The same rule destroy() one controller away has always
// asked, for the same reason: both doors lead to owning the
// account.
'current_password' => [
Rule::excludeIf(! $this->changesEmail()),
'required',
'current_password',
],
// Saved with the rest of the profile so the screen keeps one
// Save button. `timezone` is fillable, so ProfileController's
// fill() picks it up with no special handling.
@@ -43,6 +65,21 @@ class ProfileUpdateRequest extends FormRequest
];
$user = $this->user();
// An account whose credentials live in a directory or at an
// identity provider holds a local password nobody knows — see
// LdapProvisioner, which stores Str::password(64) exactly so it
// can never be used. Asking such a person to confirm "your current
// password" is a dead end dressed as a form error, and the address
// is not theirs to change here in any case: it is what the
// directory or the provider says it is, and a local edit would
// either be overwritten or break the link.
if ($this->changesEmail() && $user !== null && $user->auth_source !== AuthSource::Local) {
$rules['email'][] = function (string $attribute, mixed $value, Closure $fail): void {
$fail(__('Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.'));
};
}
if ($user?->isClient() === true) {
$rules = [
...$rules,
@@ -52,4 +89,29 @@ class ProfileUpdateRequest extends FormRequest
return $rules;
}
/**
* Whether this request asks for an address other than the stored one.
*
* Compared lowercased and trimmed because the `lowercase` rule runs
* beside this one rather than before it: without that, re-saving the
* profile with the address typed in a different case would be read as
* a change and demand a password for nothing.
*/
private function changesEmail(): bool
{
$user = $this->user();
if ($user === null) {
return false;
}
$submitted = $this->input('email');
if (! is_string($submitted)) {
return false;
}
return mb_strtolower(trim($submitted)) !== mb_strtolower(trim((string) $user->email));
}
}
+10
View File
@@ -161,6 +161,16 @@ class User extends Authenticatable implements HasLocalePreference
// credentials live is a security decision, not an attribute a
// form or an API payload may set. Written with forceFill by
// the code that provisions the account.
//
// Same for 'email_verified_at' below, and it is worth saying
// what absence from $fillable does and does not buy. It stops
// a request smuggling the value in. It does not tell the code
// that meant to set it deliberately that it failed: a key in a
// create() array is dropped in silence, so every path that
// provisions an account had one and lost it — staff accounts,
// client accounts, the setup screen and projectsend:admin, all
// fixed in September 2026. Not fillable only helps when the
// writer knows it has to be deliberate.
'auth_source' => AuthSource::class,
'ldap_synced_at' => 'datetime',
'active' => 'boolean',
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Modules\Clients;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Seats\SeatAllowance;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
/**
* Creating a client account — the rules and the side effects, shared by
* every surface that makes one.
*
* The same argument StaffAccounts makes for staff. What a client account
* *is* — its type, its role, an active flag, a quota where zero means
* "inherit the site default" rather than "none" — is a set of invariants,
* and an invariant enforced in one controller and re-implemented in
* another is one that will eventually hold in only one of them. There are
* three surfaces onto this now: the staff screens, `/api/v1/clients`, and
* the platform control plane in the private package, which reaches this
* by name because it cannot import a host class.
*
* What stays with the caller is what genuinely differs: the shape of the
* request, its validation rules, its response, and anything about *who is
* asking* — a client-scoped staff member gaining the client on their own
* roster is a fact about the creator, not about the account created.
*
* **Not to be confused with ClientProvisioning**, which sits beside it and
* handles the other half: an account that comes into existence without
* anybody deciding to create it — the public registration form, and a
* first successful LDAP sign-in. The policies genuinely differ rather than
* merely duplicating. An account made here is approved and verified by
* construction, because somebody who already knows who this is asked for
* it; one made there may wait for approval, joins a configured group, and
* tells the administrators it arrived.
*/
class ClientAccounts
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly SeatAllowance $seats,
private readonly Settings $settings,
) {}
/**
* @param int $storageQuotaMb 0 means no per-account quota and
* inherits the site default at
* enforcement time — see
* ClientStorageUsage::quotaMb(). It does
* not mean unlimited.
* @param bool $welcome whether this installation should email the
* new account. A caller that sends its own
* welcome passes false rather than having the
* customer receive two.
*/
public function create(
string $name,
string $email,
string $password,
int $storageQuotaMb = 0,
bool $welcome = true,
string $emailField = 'email',
): User {
// Before anything is written, and deliberately not left to the
// caller. The platform sets this cap and the platform is also what
// calls the control plane — so enforcing it here is what stops a
// leaked control token minting accounts without limit. A guard
// that only ran on the surfaces that remembered it would not be a
// guard.
$this->seats->guardClient($emailField);
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $name,
'email' => $email,
'password' => $password,
'storage_quota_mb' => $storageQuotaMb,
]);
// forceFill, and not part of the create() array above: like
// StaffAccounts, email_verified_at is deliberately absent from
// User::$fillable — where an account stands is a security decision
// rather than an attribute — so mass assignment drops it in
// silence. Every client-creation path used to pass it in that
// array and lose it. The intent is real: an account created by
// somebody who already knows who this is has no address to
// confirm and nobody to confirm it to. (Inert today, since
// MustVerifyEmail is not enabled on the model, but the column is
// what a later switch would read.)
$client->forceFill(['email_verified_at' => now()])->save();
$this->activity->log(Action::UserCreated, subject: $client);
if ($welcome && $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
return $client;
}
}
+43 -5
View File
@@ -28,10 +28,9 @@ class ClientStorageUsage
/**
* A client's own storage_quota_mb of 0 means "no custom quota set" —
* it inherits Setting::DefaultClientStorageQuotaMb instead of being
* unlimited, so a site-wide default (once set) also protects clients
* who never got an explicit quota, including self-registered ones.
* The site default itself being 0 is what actually means unlimited.
* it inherits the installation's default instead of being unlimited,
* so a default (once set) also protects clients who never got an
* explicit quota, including self-registered ones.
*
* @return int 0 means unlimited.
*/
@@ -39,7 +38,46 @@ class ClientStorageUsage
{
return $client->storage_quota_mb > 0
? $client->storage_quota_mb
: (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
: $this->defaultQuotaMb();
}
/**
* What a client with no quota of their own actually gets.
*
* Three sources, narrowest first, and the third is why this is a
* method rather than a `Settings::get()` at the point of use.
*
* `Setting::DefaultClientStorageQuotaMb` belongs to whoever runs the
* installation, and its default is 0 — which means unlimited. That is
* the right default for somebody setting up their own install, and the
* wrong one for an installation a platform operates on other people's
* behalf: there, an account that arrived without an explicit quota has
* no ceiling at all, which on a shared installation is one account
* away from unmetered hosting.
*
* So a platform may set a floor in the environment, exactly as it sets
* the seat caps, and for the same reason those are not settings: it is
* not a preference the installation's administrator is expressing, it
* is the shape of what was sold. It applies only where the setting says
* nothing, so an administrator who has chosen a number keeps it, and an
* install with no platform behind it is unaffected.
*
* Unset and zero are the same answer here, on purpose: a platform that
* wanted no ceiling would not set the variable.
*
* @return int 0 means unlimited.
*/
public function defaultQuotaMb(): int
{
$site = (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
if ($site > 0) {
return $site;
}
$floor = config('projectsend.platform.default_client_quota_mb');
return is_numeric($floor) ? max(0, (int) $floor) : 0;
}
/**
@@ -9,6 +9,7 @@ use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientAccounts;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
@@ -22,10 +23,7 @@ use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Builder;
@@ -62,6 +60,7 @@ class ClientsController extends Controller
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
) {}
@@ -118,8 +117,6 @@ class ClientsController extends Controller
public function store(Request $request): JsonResponse
{
$this->seats->guardClient();
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
@@ -138,21 +135,18 @@ class ClientsController extends Controller
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 means "no custom quota" and inherits the site default at
// enforcement time — see ClientStorageUsage::quotaMb().
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$this->activity->log(Action::UserCreated, subject: $client);
// The invariants — the seat guard, the type, the role, the quota's
// "0 means inherit" — live in ClientAccounts, shared with the staff
// screens and with the platform control plane. What stays here is
// this surface's own business: its validation, its custom fields,
// and who the creator is.
$client = $this->clients->create(
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
welcome: false,
);
$creator = $request->user();
assert($creator !== null);
@@ -170,6 +164,10 @@ class ClientsController extends Controller
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
// Sent here rather than inside ClientAccounts so the custom fields
// are already saved when it goes: a welcome that arrives before
// the account is finished describes an account that does not quite
// exist yet.
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
$client->notify(new ClientWelcomeNotification);
}
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientAccounts;
use App\Modules\Clients\ClientCustomFieldType;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
@@ -20,10 +21,7 @@ use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Pagination;
@@ -51,6 +49,7 @@ class ClientsController extends Controller
private readonly AccountContentDeletion $accountDeletion,
private readonly StaffLibraryScope $scope,
private readonly SeatAllowance $seats,
private readonly ClientAccounts $clients,
private readonly ErasureSchedule $erasure,
) {}
@@ -123,16 +122,25 @@ class ClientsController extends Controller
return Inertia::render('clients/create', [
'custom_fields' => $this->customFieldDefinitions(),
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
// The resolved default, not the raw setting: a platform can put
// a floor under it from the environment, and both screens
// present this as what will actually happen rather than as a
// value being edited. The edit screen mirrors quotaMb()'s
// resolution client-side to draw the usage bar, and handing it
// the effective number is what keeps that mirror correct
// without it having to know floors exist.
//
// The Client settings form deliberately still reads the raw
// setting (ClientSettingsController): that field is edited and
// saved back, so prefilling it with a floor would write the
// platform's number into the setting as the administrator's own
// choice, where it would outlive the floor.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
]);
}
public function store(Request $request): RedirectResponse
{
// A client created here is approved by construction, so it counts
// immediately — unlike a self-registration awaiting a decision.
$this->seats->guardClient();
$validated = $request->validate(array_merge([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
@@ -140,24 +148,19 @@ class ClientsController extends Controller
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
], $this->customFieldRules()));
$client = User::create([
'type' => UserType::Client,
'active' => true,
'account_requested' => false,
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
// 0 (including an omitted field) means "no custom quota" —
// it inherits Setting::DefaultClientStorageQuotaMb at
// enforcement time (see ClientStorageUsage::quotaMb()), not
// baked in here, so a later change to the site default
// keeps applying to this client automatically.
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
'email_verified_at' => now(),
]);
$this->activity->log(Action::UserCreated, subject: $client);
// The seat guard, the type, the role, and the quota's "0 means
// inherit the site default" all live in ClientAccounts, shared
// with the API and the control plane. A client created here is
// approved by construction, so it counts against the cap
// immediately — unlike a self-registration awaiting a decision.
// The welcome waits until the custom fields are saved below.
$client = $this->clients->create(
name: $validated['name'],
email: $validated['email'],
password: $validated['password'],
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
welcome: false,
);
$creator = $request->user();
assert($creator !== null);
@@ -226,7 +229,8 @@ class ClientsController extends Controller
'storage_quota_mb' => $client->storage_quota_mb,
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
],
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
// Resolved, not raw — see create() above.
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
'custom_fields' => $this->customFieldDefinitions(),
'custom_field_values' => ClientCustomFieldValue::query()
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Access;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\File;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* How often a client's own file has been taken, and when it last was.
*
* The question somebody asks about a file they sent: did it arrive? On a
* hosted free account the link is the whole of the sharing, so "3
* downloads, last one on Tuesday" is the only evidence there is that it
* worked.
*
* **Own files only, and that is a privacy rule rather than a scoping
* convenience.** A download entry says somebody fetched the file, and on
* a file shared with several clients, telling one of them the count tells
* them about the others' activity. Nobody is entitled to that except the
* person who put the file there. So a file shared *with* this client
* carries no numbers at all — not zero, which would be a claim, but
* nothing.
*
* Counts come from the activity log through File::downloads(), the same
* source every other download count in the interface uses. There is
* deliberately no counter column — see DownloadAllowance for the whole
* argument, which applies unchanged here.
*
* One query for a page, whatever it holds.
*/
class OwnFileDownloads
{
/**
* @param Collection<int, File> $files
* @return array<int, array{count: int, last_at: string|null}> keyed by
* file id, only for files this client uploaded
*/
public function forMany(Collection $files, User $client): array
{
$own = $files
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
if ($own === []) {
return [];
}
// Every own file gets an entry, including the ones with nothing to
// report: the difference between "nobody has downloaded this" and
// "this is not yours to know" is exactly what the caller renders,
// and a missing key would collapse the two.
$stats = [];
foreach ($own as $id) {
$stats[$id] = ['count' => 0, 'last_at' => null];
}
$rows = ActivityLog::query()
->selectRaw('subject_id, count(*) as downloads, max(created_at) as last_at')
->where('subject_type', (new File)->getMorphClass())
->whereIn('subject_id', $own)
->whereIn('action', [
Action::FileDownloaded->value,
Action::ShareLinkDownloaded->value,
Action::PublicFileDownloaded->value,
])
->groupBy('subject_id')
->get();
foreach ($rows as $row) {
$id = (int) $row->getAttribute('subject_id');
$lastAt = $row->getAttribute('last_at');
$stats[$id] = [
'count' => (int) $row->getAttribute('downloads'),
'last_at' => $lastAt === null ? null : Carbon::parse((string) $lastAt)->toIso8601String(),
];
}
return $stats;
}
}
+75 -7
View File
@@ -159,15 +159,37 @@ class StaffLibraryScope
return null;
}
$clientIds = $this->assignableClientIds($user) ?? [];
return array_values($this->groups($user)->pluck('id')->map(fn ($id): int => (int) $id)->all());
}
if ($clientIds === []) {
return [];
/**
* Every group this staff member may be told about, as a query.
*
* The listing half of assignableGroupIds(), and the same rule: a
* group counts as theirs because one of their clients is in it. The
* two were not the same code, and the listing simply had none — so
* `/groups` and `/api/v1/groups` showed a scoped staff member every
* group on the installation, name, description and member count,
* including groups whose every member was somebody else's client
* (GHSA-r3hg-3fxw-rcmr).
*
* Deliberately the *sharing* rule rather than the change rule below.
* A scoped staff member may already share a file with a mixed group,
* so its existence is not news to them; what they may not do is
* rename, publish or delete it.
*
* @return Builder<Group>
*/
public function groups(User $user): Builder
{
$query = Group::query();
$clientIds = $this->assignableClientIds($user);
if ($clientIds === null) {
return $query;
}
return array_values(Group::query()
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
->pluck('id')->map(fn ($id): int => (int) $id)->all());
return $query->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds));
}
public function canAssignClient(User $user, User $client): bool
@@ -249,7 +271,53 @@ class StaffLibraryScope
*/
public function allowsGroupChange(User $user, Group $group): bool
{
return $this->groupReachesNoFurther($user, $group);
return $this->groupIsNotWhollySomebodyElses($user, $group)
&& $this->groupReachesNoFurther($user, $group);
}
/**
* Whether this group is somebody else's entirely — every member
* outside the staff member's roster, and none of theirs in it.
*
* The half allowsGroupChange() was missing. Reach answers "what would
* this group hand somebody", which is the right question for putting a
* client *into* it; it says nothing about who is already there. So a
* group with nothing shared with it yet passed the reach check
* vacuously, and a scoped staff member could rename it, delete it, or
* publish it — a group made entirely of clients they had never been
* assigned (GHSA-r3hg-3fxw-rcmr).
*
* **Not "every member is mine", which is the obvious reading and is
* wrong.** A mixed group has to stay changeable: GHSA-whmp-p9hv-r7j7
* settled that a scoped staff member opens such a group's edit screen
* and is shown only their own clients in it, rather than being refused
* the screen. Requiring every member to be theirs turns that narrowing
* back into a 404 and undoes the earlier fix. What is left over — a
* mixed group whose shared content reaches past their library — is
* refused by groupReachesNoFurther() beside this, which is the check
* that has always covered it.
*
* **And deliberately not folded into groupReachesNoFurther() either.**
* That predicate is shared with allowsGroupMembership(), where a group
* nobody has joined must stay usable so its creator can put the first
* member in — the case that method's own docblock calls out.
*
* An empty group is nobody else's, so whoever just made it can still
* name it.
*/
private function groupIsNotWhollySomebodyElses(User $user, Group $group): bool
{
$clientIds = $this->assignableClientIds($user);
if ($clientIds === null) {
return true;
}
if (! $group->members()->exists()) {
return true;
}
return $group->members()->whereIn('users.id', $clientIds)->exists();
}
/**
+26 -4
View File
@@ -61,7 +61,7 @@ class FileDelivery
/**
* The method in force, and whether it was detected or stated.
*
* @return array{method: DeliveryMethod, detected: bool}
* @return array{method: DeliveryMethod, detected: bool, observed: bool}
*/
public function resolve(): array
{
@@ -69,10 +69,25 @@ class FileDelivery
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
if ($explicit !== null) {
return ['method' => $explicit, 'detected' => false];
return ['method' => $explicit, 'detected' => false, 'observed' => true];
}
return ['method' => $this->detect(), 'detected' => true];
// Whether there was anything to detect *from*. detect() reads
// SERVER_SOFTWARE, which only exists inside a request — so a
// console process has nothing to look at and falls to the `php`
// default. That default is right for the console (no web server is
// handling this, so nothing could hand a file off), and wrong as a
// statement about the installation, which is how somebody reading
// it from `artisan tinker` will take it.
//
// Reported rather than papered over: a reader who runs
// `describe()` from a shell on a perfectly good nginx box was
// being told `php`, with `detected: true` vouching for it. That
// cost somebody an afternoon before it was recognised as an
// artefact of asking outside a request.
$observed = is_string($this->request->server('SERVER_SOFTWARE'));
return ['method' => $this->detect(), 'detected' => true, 'observed' => $observed];
}
public function method(): DeliveryMethod
@@ -88,7 +103,12 @@ class FileDelivery
* the installation from outside, and neither should change meaning if
* the enum ever grows a JsonSerializable of its own.
*
* @return array{method: string, detected: bool}
* `observed` is false only outside an HTTP request, where nothing can
* be detected and `method` is a default rather than a finding. Both
* screens that read this run in a request, so they always see true;
* it exists for whoever asks from a console.
*
* @return array{method: string, detected: bool, observed: bool}
*/
public function describe(): array
{
@@ -100,6 +120,8 @@ class FileDelivery
// to the reader: a detected `php` is an installation that
// could be faster, a stated one is somebody's decision.
'detected' => $resolved['detected'],
// And whether the detection had anything to work with.
'observed' => $resolved['observed'],
];
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Events;
use App\Models\User;
use App\Modules\Files\Models\File;
/**
* A file's bytes are on a disk and its row exists.
*
* Dispatched from StoreUploadedFile, which every upload path goes through
* — the chunked flow that staff and clients share, and the synchronous
* POST beside it — so a listener sees every upload once and does not have
* to know which route produced it.
*
* A notification rather than a filter: nothing here is mutable and no
* listener can change what was stored. Anything that needs to influence
* the upload has to do so before the bytes land, which is what
* ResolvingUploadDisk is for.
*
* Fired after the row is created and before the caller has linked a
* version or answered the request, so a listener sees a complete File and
* can safely read it back.
*/
class FileWasStored
{
public function __construct(
public readonly File $file,
public readonly User $uploader,
) {}
}
@@ -311,9 +311,12 @@ class FilesController extends Controller
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
]);
// Reparenting through update() must respect the same library scope as
// Reparenting through update() must respect the same two rules as
// the web move()/bulkUpdate() paths: the destination folder must be
// one this user can see. Only enforced when folder_id actually
// one this user can see, and one they may put content into. A public
// destination publishes what lands in it, so the second question is
// the one `upload_public` exists to ask and store() above already
// asks (GHSA-rxf8-wh8v-jm9j). Only enforced when folder_id actually
// changes, so re-saving a file that already sits in an out-of-scope
// folder (reachable via a direct client share) still works. The
// integer rule admits numeric strings, so cast before the strict
@@ -322,7 +325,9 @@ class FilesController extends Controller
$validated['folder_id'] = (int) $validated['folder_id'];
if ($validated['folder_id'] !== $file->folder_id) {
$this->scope->folders($user)->findOrFail($validated['folder_id']);
$destination = $this->scope->folders($user)->whereKey($validated['folder_id'])->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
}
@@ -26,6 +26,7 @@ use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\Rules;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -91,12 +92,36 @@ class ChunkedUploadsController extends Controller
$folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null;
abort_unless(Folder::uploadableBy($user, $folder), 403);
// One session per file, and a person uploads a handful at a time.
// A cap is here because nothing else counts sessions: for anyone
// without a quota to spend — staff, and clients on an installation
// that sets no quotas — the number of sessions is the only thing
// standing between a declared size and any multiple of it.
$openSessions = UploadSession::query()->where('user_id', $user->id)->count();
$maxOpen = max(1, (int) config('projectsend.uploads.max_open_sessions'));
if ($openSessions >= $maxOpen) {
throw ValidationException::withMessages([
'filename' => __('Too many uploads are already in progress. Finish or cancel one and try again.'),
]);
}
// The declared size here is client-supplied and unverified until
// complete()'s real assembled byte count — re-checked there too.
if ($user->isClient()) {
$quotaBytes = $this->storageUsage->quotaBytes($user);
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
// Sessions already open count too, at the size they declared.
// A quota measured against stored files alone is spent twice
// over by opening the sessions one after another: each one is
// told there is room, because the ones before it had not
// finished and so had not become files. putPart() holds each
// session to its declaration, so reserving the declarations
// here is what puts bytes waiting on the temporary volume
// under the same ceiling as bytes that landed.
$pendingBytes = (int) UploadSession::query()->where('user_id', $user->id)->sum('size');
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $pendingBytes + (int) $validated['size'] > $quotaBytes) {
throw ValidationException::withMessages([
'size' => __('This upload would exceed your storage quota of :quota MB.', [
// The resolved quota, not the column: a client who
@@ -187,13 +212,7 @@ class ChunkedUploadsController extends Controller
// ownership of the session is still enforced below.
$this->authorizeSession($request, $session);
// signPart() bounds the part number; bound the part body too, or a
// session can absorb unlimited bytes. The quota is only enforceable
// at complete(), against the assembled size — until then nothing
// stops a client declaring a 1-byte upload and streaming gigabytes
// of parts, which never becomes a File row and so never counts
// against anything. Stale sessions are purged daily, so without a
// cap here the exposure is a day's worth of disk.
// signPart() bounds the part number; bound the part body too.
abort_unless($part >= 1 && $part <= 10000, 422);
$maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024;
@@ -205,16 +224,48 @@ class ChunkedUploadsController extends Controller
abort(413);
}
// Bounding one request bounds one request, and nothing else. Ten
// thousand part numbers at twice a 20 MB part is about 400 GB per
// session, sessions were not counted against anything, and none of
// it becomes a File row — so a client with a 1 MB quota could
// declare a one-byte upload and fill the temporary volume, then do
// it again. The session needs a ceiling of its own, and the room
// for a part has to be claimed before the part is read: a body's
// length is not known until it has arrived, and by then it is on
// the disk this is protecting.
//
// The ceiling is the size the session declared, which store() has
// already weighed against the file-size limit and the quota. So
// what a part gets is whatever the session has left, and the write
// is then capped at exactly that — an over-long body is cut off
// mid-stream as it always was, just against a smaller number.
$reserve = $this->reservePartRoom($session, $part, $limit);
if ($reserve < 1) {
// 413 rather than 422: this is about the size of what is being
// sent, and a client's resume logic already understands it. The
// session survives — the parts it holds are untouched, and it
// can still be completed or aborted.
abort(413);
}
$stream = $request->getContent(true);
try {
$etag = $this->parts->storePart($session, $part, $stream, $limit);
$etag = $this->parts->storePart($session, $part, $stream, $reserve);
} catch (PartTooLargeException) {
abort(413);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
// In the finally, because every way out of here needs it: the
// refused part was deleted and weighs nothing, a client that
// hung up left a short one, and a clean write leaves exactly
// what it reserved. Without this a client's own retries would
// slowly exhaust a session that has plenty of room.
$session->settleStaged($reserve, $this->parts->partSize($session, $part));
}
return response('', 200, [
@@ -231,6 +282,52 @@ class ChunkedUploadsController extends Controller
]);
}
/**
* Claim room for one part, returning how many bytes were claimed — 0
* when the session has none left.
*
* Read-then-claim, under a lock held for the two statements and not
* for the transfer. The protocol sends parts in parallel and how many
* is the client's choice, so without it every part in flight reads the
* same "room left" and they all claim it; and making the claim alone
* atomic is no better, because then the honest parallel upload is the
* one that gets refused. The lock is the same per-session shape
* complete() already uses, and it is released before a byte is read.
*/
private function reservePartRoom(UploadSession $session, int $part, int $limit): int
{
$lock = Cache::lock('upload-part:'.$session->id, 30);
try {
$lock->block(15);
} catch (LockTimeoutException) {
// Nothing is wrong with the upload — the queue for this one
// session just did not clear. 503 with Retry-After is what the
// client's own backoff is for.
abort(503, headers: ['Retry-After' => '5']);
}
try {
$existing = $this->parts->partSize($session, $part);
$session->refresh();
// Re-sending a part replaces it rather than adding to it, so
// what it already holds is room this request may spend again.
// That is an ordinary resume.
$room = max(0, $session->size - ($session->staged_bytes - $existing));
$reserve = min($limit, $room);
if ($reserve > 0 && ! $session->reserveStaged($reserve, $existing)) {
return 0;
}
return $reserve;
} finally {
$lock->release();
}
}
/**
* List the parts already received, so an interrupted upload can resume
* rather than start again.
@@ -286,7 +286,11 @@ class FilesController extends Controller
// an out-of-scope folder (reachable via a direct client share) still
// works.
if ($folderId !== null && $folderId !== $file->folder_id) {
$this->scope->folders($user)->findOrFail($folderId);
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
// And one they may publish into, if it is public. Reparenting
// through the edit form is the same privileged write as move().
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
// Normalised into the shape ApplyFileEdits reads, then handed
@@ -344,9 +348,18 @@ class FilesController extends Controller
$folderId = $validated['folder_id'] ?? null;
$user = $request->user();
// The target folder must be one the mover can actually see.
if ($folderId !== null && $user !== null) {
$this->scope->folders($user)->findOrFail($folderId);
// The target folder must be one the mover can actually see, and one
// they are allowed to put content into. Those are two questions:
// a file in a public folder is published by being there, so the
// destination reaches the property `upload_public` guards without
// anybody touching the switch. Asking only the first let an editor
// who is deliberately not allowed to publish do it by dragging
// (GHSA-rxf8-wh8v-jm9j — the move half of GHSA-237r-jx85-j3hr,
// whose fix was wired into the upload paths and no further).
if ($folderId !== null && $user !== null && $folderId !== $file->folder_id) {
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
$file->update(['folder_id' => $folderId]);
@@ -403,13 +416,19 @@ class FilesController extends Controller
&& ($validated['remove_category_ids'] ?? []) === [];
abort_if($touchesNothing, 422, __('Change at least one field before applying a bulk edit.'));
// The target folder must be one this user can actually see — same
// rule move() already applies to a single file's target.
// The target folder must be one this user can actually see, and one
// they may put content into — the same two questions move() asks of
// a single file's target. Checked once, on the destination, rather
// than per file: the destination is one folder for the whole batch,
// and if putting content there publishes it then no file in the
// batch may go.
$targetFolderId = null;
if ($validated['folder_action'] === 'move') {
$targetFolderId = $validated['folder_id'] ?? null;
if ($targetFolderId !== null) {
$this->scope->folders($user)->findOrFail($targetFolderId);
$destination = $this->scope->folders($user)->whereKey($targetFolderId)->firstOrFail();
abort_unless(Folder::uploadableBy($user, $destination), 403);
}
}
@@ -402,7 +402,20 @@ class FoldersController extends Controller
'parent_id' => Rules::folderId(),
]);
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
$user = $request->user();
$newParent = $this->resolveParent($user, $validated['parent_id'] ?? null);
// A folder carries its contents with it, and a folder inside a
// public one is public — isEffectivelyPublic() reads the whole
// ancestry. So dropping a private folder into a public parent
// publishes every file in its subtree at once, which is the same
// act the upload path refuses without `upload_public`. The flag on
// this screen is already guarded (update() above leaves public
// state alone without the permission); the placement was not
// (GHSA-rxf8-wh8v-jm9j).
if ($user !== null) {
abort_unless(Folder::uploadableBy($user, $newParent), 403);
}
$this->folders->move($folder, $newParent);
@@ -12,6 +12,7 @@ use App\Modules\Comments\Access\VisibleCommentScope;
use App\Modules\Comments\CommentingRules;
use App\Modules\Comments\CommentScope;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Access\OwnFileDownloads;
use App\Modules\Files\DownloadLimitScope;
use App\Modules\Files\Editing\ApplyFileEdits;
use App\Modules\Files\Editing\FileExpiry;
@@ -19,6 +20,7 @@ use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Sharing\ClientShareLinks;
use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Files\Versions\FileVersions;
@@ -73,6 +75,8 @@ class MyFilesController extends Controller
private readonly DownloadAllowance $allowance,
private readonly FileVersions $versions,
private readonly FileVersionLinks $versionLinks,
private readonly ClientShareLinks $shareLinks,
private readonly OwnFileDownloads $ownDownloads,
private readonly ApplyFileEdits $fileEdits,
private readonly FileExpiry $expiry,
private readonly ActivityLogger $activity,
@@ -224,6 +228,14 @@ class MyFilesController extends Controller
// docs/theming-files-checklist.md).
$versions = $this->versionLinks->forMany($fileRows, $client);
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
// One query for the page. Already narrowed to links this client
// minted on files this client uploaded — see ClientShareLinks for
// why both halves are required.
$shareUrls = $this->shareLinks->forMany($fileRows, $client);
// Also one query for the page, and also own files only — see
// OwnFileDownloads for why telling a recipient the count would be
// telling them about the other recipients.
$downloads = $this->ownDownloads->forMany($fileRows, $client);
return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
@@ -267,6 +279,19 @@ class MyFilesController extends Controller
// counterpart they were not given is null, not hidden by
// the theme. A theme must never filter this itself.
'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null],
// The public URL for a file of their own, where one
// exists. Null on a file somebody shared with them, and
// null on their own file that has no link — a client has
// no way to mint one, so this is populated only where the
// installation did it for them. Never derived from
// is_mine: a theme renders what is here and nothing else.
'share_url' => $shareUrls[$file->id] ?? null,
// How often this went out and when it last did — the
// answer to "did it arrive?", which on a link-only
// account is the only evidence there is. Null on a file
// somebody shared with this client: not zero, which would
// be a claim about other people's activity, but nothing.
'downloads' => $downloads[$file->id] ?? null,
'categories' => $file->categories->map(fn (Category $category): array => [
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
])->values()->all(),
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Sharing\CreateShareLink;
use App\Modules\Platform\Localization\LocalDay;
use App\Modules\Platform\Localization\TimezoneRegistry;
use Illuminate\Http\RedirectResponse;
@@ -30,6 +31,7 @@ class ShareLinksController extends Controller
public function __construct(
private readonly ActivityLogger $activity,
private readonly TimezoneRegistry $timezones,
private readonly CreateShareLink $links,
) {}
public function store(Request $request, File $file): RedirectResponse
@@ -80,16 +82,16 @@ class ShareLinksController extends Controller
]);
}
ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => $validated['token'] ?? Str::random(32),
'created_by' => $user->id,
'expires_at' => $user->can('set_file_expiration_date') ? $expiresAt : null,
'max_downloads' => $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
]);
$this->activity->log(Action::ShareLinkCreated, subject: $file);
// The permission gates stay here, where the request is: whether
// this person may set an expiry or a cap is a fact about them,
// not about link creation, and the action has no viewer to ask.
$this->links->for(
file: $file,
creator: $user,
expiresAt: $user->can('set_file_expiration_date') ? $expiresAt : null,
maxDownloads: $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
token: $validated['token'] ?? null,
);
return back()->with('success', __('Public link created.'));
}
+41 -7
View File
@@ -152,15 +152,26 @@ class Folder extends Model
}
/**
* Whether $user may upload a new file directly into $folder (null =
* loose at the root, always allowed).
* Whether $user may put content into $folder (null = loose at the
* root, always allowed).
*
* **Read the name as "may place into", not "may upload into".** Every
* way a file arrives in a folder has to come through here, and the
* name cost us one advisory already: the publication rule below was
* written for GHSA-237r-jx85-j3hr and wired into the upload paths
* alone, because those are what the name suggested. Moving a file in,
* bulk-moving a selection in, reparenting one through the edit form,
* and dragging a whole folder into a public parent all put content
* somewhere too, and none of them asked (GHSA-rxf8-wh8v-jm9j). They
* ask now. Anything new that writes a `folder_id` or a `parent_id`
* belongs on this list.
*
* Staff are held to the library boundary they are held to everywhere
* else: an unscoped staff member may use any folder, a client-scoped
* one only the folders StaffLibraryScope already shows them. This is
* the only place that decides it: every upload path — the web form,
* the API and the chunked flow the browser actually posts to — comes
* through here rather than checking folder_id for itself.
* one only the folders StaffLibraryScope already shows them. Callers
* that have already resolved the destination through
* StaffLibraryScope::folders() have answered that half — the two are
* the same query — and call this for the publication half.
*
* For a client this is unchanged, and is still the whole of the
* check: they own the folder, or it is a public folder that opts into
@@ -174,7 +185,30 @@ class Folder extends Model
}
if ($user->isStaff()) {
return app(StaffLibraryScope::class)->allowsFolder($user, $folder);
if (! app(StaffLibraryScope::class)->allowsFolder($user, $folder)) {
return false;
}
// Being allowed to reach the folder is not the same as being
// allowed to publish, and putting a file in a public folder
// publishes it: isEffectivelyPublic() is "my own flag, or my
// folder's". So the destination reaches the property that
// `upload_public` guards, without ever touching the switch
// (GHSA-237r-jx85-j3hr).
//
// The keys already say this. The client branch below has always
// asked for `upload_to_public_folders` here, and
// MyFilesController's picker calls that the established meaning
// of the two — it was simply never asked on a staff role, which
// left that permission doing nothing at all for staff.
//
// Effectively public, not `public`: the flag is inherited down
// a subtree, so a private folder inside a public one publishes
// just the same and a check on the folder's own flag would walk
// straight past it.
return ! $folder->isEffectivelyPublic()
|| $user->can('upload_public')
|| $user->can('upload_to_public_folders');
}
return $folder->isOwnedBy($user)
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Sharing;
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use Illuminate\Support\Collection;
/**
* The public URLs a client may be shown for their own files.
*
* A client's portal lists two kinds of file side by side: what they
* uploaded, and what somebody shared with them. Both can carry share
* links, and only one kind of link is theirs to see — a link a staff
* member minted for a file they were given is that staff member's
* decision about who may reach it, and handing the recipient the URL
* would turn "you may download this" into "you may pass this on to
* anyone".
*
* So the rule is narrow and stated once: **a link this client created, on
* a file this client uploaded.** Both halves, not either. Neither is
* redundant — a client-created link on a file they no longer own would
* outlive a reassignment, and a staff link on their own upload is still
* not theirs to hand out.
*
* Inactive links are left out rather than shown greyed: the only thing a
* client can do with this is copy it, and a URL that answers "this link
* has expired" is worse than no URL at all.
*
* Resolved for a whole page at a time. One query for the listing, not one
* per row.
*/
class ClientShareLinks
{
/**
* @param Collection<int, File> $files
* @return array<int, string> file id => URL, for the files that have one
*/
public function forMany(Collection $files, User $client): array
{
$own = $files
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
if ($own === []) {
return [];
}
$links = ShareLink::query()
->where('shareable_type', (new File)->getMorphClass())
->whereIn('shareable_id', $own)
->where('created_by', $client->id)
// Oldest first, so a file that somehow carries two is
// described by the one the client has already been given
// rather than by whichever the database returned today.
->orderBy('id')
->get();
$urls = [];
foreach ($links as $link) {
$fileId = (int) $link->shareable_id;
if (isset($urls[$fileId]) || ! $link->isActive()) {
continue;
}
$urls[$fileId] = route('share.show', $link->token);
}
return $urls;
}
}
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Sharing;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use Carbon\CarbonInterface;
use Illuminate\Support\Str;
/**
* Minting a public link for a file, in one place.
*
* Extracted from ShareLinksController rather than invented: the
* controller is an HTTP handler behind `staff` middleware, and a link now
* needs creating from outside a request as well. Two copies of "make a
* token, write the row, log it" would drift, and the half most likely to
* drift is the token.
*
* **The token is the whole authorization.** There is nothing behind
* /s/{token} — no session, no second factor — so its only defence is
* being unguessable. Str::random(32) is about 190 bits, which is more
* than a UUID's 122; anything minted here gets that and never a chosen
* value. A caller that wants a chosen token is a person typing one into a
* form, and that path stays in the controller where its minimum length
* can be argued about in a validation rule.
*
* Expiry and download caps are the caller's to decide and are passed in
* already resolved, because "the end of the 12th" depends on whose zone
* you are in and this class has no viewer.
*/
class CreateShareLink
{
public function __construct(
private readonly ActivityLogger $activity,
) {}
public function for(
File $file,
User $creator,
?CarbonInterface $expiresAt = null,
?int $maxDownloads = null,
?string $token = null,
): ShareLink {
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => $token ?? Str::random(32),
'created_by' => $creator->id,
'expires_at' => $expiresAt,
'max_downloads' => $maxDownloads,
]);
$this->activity->log(Action::ShareLinkCreated, subject: $file);
return $link;
}
}
@@ -6,6 +6,8 @@ namespace App\Modules\Files\Thumbnails;
use App\Modules\Files\Thumbnails\Events\RenderingImage;
use claviska\SimpleImage;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use RuntimeException;
@@ -102,12 +104,111 @@ class ThumbnailGenerator
};
}
/**
* How long a render may hold the lock before another request is
* entitled to assume it died. Generous: a 40-megapixel decode is a
* second or two, and an external source is copied local first.
*/
private const LOCK_SECONDS = 120;
/**
* How long to wait for the request that got there first.
*
* Waiting costs an idle worker — about 35 MB. Rendering costs that
* plus four bytes per source pixel, up to 160 MB at the megapixel
* ceiling. Waiting is the cheap option by an order of magnitude,
* which is the whole reason this exists.
*
* Configurable because the right number depends on how long a decode
* takes here, and that is a property of the machine rather than of
* the application: a small VPS reading a large source off a slow disk
* wants longer than this, and nothing in the code can know that.
*/
private const DEFAULT_LOCK_WAIT_SECONDS = 15;
/**
* Render one image, once, however many requests ask at the same time.
*
* **Why the lock.** Renditions are generated on demand and cached by
* existence, and nothing between the callers stopped two requests
* rendering the same image at once. The atomic rename below settles
* which file survives — it never stopped both from decoding. So N
* concurrent requests for one cold rendition were N full-size decodes,
* each holding four bytes per source pixel.
*
* That is not an attack. A public listing emits a thumbnail URL per
* file, a browser opens six or more connections at once, and the first
* visit to a gallery of ordinary camera images is six simultaneous
* decodes on a container sized for one. It kills the container, and
* because a killed render writes nothing, the cache never warms: the
* page dies again on the next visit. `PublicGroupsController` reaches
* here with no account at all.
*
* **Why waiting rather than refusing.** The request that waits holds
* an idle worker. The request that renders holds a worker plus the
* whole source bitmap. Six waiters cost what one renderer costs, so
* blocking is the cheap answer even when it looks like the slow one.
*
* **Why the re-check after acquiring.** The winner has finished by the
* time a waiter gets in, so the file it was waiting for is already
* there. Re-reading is what turns a wait into a cache hit rather than
* a second render of the same image.
*/
public function generate(
string $sourcePath,
string $destinationPath,
string $mimeType,
ImageAudience $audience,
ImageRendition $rendition,
): void {
// Keyed on the destination, which already encodes the file, the
// audience and the rendition — two requests collide here exactly
// when they would have written the same path.
$lock = Cache::lock('rendition:'.sha1($destinationPath), self::LOCK_SECONDS);
try {
$lock->block($this->lockWaitSeconds());
} catch (LockTimeoutException) {
// Deliberately not rendering anyway. Falling through on
// timeout would reinstate exactly the pile-on this exists to
// stop, at the moment the system is already struggling — one
// failed thumbnail is a better outcome than a container that
// dies and takes the warm cache with it.
throw new RuntimeException('Timed out waiting for another request to render this image.');
}
try {
// Somebody else rendered it while we waited. An empty file is
// not a rendition — same rule the callers apply, and the same
// reason: nothing invalidates one once it is cached.
if (is_file($destinationPath) && filesize($destinationPath) > 0) {
return;
}
$this->render($sourcePath, $destinationPath, $mimeType, $audience, $rendition);
} finally {
$lock->release();
}
}
/**
* Clamped to at least a second: a zero would make every concurrent
* request fail instead of waiting, which is the opposite of the point
* and exactly what a stray empty environment variable produces.
*/
private function lockWaitSeconds(): int
{
$configured = config('projectsend.rendition_lock_wait_seconds');
return max(1, is_numeric($configured) ? (int) $configured : self::DEFAULT_LOCK_WAIT_SECONDS);
}
private function render(
string $sourcePath,
string $destinationPath,
string $mimeType,
ImageAudience $audience,
ImageRendition $rendition,
): void {
$dimensions = @getimagesize($sourcePath);
@@ -118,6 +118,25 @@ class LocalPartStore
return md5_file($path) ?: '';
}
/**
* What one part number currently weighs on disk, 0 if it has never
* arrived. Read before and after a part is received, so the session's
* reservation can be settled against what is really there rather than
* against what the request claimed it would send.
*/
public function partSize(UploadSession $session, int $partNumber): int
{
$path = $this->partPath($session, $partNumber);
if (! is_file($path)) {
return 0;
}
clearstatcache(true, $path);
return (int) (filesize($path) ?: 0);
}
/**
* @return list<array{PartNumber: int, Size: int, ETag: string}>
*/
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Modules\Files\Uploads;
use App\Models\User;
use App\Modules\Files\Events\FileWasStored;
use Illuminate\Support\Facades\Event;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\File;
@@ -52,6 +54,13 @@ class StoreUploadedFile
$this->activity->log($action, $uploader, $file);
// Every upload path converges here — the chunked flow staff and
// clients share, and the synchronous POST beside it — so a
// listener sees each upload once without knowing which route
// produced it. Dispatched after the row exists, so what it
// receives is a complete File.
Event::dispatch(new FileWasStored($file, $uploader));
return $file;
}
@@ -9,6 +9,7 @@ use App\Modules\Files\Models\Folder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\DB;
/**
* A resumable chunked upload in progress. Parts live on disk under the
@@ -20,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int|null $previous_file_id
* @property string $original_name
* @property int $size
* @property int $staged_bytes
* @property string|null $mime_type
* @property string|null $description
* @property string $status
@@ -53,4 +55,71 @@ class UploadSession extends Model
{
return $this->user_id === $user->id;
}
/**
* Claim room on the temporary volume for a part that is about to
* arrive, returning false if the session has no room left.
*
* The claim is made before the bytes are read, and it is one
* statement, because neither weaker version holds. Checking the part
* directory and then writing leaves a gap that every other part
* currently in flight fits through — and the protocol sends parts in
* parallel, so the number of them is the caller's choice, not ours.
* Charging the real size afterwards is the same gap by another name.
*
* $replacing is what the part number already holds, since re-sending a
* part overwrites it rather than adding to it. That is an ordinary
* resume, not an attack.
*
* The ceiling is the size the session declared. A client cannot stage
* more than it said it was sending, which is the invariant the whole
* fix rests on: store() has already measured that declaration against
* the file-size limit and the storage quota, so bounding staged bytes
* by it puts temporary bytes under the same limits as stored ones.
*/
public function reserveStaged(int $bytes, int $replacing = 0): bool
{
$delta = $bytes - $replacing;
$query = static::query()->whereKey($this->getKey());
// Both bounds are rearranged so that the column is never part of a
// subtraction. `staged_bytes + :delta BETWEEN 0 AND size` reads
// naturally and is wrong: staged_bytes is BIGINT UNSIGNED, and on
// MySQL a negative delta makes that expression underflow and raise
// SQLSTATE 22003 — in the comparison, before any row is chosen, so
// the bound meant to prevent it is the thing that trips over it.
// SQLite has no unsigned integers, so the suite cannot see this at
// all; UploadSessionStagedBytesMysqlTest is what covers it.
if ($delta >= 0) {
if ($delta > $this->size) {
return false;
}
$query->where('staged_bytes', '<=', $this->size - $delta);
} else {
// Never give back more than is held.
$query->where('staged_bytes', '>=', -$delta);
}
return $query->update(['staged_bytes' => DB::raw(sprintf('staged_bytes + (%d)', $delta))]) === 1;
}
/**
* Replace a reservation with what the part actually weighs.
*
* Always called, whatever happened to the part: a body shorter than
* its Content-Length, a client that hung up mid-transfer, a part
* refused for being too long and deleted. Whatever is on disk now is
* the truth, and the difference goes back to the session — otherwise
* a client's own retries would slowly exhaust their room.
*/
public function settleStaged(int $reserved, int $actual): void
{
if ($reserved === $actual) {
return;
}
$this->reserveStaged($actual, $reserved);
}
}
@@ -41,7 +41,13 @@ class GroupsController extends Controller
'visibility' => ['nullable', Rule::in(['public', 'private'])],
]);
$query = Group::query()->withCount('members');
$viewer = $request->user();
assert($viewer !== null);
// The API twin of the web listing's narrowing, and it has to be
// here rather than only there: the same disclosure through a token
// is the same disclosure (GHSA-r3hg-3fxw-rcmr).
$query = $this->scope->groups($viewer)->withCount('members');
if (($filters['search'] ?? null) !== null) {
$search = $filters['search'];
@@ -40,7 +40,14 @@ class GroupsController extends Controller
'visibility' => $validated['visibility'] ?? null,
];
$groups = Group::query()
$viewer = $request->user();
assert($viewer !== null);
// Scoped, not Group::query(): a client-scoped staff member is told
// about a group because one of their clients is in it. Without
// this the listing showed every group on the installation, to a
// viewer who could reach nothing of theirs (GHSA-r3hg-3fxw-rcmr).
$groups = $this->scope->groups($viewer)
->withCount('members')
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
->where('name', 'like', "%{$search}%")
@@ -10,6 +10,7 @@ use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\Models\Role;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@@ -61,12 +62,8 @@ class AccountContentDeletion
*/
public function candidates(?User $viewer, ?int $excludeId = null): array
{
return User::query()
return $this->reachableTargets($viewer)
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
->where('type', UserType::Staff)
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
->where('active', true)
->with('role')
->orderBy('name')
->get()
@@ -99,17 +96,62 @@ class AccountContentDeletion
return [];
}
$viewer = $request->user();
return $request->validate([
'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])],
'reassign_to_id' => [
'required_if:content_action,reassign',
'integer',
Rule::exists('users', 'id')->where('active', true),
Rule::notIn([$target->id]),
// The same question the picker asks, asked again of what
// came back from it. It used to be "exists, and is active",
// which is not the boundary the picker documents two
// methods up: a client-scoped staff member was shown their
// own roster and could name anybody, so deleting a roster
// client could hand that client's files and folders to a
// client on somebody else's roster — who then reads, edits
// and deletes them under the own-upload rules
// (GHSA-w29w-pj29-x7ww).
//
// One predicate for both, rather than a matching pair: a
// picker that promises a boundary the write does not keep
// is exactly what this was.
function (string $attribute, mixed $value, Closure $fail) use ($viewer): void {
if (! $this->reachableTargets($viewer)->whereKey($value)->exists()) {
// Deliberately the message an id that does not
// exist at all would get. "Not yours" and "not
// there" have to read the same, or refusing is how
// a scoped staff member enumerates the accounts
// outside their roster.
$fail('validation.exists')->translate();
}
},
],
]);
}
/**
* Every active account $viewer may hand content to: staff, who are
* narrowed nowhere in the application, plus the clients
* StaffLibraryScope shows them. An unscoped viewer gets everybody,
* because clients() returns everybody for them.
*
* $viewer is null only where the question is about the installation
* rather than about a screen — the erasure default in privacy
* settings, which is stored once for everybody.
*
* @return Builder<User>
*/
private function reachableTargets(?User $viewer): Builder
{
return User::query()
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
->where('type', UserType::Staff)
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
->where('active', true);
}
/**
* @param array{content_action?: string, reassign_to_id?: int} $validated
*/
+81
View File
@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity;
use App\Models\User;
/**
* Finding the account that holds an address, exactly.
*
* `where('email', $address)` is not an exact match. It is whatever the
* database's collation says equality means, and the documented one here —
* `utf8mb4_unicode_ci`, in INSTALL.md and in config/database.php — folds
* accents:
*
* administrator@example.com = administrator@éxample.com -> 1
*
* Those are two different domains. `éxample.com` is `xn--xample-9ua.com`,
* a name somebody else can own and prove they own. So an attacker could
* register the second at an OIDC provider, verify it honestly, sign in,
* and be handed the first account — no password, no interaction from its
* owner, and an administrator session if that account was one
* (GHSA-wgxf-v8cr-37mj).
*
* ### Loose is right when refusing and wrong when selecting
*
* The same looseness protects elsewhere and is deliberately left alone.
* `AvailableEmailRule` and `ClientProvisioning::emailIsAvailable()` ask
* "is this address free?", and a collation that answers "no" to a
* near-miss refuses *more* registrations, which is the safe direction.
* This class is for the other question — "which account is this?" — where
* matching more than you meant hands somebody an account.
*
* ### Why the filtering is in PHP
*
* A `COLLATE utf8mb4_bin` in the query would work on MySQL and break
* everywhere else, and the test suite runs on SQLite, which is byte-exact
* and would never have shown the bug in the first place. Comparing here
* gives one answer on every driver, and it is the answer that does not
* depend on how somebody created their database.
*
* Case is still folded, because that is a real requirement rather than an
* accident: addresses are stored lowercased and a provider may send any
* case. `mb_strtolower` folds case without folding accents, which is
* exactly the line to draw.
*/
class AccountLookup
{
/**
* The account whose address is exactly this one, or null.
*
* @param bool $withTrashed include soft-deleted accounts — a
* deleted account still holds its address
* until erasure
*/
public function byEmail(string $email, bool $withTrashed = false): ?User
{
$query = $withTrashed ? User::withTrashed() : User::query();
// The database narrows, this decides. A collation that matches too
// much returns extra rows here and they are dropped; one that
// matches too little was never going to return the right row at
// all, which is a different bug and not one anybody has.
return $query->where('email', $email)->get()
->first(fn (User $user): bool => $this->isSameAddress($user->email, $email));
}
/**
* Whether two strings name the same mailbox: case-insensitively, and
* byte-exact about everything else.
*/
public function isSameAddress(?string $stored, ?string $given): bool
{
if ($stored === null || $given === null) {
return false;
}
return mb_strtolower(trim($stored), 'UTF-8') === mb_strtolower(trim($given), 'UTF-8');
}
}
@@ -8,6 +8,7 @@ use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\Erasure\AvailableEmailRule;
use App\Modules\Identity\FirstAdministrator;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
@@ -30,7 +31,14 @@ class CreateAdminCommand extends Command
public function handle(): int
{
if ($this->option('if-none') && User::query()->where('type', UserType::Staff)->exists()) {
$ifNone = (bool) $this->option('if-none');
// Asked early so an unattended boot does not prompt for a name and
// a password it is about to throw away. It is asked again below,
// under a lock, because this read on its own has the same hole the
// setup screen had: two containers coming up against one database
// both see no staff and both create an administrator.
if ($ifNone && $this->staffExists()) {
$this->info('A staff user already exists; nothing to do.');
return self::SUCCESS;
@@ -57,15 +65,36 @@ class CreateAdminCommand extends Command
return self::FAILURE;
}
$user = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $name,
'email' => $email,
'password' => $password,
'email_verified_at' => now(),
]);
$create = function () use ($name, $email, $password): User {
$user = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $name,
'email' => $email,
'password' => $password,
]);
// forceFill, for the reason SetupController gives beside it:
// email_verified_at is not in User::$fillable, so passing it
// into create() lost it without a word. Whoever provisioned
// this container supplied the address themselves.
$user->forceFill(['email_verified_at' => now()])->save();
return $user;
};
// Without --if-none an operator is asking for an administrator
// outright, whoever else exists, so there is nothing to claim.
$user = $ifNone
? FirstAdministrator::claim(fn (): bool => ! $this->staffExists(), $create)
: $create();
if ($user === null) {
$this->info('A staff user already exists; nothing to do.');
return self::SUCCESS;
}
app(ActivityLogger::class)->log(Action::UserCreated, null, $user);
@@ -84,4 +113,12 @@ class CreateAdminCommand extends Command
return self::SUCCESS;
}
/**
* @phpstan-impure another process can create one between two calls
*/
private function staffExists(): bool
{
return User::query()->where('type', UserType::Staff)->exists();
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Identity\Console;
use App\Models\User;
use App\Modules\Identity\AccountLookup;
use App\Modules\Identity\Erasure\AccountEraser;
use Illuminate\Console\Command;
@@ -25,7 +26,10 @@ class EraseAccountCommand extends Command
{
$email = (string) $this->argument('email');
$user = User::withTrashed()->where('email', $email)->first();
// Exact: this deletes somebody permanently, and a collation that
// folds accents could hand it a different account than the one an
// operator typed. See AccountLookup.
$user = app(AccountLookup::class)->byEmail($email, withTrashed: true);
if ($user === null) {
$this->error("No account found for {$email}.");
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\DB;
@@ -102,6 +103,24 @@ class AccountEraser
$this->activity->logSystem(Action::AccountContentCascadeDeleted, ['name' => $user->name, ...$result]);
}
/**
* The account configured to inherit erased content, or null when
* there is nobody valid to hand it to — in which case handleContent()
* cascades, because orphaning is never the answer.
*
* **A staff member's content may only go to staff.** The target is one
* installation-wide id used for every erasure, and the picker offers
* clients on purpose: erasing a client and handing their files to
* another client is what the setting is for. Applied to a *staff*
* account the same id means something else entirely — a staff library
* is usually the whole installation's, and a client named there would
* inherit all of it, in one unattended scheduled job.
*
* The check cannot live in the settings validation, which is where it
* would otherwise belong: that runs when the target is chosen, and
* whose account will be erased later is not knowable then. So it is
* asked here, where both halves are in hand.
*/
private function fallbackFor(User $user): ?User
{
$id = (int) $this->settings->get(Setting::AccountErasureReassignTo);
@@ -113,6 +132,7 @@ class AccountEraser
return User::query()
->where('active', true)
->whereKeyNot($user->id)
->when($user->isStaff(), fn ($query) => $query->where('type', UserType::Staff))
->find($id);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity;
use App\Models\User;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\EnsureSystemRoles;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Facades\DB;
/**
* Creating the very first administrator is a claim, not a check followed
* by an insert.
*
* "Has this installation been set up" is answered by asking whether any
* staff row exists, and an empty result has nothing in it to lock. So two
* unauthenticated setup requests arriving together both read "no staff",
* both spend a quarter of a second hashing a password, and both insert a
* System Administrator. The operator's own setup succeeds and looks
* entirely normal, which is the point: a stranger walks away with a
* second, permanent administrator account and nothing says so.
* (GHSA-w3w9-prpw-qx77, reported by @ry2811.)
*
* What gets locked is the System Administrator role row. It is the thing
* being claimed; it is written by the roles migration and rewritten on
* every boot, so unlike the staff rows it is always there to be locked.
* The second caller waits on it, and by the time it has the lock the
* first caller's user row is committed and visible — so its own re-check,
* asked inside the claim this time, sees an installation that is already
* set up and creates nothing.
*
* Locking the staff query itself would not do. There are no matching rows
* on a fresh install, and a lock over nothing serialises nothing.
*/
final class FirstAdministrator
{
/**
* Create the initial administrator, or nothing if somebody else got
* there first.
*
* @param callable(): bool $stillNeeded asked again with the claim held
* @param callable(): User $create runs only if it is still needed
* @return User|null null when the claim was lost
*/
public static function claim(callable $stillNeeded, callable $create): ?User
{
// The lock needs a row to bite on. This is idempotent and already
// runs on every boot; asking again costs one query on the one
// request in the life of an installation that comes through here,
// and means a database somehow missing its roles gets them back
// rather than quietly racing.
(new EnsureSystemRoles)->ensure();
return DB::transaction(function () use ($stillNeeded, $create): ?User {
Role::query()
->where('name', SystemRole::SystemAdministrator->value)
->lockForUpdate()
->value('id');
return $stillNeeded() ? $create() : null;
});
}
}
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\FirstAdministrator;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
@@ -54,17 +55,46 @@ class SetupController extends Controller
'password' => ['required', 'confirmed', Password::defaults()],
]);
$this->settings->set(Setting::SiteName, $validated['site_name']);
// The check above is not enough on its own: it is a plain read, and
// between it and the insert a second setup request can do the same
// read and insert an administrator of its own. Everything this
// request writes therefore happens inside the claim, so a request
// that loses the race writes nothing at all — not the site name
// either. See FirstAdministrator.
$admin = FirstAdministrator::claim(
fn (): bool => ! $this->setupIsComplete(),
function () use ($validated): User {
$this->settings->set(Setting::SiteName, $validated['site_name']);
$admin = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
'email_verified_at' => now(),
]);
$admin = User::create([
'type' => UserType::Staff,
'active' => true,
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
]);
// forceFill, not part of the create() array:
// email_verified_at is deliberately absent from
// User::$fillable, so mass assignment dropped it in silence
// and this account was never marked verified. The first
// administrator typed their own address into the form in
// front of them; there is nobody to confirm it to. (Inert
// today, since MustVerifyEmail is not enabled on the model,
// but the column is what a later switch would read.)
$admin->forceFill(['email_verified_at' => now()])->save();
return $admin;
},
);
// Somebody else finished setup while this request was in flight.
// Theirs is the administrator that exists; this one is sent to the
// login screen like any other visitor to an installed site.
if ($admin === null) {
return redirect()->route('home');
}
// v1 logged installation as action 0; setup is a recorded action.
$this->activity->log(Action::SetupCompleted, $admin);
@@ -104,6 +134,9 @@ class SetupController extends Controller
* The middleware and this must agree — one of them saying "not set
* up" while the other says "set up" is either a redirect loop or an
* open form.
*
* @phpstan-impure asking twice can honestly give two answers, which is
* the entire reason store() asks a second time under a lock
*/
private function setupIsComplete(): bool
{
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Modules\Identity\Social;
use App\Models\User;
use App\Modules\Identity\AccountLookup;
/**
* Which local account, if any, a provider identity signs in as.
@@ -28,6 +29,7 @@ class SocialAuthenticator
{
public function __construct(
private readonly SocialProvisioner $provisioner,
private readonly AccountLookup $accounts,
) {}
public function resolve(SocialSettings $settings, SocialIdentity $identity): SocialResolution
@@ -74,7 +76,12 @@ class SocialAuthenticator
// directory that omits the claim.
$trusted = $identity->emailVerified || ! $settings->require_verified_email;
$existing = User::query()->where('email', $identity->email)->first();
// Exactly this address, not whatever the database's collation
// calls equal. utf8mb4_unicode_ci folds accents, so a verified
// sign-in as administrator@éxample.com — a domain somebody else
// can own — selected administrator@example.com and this method
// then linked the attacker's subject to it (GHSA-wgxf-v8cr-37mj).
$existing = $this->accounts->byEmail($identity->email);
if ($existing !== null) {
// 4/5. The takeover, refused. An unverified address may not
+35 -2
View File
@@ -39,8 +39,40 @@ final readonly class SocialIdentity
* | LinkedIn | `email_verified` claim |
* | OpenID Connect | `email_verified` claim, from the ID token |
* | GitHub | An address at all — Socialite's GithubProvider replaces `email` with the result of `getEmailByToken()`, which only ever returns one that is **primary and verified** |
* | Microsoft | The token's `tid` matching the configured tenant. Entra does not emit a usable `email_verified`, and its `email` claim is user-mutable — pinning the tenant is what makes it mean anything (this is the *nOAuth* class of bug) |
* | Microsoft | The token's `tid` matching the configured tenant **and** `xms_edov` — see below |
* | Facebook | Nothing. The Graph API has no equivalent claim, so an address from Facebook is never treated as verified |
*
* ### Microsoft takes two claims, not one
*
* Entra emits no usable `email_verified`, and its `email` claim is
* user-mutable — populated from `otherMails`/proxyAddresses for a B2B
* guest, among other places. Pinning the tenant was the first answer
* and it is half of one: it defeats the classic cross-tenant *nOAuth*,
* where a stranger's own tenant asserts your address, because a
* foreign tenant carries a different `tid`.
*
* It does nothing about the same attack from *inside* the pinned
* tenant. A colleague, or a guest somebody invited, could shape their
* `email` claim to an administrator's address and have their subject
* bound to that account (GHSA-2rfh-v3j2-2jg7). Tenant-pinning answers
* "which directory said this", never "does this person own that
* address".
*
* `xms_edov` is Microsoft's own answer to the second question — the
* optional claim meaning the tenant has verified it owns the email's
* domain — and their guidance says to require it wherever `email`
* identifies an account. Absent is treated as unverified, which is the
* only safe reading: it is absent by default, so anything else would
* be no check at all.
*
* **What an installation has to do.** The claim must be added to the
* app registration (Token configuration → optional claims → `xms_edov`
* on the ID token). Until it is, Microsoft sign-in still works and
* still creates new accounts — it simply stops silently attaching
* itself to accounts that already exist, and says so, pointing the
* person at signing in with a password and connecting the provider
* from their settings. Accounts already linked are unaffected: they
* resolve by subject, before this is consulted at all.
*/
public static function fromSocialite(
SocialProvider $provider,
@@ -72,7 +104,8 @@ final readonly class SocialIdentity
|| ($raw['email_verified'] ?? null) === 'true',
SocialProvider::Github => true,
SocialProvider::Microsoft => is_string($settings->tenant_id)
&& ($raw['tid'] ?? null) === $settings->tenant_id,
&& ($raw['tid'] ?? null) === $settings->tenant_id
&& (($raw['xms_edov'] ?? null) === true || ($raw['xms_edov'] ?? null) === 'true'),
SocialProvider::Facebook => false,
},
name: is_string($user->getName()) && trim($user->getName()) !== ''
@@ -42,17 +42,58 @@ class ResolvingAnnouncement
) {}
/**
* Audience is declared by the listener and enforced here, rather than
* each listener remembering to check `isStaff`.
*
* The first version of this refused clients outright, which was right
* for the only message that existed — a hosted instance telling its
* administrator about their plan. It stopped being right when a
* message needed to reach the *clients* of a shared instance, and the
* safe way to allow that is not to drop the guard: it is to make
* every caller say who it is talking to, so a listener that forgets
* reaches nobody rather than everybody.
*/
public const AUDIENCE_STAFF = 'staff';
public const AUDIENCE_CLIENTS = 'clients';
/**
* `audience` is required and has no default. A message for staff and
* a message for the people they share with are different messages,
* and a signature that let one be mistaken for the other would put
* the mistake in the quiet direction.
*
* `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
{
public function show(
string $title,
string $body,
string $audience,
?string $actionLabel = null,
?string $actionUrl = null,
string $tone = 'info',
): void {
if ($this->announcement !== null) {
return;
}
// Silently ignored rather than thrown, and deliberately: a
// listener aimed at the wrong audience should show nothing, not
// break the page it was trying to decorate. An unrecognised value
// reaches nobody for the same reason.
$intended = match ($audience) {
self::AUDIENCE_STAFF => $this->isStaff,
self::AUDIENCE_CLIENTS => ! $this->isStaff,
default => false,
};
if (! $intended) {
return;
}
$this->announcement = [
'title' => $title,
'body' => $body,
@@ -17,6 +17,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\RequiredIf;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
@@ -43,6 +44,7 @@ class ExternalStorageSettingsController extends Controller
return Inertia::render('system/settings/storage', [
'active' => $settings->active,
'provider' => $settings->provider->value,
'use_instance_role' => $settings->use_instance_role,
// Never name a top-level Inertia prop "key" — Inertia's React
// renderer spreads page props onto the component via
// `{ key: <internal-remount-key>, ...props }`, and a prop
@@ -78,10 +80,18 @@ class ExternalStorageSettingsController extends Controller
'bucket' => ['required', 'string', 'max:255'],
'root' => ['nullable', 'string', 'max:255'],
// 'sometimes' for the same reason as 'provider' above: absent
// means false, which is what every payload written before this
// choice existed meant.
'use_instance_role' => ['sometimes', 'boolean'],
// Required only for the provider that uses them, so switching
// to GCS does not demand an AWS region that means nothing.
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'access_key' => [self::requiredForStaticS3($request), 'nullable', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
// Still required when the machine's own role is doing the
// authenticating: the credential chain resolves credentials,
// not which region the bucket is in.
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'endpoint' => ['nullable', 'string', 'max:255'],
'use_path_style' => ['required', 'boolean'],
@@ -94,10 +104,13 @@ class ExternalStorageSettingsController extends Controller
$settings = ExternalStorageSettings::current();
$useInstanceRole = (bool) ($validated['use_instance_role'] ?? false);
$settings->fill([
'active' => $validated['active'],
'provider' => $validated['provider'],
'key' => $validated['access_key'] ?? null,
'use_instance_role' => $useInstanceRole,
'key' => $useInstanceRole ? null : ($validated['access_key'] ?? null),
'bucket' => $validated['bucket'],
'region' => $validated['region'] ?? null,
'endpoint' => $validated['endpoint'] ?? null,
@@ -108,7 +121,16 @@ class ExternalStorageSettingsController extends Controller
// A blank credential keeps whatever is already stored — neither
// field is ever round-tripped to the browser (only the has_*
// flags are), so blank means "unchanged", not "cleared".
if (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
//
// Except when the machine's own role takes over, which is the one
// thing that does clear it. The whole point of the setting is that
// no long-lived AWS credential is kept here, and a secret left
// sitting in the row unused would still be in the next database
// dump — and would silently come back the moment the box is
// unticked.
if ($useInstanceRole) {
$settings->secret = null;
} elseif (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
$settings->secret = $validated['secret'];
}
@@ -144,7 +166,8 @@ class ExternalStorageSettingsController extends Controller
$validated = $request->validate([
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
'bucket' => ['required', 'string', 'max:255'],
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'use_instance_role' => ['sometimes', 'boolean'],
'access_key' => [self::requiredForStaticS3($request), 'nullable', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'endpoint' => ['nullable', 'string', 'max:255'],
@@ -174,13 +197,21 @@ class ExternalStorageSettingsController extends Controller
$config = [
'version' => 'latest',
'region' => $validated['region'],
'credentials' => [
'key' => $validated['access_key'],
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
],
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
];
// Omitted entirely, not left blank: an S3Client handed a
// 'credentials' array is told to use it, so an empty one fails
// instead of falling through to the default credential provider
// chain. This is what makes the button test the same thing the
// uploads will do — see ExternalStorageConfigApplier::applyS3().
if (! ($validated['use_instance_role'] ?? false)) {
$config['credentials'] = [
'key' => $validated['access_key'],
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
];
}
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
$config['endpoint'] = $validated['endpoint'];
}
@@ -210,6 +241,22 @@ class ExternalStorageSettingsController extends Controller
iterator_to_array($bucket->objects(['maxResults' => 1]), false);
}
/**
* An access key is only demanded of an S3 backend that is actually
* going to authenticate with one. Shared by both the save and the
* connection test so the two cannot disagree about what is required.
*/
private static function requiredForStaticS3(Request $request): RequiredIf
{
// Rule::requiredIf(), not a closure rule: this has to fire when
// the field is missing from the payload altogether, and a closure
// rule is not implicit — it never runs on an absent attribute.
return Rule::requiredIf(
fn (): bool => $request->input('provider') === StorageProvider::S3->value
&& ! $request->boolean('use_instance_role')
);
}
/**
* A credential field left blank means "keep what is stored" on save,
* so the connection test has to read it the same way — otherwise
@@ -6,6 +6,7 @@ namespace App\Modules\Platform\Installation\Console;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Models\File;
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
use App\Modules\Identity\UserType;
@@ -149,6 +150,28 @@ use Throwable;
* quota nobody is watching. The installation looks completely healthy
* while it happens, to its operator and to its administrator alike.
*
* ### `settings` holds the three an outsider has to act on, and no more
*
* Not a dump of the settings table. Everything here leaves the container,
* so each field needs a reason somebody outside would act on it, and
* these three have one: they are the settings whose wrong value is
* invisible from outside and expensive.
*
* `two_factor_enforcement` is what the platform sold compared against
* what the installation applied. The other two are one question in two
* halves -- **who can make an account here, and what that account is
* allowed** -- and the answer matters most where it is least visible. On
* a shared installation every client is a separate customer, so
* self-registration switched on means accounts appearing that nobody
* provisioned, and a default quota of zero means those accounts have no
* ceiling at all. Both defaults are the permissive ones (see Setting),
* which is right for a self-hosted install setting itself up and wrong
* for an installation somebody else is operating.
*
* Neither is a secret: both are on the client settings screen any
* administrator can open. What the document adds is that a watcher can
* see them without one.
*
* ### A version is a decision, a commit is a fact
*
* `build` says which commit this installation was built from. A version
@@ -206,8 +229,12 @@ class StatusCommand extends Command
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats, Settings $settings): int
{
public function handle(
CapabilityRegistry $capabilities,
SeatAllowance $seats,
Settings $settings,
ClientStorageUsage $quotas,
): int {
$status = [
'version' => (string) config('projectsend.version'),
'edition' => $capabilities->edition()->value,
@@ -252,6 +279,25 @@ class StatusCommand extends Command
// the middleware enforces would be worse than reporting
// none at all.
'two_factor_enforcement' => $this->enforcement($settings),
// Whether strangers can make themselves an account here.
// Read the way RegistrationController reads it, `=== true`
// included: `get()` casts a boolean with `(bool)`, so the
// only value that is neither true nor false is null, and
// null is what the gate treats as closed.
'clients_can_register' => $settings->get(Setting::ClientsCanRegister) === true,
// What a client with no quota of their own is allowed, in
// megabytes. **Zero means unlimited**, which is the whole
// reason this is worth reporting: it is the default, it is
// the answer for every account created without one asked
// for, and nothing else in this document reveals it.
//
// The *effective* number, through the same method the
// upload check resolves it with, rather than the setting
// read on its own. A platform can put a floor under it from
// the environment, and a document that reported the setting
// while the uploads obeyed the floor would say the ceiling
// was missing on an installation that has one.
'default_client_storage_quota_mb' => $quotas->defaultQuotaMb(),
],
// Cast so an installation with no packages emits {} rather
// than [] -- an empty PHP array encodes as a list, and a
@@ -50,7 +50,9 @@ class ExternalStorageConfigApplier
// account's private key included — in the same database whose dump
// the `encrypted` cast exists to survive. Both are now read straight
// from the row, by the provider branch that uses them.
private const CACHE_KEY = 'platform.external_storage_settings.v3';
// v4: use_instance_role joined the shape. Not a credential — it is
// the fact that there isn't one — so it caches like the rest.
private const CACHE_KEY = 'platform.external_storage_settings.v4';
public function __construct(
private readonly CapabilityRegistry $capabilities,
@@ -93,8 +95,16 @@ class ExternalStorageConfigApplier
*/
private function applyS3(array $resolved): void
{
Config::set('filesystems.disks.files_external.key', $resolved['key']);
Config::set('filesystems.disks.files_external.secret', $this->credential('secret'));
// Left null on purpose when the machine's own role is doing the
// authenticating. Laravel's FilesystemManager::formatS3Config()
// only builds a `credentials` entry when both a key and a secret
// are non-empty, and the AWS SDK falls back to its default
// credential provider chain — ECS task role, EC2 instance
// profile, EKS/IRSA, environment — whenever none is supplied.
// Passing an empty string instead of nothing would be a
// credential, and would fail rather than fall through.
Config::set('filesystems.disks.files_external.key', $resolved['use_instance_role'] ? null : $resolved['key']);
Config::set('filesystems.disks.files_external.secret', $resolved['use_instance_role'] ? null : $this->credential('secret'));
Config::set('filesystems.disks.files_external.region', $resolved['region']);
Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']);
Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']);
@@ -172,13 +182,14 @@ class ExternalStorageConfigApplier
* filled in and active, nothing more. Callers AND the capability check
* live and uncached — see class docblock.
*
* @return array{configured: bool, provider: string, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
* @return array{configured: bool, provider: string, use_instance_role: bool, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
*/
private function resolve(): array
{
$blank = [
'configured' => false,
'provider' => StorageProvider::S3->value,
'use_instance_role' => false,
'key' => null,
'region' => null, 'bucket' => null,
'endpoint' => null, 'use_path_style' => false, 'root' => null,
@@ -202,6 +213,7 @@ class ExternalStorageConfigApplier
return [
'configured' => true,
'provider' => $settings->provider->value,
'use_instance_role' => $settings->use_instance_role,
'key' => $settings->key,
'region' => $settings->region,
'bucket' => $settings->bucket,
@@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Model;
* @property int $id
* @property bool $active
* @property StorageProvider $provider
* @property bool $use_instance_role
* @property string|null $key
* @property string|null $secret
* @property string|null $key_file
@@ -33,6 +34,7 @@ class ExternalStorageSettings extends Model
protected $fillable = [
'active',
'provider',
'use_instance_role',
'key',
'secret',
'key_file',
@@ -55,6 +57,7 @@ class ExternalStorageSettings extends Model
protected $attributes = [
'active' => false,
'provider' => 's3',
'use_instance_role' => false,
'use_path_style' => false,
];
@@ -63,6 +66,7 @@ class ExternalStorageSettings extends Model
return [
'active' => 'boolean',
'provider' => StorageProvider::class,
'use_instance_role' => 'boolean',
'secret' => 'encrypted',
'key_file' => 'encrypted',
'use_path_style' => 'boolean',
@@ -71,7 +75,38 @@ class ExternalStorageSettings extends Model
public static function current(): self
{
return static::query()->firstOrNew([]);
$settings = static::query()->firstOrNew([]);
// Column defaults — the $attributes array above — apply to a NEW
// model, never to one hydrated from a row. So a row written by an
// older release, before one of these columns existed, reads that
// column as null however sensible its default is.
//
// That matters here more than it would anywhere else, because
// PlatformServiceProvider::boot() reads these settings on every
// process boot — and boot happens BEFORE `artisan migrate` runs.
// For the length of an upgrade the code is new and the schema is
// still old, and every artisan command in that window, including
// the one that would run the migrations, boots through here.
//
// A null `provider` made the match in isConfigured() throw
// UnhandledMatchError, which the official image's readiness probe
// reported to the operator as "database unreachable" — on a
// perfectly reachable database, in a container that then
// restart-looped without ever reaching the migration that would
// have fixed it (#1770, upgrading from 2.0/2.1 with external
// storage configured).
//
// Applying the defaults to a hydrated row closes that window for
// every column that has one, rather than for the single column
// where it was found. Inert on any install whose schema is current.
foreach ((new self)->getAttributes() as $column => $default) {
if (! array_key_exists($column, $settings->getAttributes())) {
$settings->setAttribute($column, $default);
}
}
return $settings;
}
/**
@@ -88,8 +123,19 @@ class ExternalStorageSettings extends Model
// What counts as "filled in" is per provider, because the two
// authenticate with different things entirely: S3 wants a key and
// a secret, GCS wants a service account key file.
//
// The match is deliberately left total rather than given a default
// arm: current() guarantees a provider even on a row older than the
// column, and a default arm here would quietly swallow a genuinely
// unhandled case instead of naming it.
//
// Unless S3 is being asked to authenticate as the machine it is
// running on, in which case there is no credential to fill in at
// all and demanding one would leave the disk permanently
// "unconfigured" — which fails silently, by leaving every new
// upload on the local disk rather than by reporting anything.
return match ($this->provider) {
StorageProvider::S3 => $this->filled('key') && $this->filled('secret'),
StorageProvider::S3 => $this->use_instance_role || ($this->filled('key') && $this->filled('secret')),
StorageProvider::Gcs => $this->filled('key_file'),
};
}
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Reading an on/off environment variable strictly.
*
* `env()` recognises the words `true` and `false` and hands back
* everything else as the string it was — and every non-empty string is
* truthy in PHP. So the obvious `(bool) env(...)` reads `no`, `off` and
* a typo as **on**:
*
* SOMETHING=no -> on
* SOMETHING=off -> on
* SOMETHING=enabld -> on
*
* For most settings that is a shrug — somebody notices the feature is on
* and fixes the line. It stops being a shrug when the wrong answer is the
* unsafe one, which is every flag that switches a protection *off* or a
* disclosure *on*. Those have to fail the other way, so this lists what
* counts as yes and reads everything else — including anything it does
* not recognise — as no.
*
* The list is deliberately short. Nothing an operator might have meant as
* "no" is in it, which is the point; a value typed as `enabled` turns
* nothing on and is a configuration mistake to be found rather than
* guessed at.
*/
final class EnvFlag
{
private const TRUE_VALUES = ['1', 'true'];
/**
* @param mixed $value whatever `env()` returned
*/
public static function isTrue(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value === 1;
}
return is_string($value)
&& in_array(strtolower(trim($value)), self::TRUE_VALUES, true);
}
}
+37 -2
View File
@@ -1,6 +1,7 @@
<?php
use App\Modules\Platform\Capabilities\Edition;
use App\Support\EnvFlag;
return [
@@ -80,10 +81,33 @@ return [
// read at all and that is how TRUSTED_PROXIES came to silently do
// nothing.
'two_factor_enforcement' => env('PROJECTSEND_TWO_FACTOR_ENFORCEMENT'),
// A floor under what a client with no quota of their own gets, in
// megabytes. Not a setting, for the same reason the seat caps above
// are not: it is the shape of what the platform sold rather than a
// preference the installation's administrator is expressing, and an
// administrator who has chosen a number keeps it — see
// ClientStorageUsage::defaultQuotaMb().
//
// It exists because the setting's own default is 0, and 0 means
// unlimited. On an installation a platform runs for other people
// that is one account away from unmetered hosting, and the account
// does not have to be one the platform created.
'default_client_quota_mb' => env('PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB'),
],
'uploads' => [
'parts_path' => env('UPLOAD_PARTS_PATH'),
// How many resumable uploads one account may have in flight.
//
// A session holds room on the temporary volume from the moment it
// is created until it completes, is cancelled, or is swept — and
// for an account with no storage quota to spend, this number is
// the only thing bounding how much room that is. The browser
// uploads a few files at once, so this is far above anything a
// person does by hand.
'max_open_sessions' => 25,
],
/*
@@ -141,7 +165,12 @@ return [
*/
'captcha' => [
'disabled' => (bool) env('PROJECTSEND_CAPTCHA_DISABLED', false),
// Read strictly rather than cast: `env()` returns anything it does
// not recognise as the string it was, and every non-empty string
// is truthy — so `(bool)` would read `PROJECTSEND_CAPTCHA_DISABLED=no`
// as "yes, disabled" and quietly take the bot protection off the
// login and registration forms. See App\Support\EnvFlag.
'disabled' => EnvFlag::isTrue(env('PROJECTSEND_CAPTCHA_DISABLED', false)),
'managed' => [
'provider' => env('PROJECTSEND_CAPTCHA_MANAGED_PROVIDER'),
@@ -161,7 +190,13 @@ return [
|
*/
'version' => '2.4.0',
// How long a request waits for another one that is already rendering
// the same thumbnail or preview, before giving up rather than
// decoding the same image a second time. See ThumbnailGenerator.
// A slow disk or a large source wants longer than the default 15.
'rendition_lock_wait_seconds' => env('PROJECTSEND_RENDITION_LOCK_WAIT_SECONDS'),
'version' => '2.4.1',
/*
|--------------------------------------------------------------------------
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('external_storage_settings', function (Blueprint $table) {
// Every row that exists predates the choice, and every one of
// them authenticates with a stored key and secret — so `false`
// is what keeps this migration invisible to anyone already
// using external storage.
//
// An explicit column rather than "the key field was left
// blank": on this form a blank credential already means "keep
// the one you have", because neither the secret nor the key
// file is ever sent back to the browser. Blank cannot also
// mean "authenticate a different way" without the two
// meanings colliding on the first save.
$table->boolean('use_instance_role')->default(false)->after('provider');
});
}
public function down(): void
{
Schema::table('external_storage_settings', function (Blueprint $table) {
$table->dropColumn('use_instance_role');
});
}
};
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('upload_sessions', function (Blueprint $table) {
// Bytes this session currently holds on the temporary volume,
// including any part still being received. The filesystem is
// still the part ledger; this is the running total, kept here
// because a limit has to be claimed before the bytes arrive and
// a directory listing cannot be read and written atomically.
$table->unsignedBigInteger('staged_bytes')->default(0)->after('size');
});
}
public function down(): void
{
Schema::table('upload_sessions', function (Blueprint $table) {
$table->dropColumn('staged_bytes');
});
}
};
+16 -2
View File
@@ -58,12 +58,26 @@ fi
# Wait for the database. `migrate` against a database still starting up is
# the single most common first-run failure, and a bare failure here would
# restart-loop the container with a stack trace instead of a clear message.
#
# The probe boots the whole application, so it fails for two quite different
# reasons: the database really is not there yet, or it is there and the
# application could not start. Both used to be reported as the first one,
# which sent an operator off checking credentials that were never wrong
# (#1770). The last failure is kept and printed, so whichever it was is on
# screen instead of being guessed at.
if [ "$1" = "supervisord" ] || [ "$1" = "/usr/bin/supervisord" ]; then
i=0
until su-exec www-data php artisan db:show --quiet >/dev/null 2>&1; do
until probe_error=$(su-exec www-data php artisan db:show --quiet 2>&1); do
i=$((i + 1))
if [ "$i" -ge 60 ]; then
echo "projectsend: database unreachable after 60s — check DB_HOST, DB_DATABASE and credentials" >&2
echo "projectsend: gave up waiting for the database after 60s." >&2
echo "projectsend: the last attempt failed with:" >&2
printf '%s\n' "$probe_error" | tail -n 20 >&2
echo "projectsend:" >&2
echo "projectsend: if that names the database host, the connection or the credentials," >&2
echo "projectsend: check DB_HOST, DB_DATABASE, DB_USERNAME and DB_PASSWORD." >&2
echo "projectsend: if it is an application error, the database is fine and this is a" >&2
echo "projectsend: bug — please report it at https://github.com/projectsend/projectsend/issues" >&2
exit 1
fi
[ "$i" = 1 ] && echo "projectsend: waiting for the database..."
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Més",
"Copy the public link to this file": "Copia l'enllaç públic d'aquest fitxer",
"Downloaded :count times, last on :date": "Descarregat :count vegades, l'última el :date",
"Downloaded once, on :date": "Descarregat una vegada, el :date",
"Not downloaded yet": "Encara no s'ha descarregat",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Afegeix també la reclamació opcional \"xms_edov\" al registre de l'aplicació, a Configuració de testimoni. Indicar el tenant diu quin directori avala l'inici de sessió; aquesta reclamació diu que el directori ha comprovat que la persona és realment propietària de l'adreça. Sense ella, algú altre dins del teu tenant podria iniciar sessió amb l'adreça d'un company, de manera que ProjectSend crearà comptes nous però mai no vincularà un inici de sessió de Microsoft a un compte que ja existeix.",
"A password reset goes to this address, so changing it needs your password.": "El restabliment de contrasenya s'envia a aquesta adreça, així que canviar-la requereix la teva contrasenya.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "La teva adreça de correu prové del directori o del proveïdor d'identitat amb què inicies sessió, i no es pot canviar aquí.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Els enllaços de restabliment duren una hora. Demana'n un de nou i arribarà de seguida.",
"Send me a new link": "Envia'm un enllaç nou",
"This link has expired": "Aquest enllaç ha caducat",
"This password reset link is no longer valid. Ask for a new one and try again.": "Aquest enllaç de restabliment ja no és vàlid. Demana'n un de nou i torna-ho a provar.",
"Authenticate as this server's IAM role": "Autentica't amb el rol d'IAM d'aquest servidor",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Per a instal·lacions a AWS que ja tinguin un rol assignat: un rol de tasca d'ECS, un perfil d'instància d'EC2, EKS/IRSA. ProjectSend demana credencials temporals al SDK d'AWS en lloc de desar una clau d'accés. Deixa-ho desactivat per a MinIO, Backblaze, Wasabi i qualsevol altre servei que necessiti una clau i un secret.",
"Saving will delete the access key and secret currently stored here.": "En desar s'esborraran la clau d'accés i la clau secreta que hi ha desades aquí.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ja hi ha massa pujades en curs. Acaba'n o cancel·la'n una i torna-ho a provar."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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ší"
"More": "Další",
"Copy the public link to this file": "Zkopírovat veřejný odkaz na tento soubor",
"Downloaded :count times, last on :date": "Stažení: :count — naposledy :date",
"Downloaded once, on :date": "Staženo jednou, :date",
"Not downloaded yet": "Zatím nestaženo",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Přidejte také volitelný nárok \"xms_edov\" do registrace aplikace v části Konfigurace tokenu. Uvedení tenanta říká, který adresář se za přihlášení zaručil; tento nárok říká, že adresář ověřil, že adresa opravdu patří dané osobě. Bez něj by se někdo jiný ve vašem tenantovi mohl přihlásit adresou kolegy, takže ProjectSend bude vytvářet nové účty, ale nikdy nepřipojí přihlášení přes Microsoft k účtu, který už existuje.",
"A password reset goes to this address, so changing it needs your password.": "Obnovení hesla se posílá na tuto adresu, takže její změna vyžaduje vaše heslo.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Vaše e-mailová adresa pochází z adresáře nebo poskytovatele identity, přes kterého se přihlašujete, a nelze ji zde změnit.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Odkazy pro obnovení platí jednu hodinu. Požádejte o nový a za chvíli dorazí.",
"Send me a new link": "Pošlete mi nový odkaz",
"This link has expired": "Platnost tohoto odkazu vypršela",
"This password reset link is no longer valid. Ask for a new one and try again.": "Tento odkaz pro obnovení už neplatí. Požádejte o nový a zkuste to znovu.",
"Authenticate as this server's IAM role": "Ověřovat se rolí IAM tohoto serveru",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Pro instalace běžící na AWS, ke kterým je již přiřazena role – role úlohy ECS, profil instance EC2, EKS/IRSA. ProjectSend si vyžádá dočasné přihlašovací údaje od AWS SDK místo toho, aby ukládal přístupový klíč. Pro MinIO, Backblaze, Wasabi a cokoli dalšího, co vyžaduje klíč a tajný klíč, nechte vypnuté.",
"Saving will delete the access key and secret currently stored here.": "Uložením se smaže přístupový klíč i tajný klíč, které jsou zde nyní uložené.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Už probíhá příliš mnoho nahrávání. Dokončete nebo zrušte jedno z nich a zkuste to znovu."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Mehr",
"Copy the public link to this file": "Öffentlichen Link zu dieser Datei kopieren",
"Downloaded :count times, last on :date": ":count Mal heruntergeladen, zuletzt am :date",
"Downloaded once, on :date": "Einmal heruntergeladen, am :date",
"Not downloaded yet": "Noch nicht heruntergeladen",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Fügen Sie außerdem den optionalen Anspruch \"xms_edov\" unter Tokenkonfiguration zu Ihrer App-Registrierung hinzu. Die Angabe des Mandanten sagt, welches Verzeichnis für die Anmeldung bürgt; dieser Anspruch sagt, dass das Verzeichnis geprüft hat, dass die Person die Adresse wirklich besitzt. Ohne ihn könnte sich jemand anderes in Ihrem Mandanten mit der Adresse einer Kollegin anmelden, daher legt ProjectSend zwar neue Konten an, verknüpft eine Microsoft-Anmeldung aber nie mit einem bereits bestehenden Konto.",
"A password reset goes to this address, so changing it needs your password.": "Eine Kennwortzurücksetzung geht an diese Adresse, daher ist für eine Änderung Ihr Kennwort erforderlich.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Ihre E-Mail-Adresse stammt aus dem Verzeichnis oder Identitätsanbieter, mit dem Sie sich anmelden, und kann hier nicht geändert werden.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Zurücksetzungslinks gelten eine Stunde. Fordern Sie einen neuen an, er trifft gleich ein.",
"Send me a new link": "Neuen Link senden",
"This link has expired": "Dieser Link ist abgelaufen",
"This password reset link is no longer valid. Ask for a new one and try again.": "Dieser Link zum Zurücksetzen ist nicht mehr gültig. Fordern Sie einen neuen an und versuchen Sie es erneut.",
"Authenticate as this server's IAM role": "Mit der IAM-Rolle dieses Servers authentifizieren",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Für Installationen auf AWS, denen bereits eine Rolle zugewiesen ist – eine ECS-Task-Rolle, ein EC2-Instanzprofil, EKS/IRSA. ProjectSend fordert temporäre Anmeldedaten beim AWS SDK an, statt einen Zugriffsschlüssel zu speichern. Lassen Sie dies für MinIO, Backblaze, Wasabi und alles andere, was Schlüssel und geheimen Schlüssel benötigt, ausgeschaltet.",
"Saving will delete the access key and secret currently stored here.": "Beim Speichern werden der hier hinterlegte Zugriffsschlüssel und der geheime Schlüssel gelöscht.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Es laufen bereits zu viele Uploads. Beenden oder brechen Sie einen ab und versuchen Sie es erneut."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Más",
"Copy the public link to this file": "Copiar el enlace público a este archivo",
"Downloaded :count times, last on :date": "Descargado :count veces, la última el :date",
"Downloaded once, on :date": "Descargado una vez, el :date",
"Not downloaded yet": "Todavía no se descargó",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Añade también la notificación opcional \"xms_edov\" al registro de tu aplicación, en Configuración de token. Indicar el tenant dice qué directorio avaló el inicio de sesión; esa notificación dice que el directorio comprobó que la persona es realmente dueña de la dirección. Sin ella, alguien más dentro de tu tenant podría entrar con la dirección de un compañero, así que ProjectSend creará cuentas nuevas pero nunca enlazará un inicio de sesión de Microsoft con una cuenta que ya existe.",
"A password reset goes to this address, so changing it needs your password.": "El restablecimiento de contraseña se envía a esta dirección, así que cambiarla necesita tu contraseña.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Tu dirección de correo viene del directorio o proveedor de identidad con el que inicias sesión, y no se puede cambiar aquí.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Los enlaces de restablecimiento duran una hora. Pedí uno nuevo y llegará en un momento.",
"Send me a new link": "Enviame un enlace nuevo",
"This link has expired": "Este enlace ha caducado",
"This password reset link is no longer valid. Ask for a new one and try again.": "Este enlace de restablecimiento ya no es válido. Pedí uno nuevo y volvé a intentarlo.",
"Authenticate as this server's IAM role": "Autenticarse con el rol de IAM de este servidor",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Para instalaciones en AWS que ya tengan un rol asignado: un rol de tarea de ECS, un perfil de instancia de EC2, EKS/IRSA. ProjectSend le pide credenciales temporales al SDK de AWS en lugar de guardar una clave de acceso. Déjalo desactivado para MinIO, Backblaze, Wasabi y cualquier otro servicio que necesite una clave y una clave secreta.",
"Saving will delete the access key and secret currently stored here.": "Al guardar se borrarán la clave de acceso y la clave secreta que hay guardadas aquí.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ya hay demasiadas subidas en curso. Termina o cancela una e inténtalo de nuevo."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Plus",
"Copy the public link to this file": "Copier le lien public vers ce fichier",
"Downloaded :count times, last on :date": "Téléchargé :count fois, la dernière le :date",
"Downloaded once, on :date": "Téléchargé une fois, le :date",
"Not downloaded yet": "Pas encore téléchargé",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Ajoutez également la revendication facultative « xms_edov » à votre inscription d'application, sous Configuration des jetons. Indiquer le locataire dit quel annuaire s'est porté garant de la connexion ; cette revendication dit que l'annuaire a vérifié que la personne possède réellement l'adresse. Sans elle, quelqu'un d'autre dans votre locataire pourrait se connecter avec l'adresse d'un collègue, aussi ProjectSend créera de nouveaux comptes mais ne rattachera jamais une connexion Microsoft à un compte existant.",
"A password reset goes to this address, so changing it needs your password.": "Une réinitialisation de mot de passe part vers cette adresse, la modifier demande donc votre mot de passe.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Votre adresse e-mail provient de l'annuaire ou du fournisseur d'identité avec lequel vous vous connectez, et ne peut pas être modifiée ici.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Les liens de réinitialisation durent une heure. Demandez-en un nouveau, il arrivera dans un instant.",
"Send me a new link": "Envoyez-moi un nouveau lien",
"This link has expired": "Ce lien a expiré",
"This password reset link is no longer valid. Ask for a new one and try again.": "Ce lien de réinitialisation n'est plus valide. Demandez-en un nouveau et réessayez.",
"Authenticate as this server's IAM role": "S'authentifier avec le rôle IAM de ce serveur",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Pour les installations sur AWS disposant déjà d'un rôle — un rôle de tâche ECS, un profil d'instance EC2, EKS/IRSA. ProjectSend demande des identifiants temporaires au SDK AWS au lieu de stocker une clé d'accès. Laissez cette option désactivée pour MinIO, Backblaze, Wasabi et tout autre service nécessitant une clé et une clé secrète.",
"Saving will delete the access key and secret currently stored here.": "L'enregistrement supprimera la clé d'accès et la clé secrète actuellement stockées ici.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Trop de téléversements sont déjà en cours. Terminez-en un ou annulez-en un, puis réessayez."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Lainnya",
"Copy the public link to this file": "Salin tautan publik ke berkas ini",
"Downloaded :count times, last on :date": "Diunduh :count kali, terakhir pada :date",
"Downloaded once, on :date": "Diunduh sekali, pada :date",
"Not downloaded yet": "Belum pernah diunduh",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Tambahkan juga klaim opsional \"xms_edov\" ke pendaftaran aplikasi Anda, di bagian Token configuration. Menyebutkan tenant memberi tahu direktori mana yang menjamin proses masuk; klaim itu menyatakan bahwa direktori telah memeriksa bahwa orang tersebut benar-benar memiliki alamat itu. Tanpanya, orang lain di dalam tenant Anda dapat masuk dengan alamat rekan kerja, sehingga ProjectSend akan membuat akun baru tetapi tidak pernah menautkan proses masuk Microsoft ke akun yang sudah ada.",
"A password reset goes to this address, so changing it needs your password.": "Pengaturan ulang kata sandi dikirim ke alamat ini, jadi mengubahnya memerlukan kata sandi Anda.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Alamat email Anda berasal dari direktori atau penyedia identitas tempat Anda masuk, dan tidak dapat diubah di sini.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Tautan atur ulang berlaku satu jam. Minta yang baru dan akan tiba sebentar lagi.",
"Send me a new link": "Kirimi saya tautan baru",
"This link has expired": "Tautan ini sudah kedaluwarsa",
"This password reset link is no longer valid. Ask for a new one and try again.": "Tautan atur ulang ini sudah tidak berlaku. Minta yang baru dan coba lagi.",
"Authenticate as this server's IAM role": "Autentikasi sebagai peran IAM server ini",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Untuk instalasi yang berjalan di AWS dan sudah memiliki peran terpasang — peran tugas ECS, profil instance EC2, EKS/IRSA. ProjectSend meminta kredensial sementara kepada AWS SDK alih-alih menyimpan kunci akses. Biarkan nonaktif untuk MinIO, Backblaze, Wasabi, dan layanan lain yang memerlukan kunci akses dan kunci rahasia.",
"Saving will delete the access key and secret currently stored here.": "Menyimpan akan menghapus kunci akses dan kunci rahasia yang tersimpan di sini.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Sudah terlalu banyak unggahan yang sedang berjalan. Selesaikan atau batalkan salah satunya, lalu coba lagi."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Altro",
"Copy the public link to this file": "Copia il link pubblico a questo file",
"Downloaded :count times, last on :date": "Scaricato :count volte, l'ultima il :date",
"Downloaded once, on :date": "Scaricato una volta, il :date",
"Not downloaded yet": "Non ancora scaricato",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Aggiungi anche l'attestazione facoltativa \"xms_edov\" alla registrazione dell'app, in Configurazione token. Indicare il tenant dice quale directory ha garantito per l'accesso; quell'attestazione dice che la directory ha verificato che la persona possieda davvero l'indirizzo. Senza di essa, qualcun altro nel tuo tenant potrebbe accedere con l'indirizzo di un collega, quindi ProjectSend creerà nuovi account ma non collegherà mai un accesso Microsoft a un account già esistente.",
"A password reset goes to this address, so changing it needs your password.": "La reimpostazione della password viene inviata a questo indirizzo, quindi cambiarlo richiede la tua password.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Il tuo indirizzo email proviene dalla directory o dal provider di identità con cui accedi e non può essere modificato qui.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "I link di reimpostazione durano un'ora. Chiedine uno nuovo e arriverà tra un momento.",
"Send me a new link": "Inviami un nuovo link",
"This link has expired": "Questo link è scaduto",
"This password reset link is no longer valid. Ask for a new one and try again.": "Questo link di reimpostazione non è più valido. Chiedine uno nuovo e riprova.",
"Authenticate as this server's IAM role": "Autenticati con il ruolo IAM di questo server",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Per installazioni su AWS a cui è già associato un ruolo — un ruolo attività ECS, un profilo istanza EC2, EKS/IRSA. ProjectSend chiede credenziali temporanee all'SDK di AWS invece di memorizzare una chiave di accesso. Lascialo disattivato per MinIO, Backblaze, Wasabi e qualsiasi altro servizio che richieda una chiave di accesso e una chiave segreta.",
"Saving will delete the access key and secret currently stored here.": "Salvando verranno eliminate la chiave di accesso e la chiave segreta memorizzate qui.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ci sono già troppi caricamenti in corso. Completane o annullane uno e riprova."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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": "その他"
"More": "その他",
"Copy the public link to this file": "このファイルの公開リンクをコピー",
"Downloaded :count times, last on :date": ":count 回ダウンロードされました。最終 :date",
"Downloaded once, on :date": "1 回ダウンロードされました。:date",
"Not downloaded yet": "まだダウンロードされていません",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "アプリ登録の「トークン構成」で、オプションの要求「xms_edov」も追加してください。テナントの指定は、どのディレクトリがサインインを保証したかを示します。この要求は、その人が本当にそのアドレスの持ち主であることをディレクトリが確認したことを示します。これがないと、テナント内の別の人が同僚のアドレスでサインインできてしまうため、ProjectSend は新しいアカウントは作成しますが、既存のアカウントに Microsoft サインインを結び付けることはありません。",
"A password reset goes to this address, so changing it needs your password.": "パスワードの再設定はこのアドレスに届くため、変更にはパスワードが必要です。",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "メールアドレスはサインインに使用しているディレクトリまたは ID プロバイダーのもので、ここでは変更できません。",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "再設定リンクの有効期間は 1 時間です。新しいリンクを申し込めば、すぐに届きます。",
"Send me a new link": "新しいリンクを送る",
"This link has expired": "このリンクは有効期限が切れています",
"This password reset link is no longer valid. Ask for a new one and try again.": "この再設定リンクは無効になりました。新しいリンクを申し込んでもう一度お試しください。",
"Authenticate as this server's IAM role": "このサーバーの IAM ロールとして認証する",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "すでにロールが割り当てられている AWS 上のインストール向けです(ECS タスクロール、EC2 インスタンスプロファイル、EKS/IRSA)。ProjectSend はアクセスキーを保存する代わりに、AWS SDK から一時的な認証情報を取得します。MinIO、Backblaze、Wasabi など、アクセスキーとシークレットキーが必要なサービスではオフのままにしてください。",
"Saving will delete the access key and secret currently stored here.": "保存すると、ここに保存されているアクセスキーとシークレットキーは削除されます。",
"Too many uploads are already in progress. Finish or cancel one and try again.": "すでに進行中のアップロードが多すぎます。どれか一つを完了するか取り消してから、もう一度お試しください。"
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Meer",
"Copy the public link to this file": "Kopieer de openbare link naar dit bestand",
"Downloaded :count times, last on :date": ":count keer gedownload, laatst op :date",
"Downloaded once, on :date": "Eén keer gedownload, op :date",
"Not downloaded yet": "Nog niet gedownload",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Voeg ook de optionele claim \"xms_edov\" toe aan je app-registratie, onder Tokenconfiguratie. De tenant noemen zegt welke directory voor de aanmelding instaat; die claim zegt dat de directory heeft gecontroleerd dat de persoon het adres echt bezit. Zonder die claim kan iemand anders binnen je tenant zich aanmelden met het adres van een collega, dus ProjectSend maakt wel nieuwe accounts aan maar koppelt een Microsoft-aanmelding nooit aan een account dat al bestaat.",
"A password reset goes to this address, so changing it needs your password.": "Een wachtwoordherstel gaat naar dit adres, dus het wijzigen ervan vraagt om je wachtwoord.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Je e-mailadres komt van de directory of identiteitsprovider waarmee je je aanmeldt en kan hier niet worden gewijzigd.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Herstellinks zijn een uur geldig. Vraag een nieuwe aan, die komt zo binnen.",
"Send me a new link": "Stuur me een nieuwe link",
"This link has expired": "Deze link is verlopen",
"This password reset link is no longer valid. Ask for a new one and try again.": "Deze herstellink is niet meer geldig. Vraag een nieuwe aan en probeer het opnieuw.",
"Authenticate as this server's IAM role": "Verifiëren met de IAM-rol van deze server",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Voor installaties op AWS waaraan al een rol is gekoppeld — een ECS-taakrol, een EC2-instantieprofiel, EKS/IRSA. ProjectSend vraagt tijdelijke inloggegevens op bij de AWS SDK in plaats van een toegangssleutel op te slaan. Laat dit uit staan voor MinIO, Backblaze, Wasabi en al het andere dat een toegangssleutel en geheime sleutel nodig heeft.",
"Saving will delete the access key and secret currently stored here.": "Bij het opslaan worden de hier opgeslagen toegangssleutel en geheime sleutel verwijderd.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Er lopen al te veel uploads. Rond er een af of annuleer er een en probeer het opnieuw."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Więcej",
"Copy the public link to this file": "Skopiuj publiczny link do tego pliku",
"Downloaded :count times, last on :date": "Pobrania: :count — ostatnie :date",
"Downloaded once, on :date": "Pobrano raz, :date",
"Not downloaded yet": "Jeszcze nie pobrano",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Dodaj też opcjonalne oświadczenie \"xms_edov\" do rejestracji aplikacji, w sekcji Konfiguracja tokenu. Wskazanie dzierżawy mówi, który katalog poręczył za logowanie; to oświadczenie mówi, że katalog sprawdził, iż dana osoba naprawdę jest właścicielem adresu. Bez niego ktoś inny w Twojej dzierżawie mógłby zalogować się adresem współpracownika, więc ProjectSend będzie tworzyć nowe konta, ale nigdy nie powiąże logowania Microsoft z kontem, które już istnieje.",
"A password reset goes to this address, so changing it needs your password.": "Resetowanie hasła trafia na ten adres, więc jego zmiana wymaga Twojego hasła.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Twój adres e-mail pochodzi z katalogu lub dostawcy tożsamości, przez którego się logujesz, i nie można go tu zmienić.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Linki do resetowania są ważne godzinę. Poproś o nowy, dotrze za chwilę.",
"Send me a new link": "Wyślij mi nowy link",
"This link has expired": "Ten link wygasł",
"This password reset link is no longer valid. Ask for a new one and try again.": "Ten link do resetowania nie jest już ważny. Poproś o nowy i spróbuj ponownie.",
"Authenticate as this server's IAM role": "Uwierzytelniaj się rolą IAM tego serwera",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Dla instalacji działających na AWS, do których przypisano już rolę — rola zadania ECS, profil instancji EC2, EKS/IRSA. ProjectSend prosi AWS SDK o tymczasowe poświadczenia zamiast przechowywać klucz dostępu. Pozostaw wyłączone dla MinIO, Backblaze, Wasabi i wszystkiego innego, co wymaga klucza dostępu i klucza tajnego.",
"Saving will delete the access key and secret currently stored here.": "Zapisanie usunie klucz dostępu i klucz tajny obecnie tu przechowywane.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Trwa już zbyt wiele przesyłań. Zakończ lub anuluj jedno z nich i spróbuj ponownie."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Mais",
"Copy the public link to this file": "Copiar o link público para este arquivo",
"Downloaded :count times, last on :date": "Baixado :count vezes, a última em :date",
"Downloaded once, on :date": "Baixado uma vez, em :date",
"Not downloaded yet": "Ainda não foi baixado",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Adicione também a declaração opcional \"xms_edov\" ao registro do aplicativo, em Configuração de token. Informar o locatário diz qual diretório respondeu pelo login; essa declaração diz que o diretório verificou que a pessoa realmente é dona do endereço. Sem ela, outra pessoa dentro do seu locatário poderia entrar com o endereço de um colega, então o ProjectSend criará contas novas mas nunca vinculará um login da Microsoft a uma conta que já existe.",
"A password reset goes to this address, so changing it needs your password.": "A redefinição de senha vai para este endereço, então alterá-lo exige a sua senha.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Seu endereço de e-mail vem do diretório ou provedor de identidade com que você entra, e não pode ser alterado aqui.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Os links de redefinição duram uma hora. Peça um novo e ele chega em instantes.",
"Send me a new link": "Envie-me um novo link",
"This link has expired": "Este link expirou",
"This password reset link is no longer valid. Ask for a new one and try again.": "Este link de redefinição não é mais válido. Peça um novo e tente de novo.",
"Authenticate as this server's IAM role": "Autenticar com a função IAM deste servidor",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Para instalações na AWS que já tenham uma função associada — uma função de tarefa do ECS, um perfil de instância do EC2, EKS/IRSA. O ProjectSend pede credenciais temporárias ao SDK da AWS em vez de armazenar uma chave de acesso. Deixe desativado para MinIO, Backblaze, Wasabi e qualquer outro serviço que precise de chave de acesso e chave secreta.",
"Saving will delete the access key and secret currently stored here.": "Ao salvar, a chave de acesso e a chave secreta armazenadas aqui serão excluídas.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Já há envios demais em andamento. Conclua ou cancele um deles e tente novamente."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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": "Ещё"
"More": "Ещё",
"Copy the public link to this file": "Скопировать публичную ссылку на этот файл",
"Downloaded :count times, last on :date": "Скачиваний: :count — последнее :date",
"Downloaded once, on :date": "Скачан один раз, :date",
"Not downloaded yet": "Ещё не скачивался",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Также добавьте необязательное утверждение «xms_edov» в регистрацию приложения, в разделе «Конфигурация токена». Указание клиента говорит, какой каталог поручился за вход; это утверждение говорит, что каталог проверил, что адрес действительно принадлежит человеку. Без него кто-то другой внутри вашего клиента смог бы войти с адресом коллеги, поэтому ProjectSend будет создавать новые учётные записи, но никогда не привяжет вход через Microsoft к уже существующей.",
"A password reset goes to this address, so changing it needs your password.": "Сброс пароля отправляется на этот адрес, поэтому его изменение требует вашего пароля.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Ваш адрес электронной почты берётся из каталога или поставщика удостоверений, через который вы входите, и здесь его изменить нельзя.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Ссылки для сброса действуют один час. Запросите новую — она придёт через мгновение.",
"Send me a new link": "Прислать новую ссылку",
"This link has expired": "Срок действия ссылки истёк",
"This password reset link is no longer valid. Ask for a new one and try again.": "Эта ссылка для сброса больше не действует. Запросите новую и попробуйте снова.",
"Authenticate as this server's IAM role": "Аутентификация через роль IAM этого сервера",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Для установок на AWS, которым уже назначена роль, — роль задачи ECS, профиль экземпляра EC2, EKS/IRSA. ProjectSend запрашивает временные учётные данные у AWS SDK вместо того, чтобы хранить ключ доступа. Оставьте выключенным для MinIO, Backblaze, Wasabi и всего остального, чему нужны ключ доступа и секретный ключ.",
"Saving will delete the access key and secret currently stored here.": "При сохранении хранящиеся здесь ключ доступа и секретный ключ будут удалены.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Уже выполняется слишком много загрузок. Завершите или отмените одну из них и попробуйте снова."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Zaidi",
"Copy the public link to this file": "Nakili kiungo cha umma cha faili hili",
"Downloaded :count times, last on :date": "Imepakuliwa mara :count, mara ya mwisho :date",
"Downloaded once, on :date": "Imepakuliwa mara moja, :date",
"Not downloaded yet": "Bado haijapakuliwa",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Ongeza pia dai la hiari \"xms_edov\" kwenye usajili wa programu yako, chini ya Token configuration. Kutaja mpangaji kunaeleza ni saraka gani iliyodhamini kuingia; dai hilo linaeleza kuwa saraka imethibitisha kuwa mtu huyo ndiye mmiliki halisi wa anwani. Bila hilo, mtu mwingine ndani ya mpangaji wako anaweza kuingia kwa anwani ya mwenzake, hivyo ProjectSend itaunda akaunti mpya lakini haitaunganisha kamwe kuingia kwa Microsoft na akaunti iliyopo.",
"A password reset goes to this address, so changing it needs your password.": "Kuweka upya nenosiri hutumwa kwa anwani hii, hivyo kuibadilisha kunahitaji nenosiri lako.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Anwani yako ya barua pepe inatoka kwenye saraka au mtoa utambulisho unaotumia kuingia, na haiwezi kubadilishwa hapa.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Viungo vya kuweka upya hudumu saa moja. Omba kipya na kitafika punde.",
"Send me a new link": "Nitumie kiungo kipya",
"This link has expired": "Kiungo hiki kimeisha muda",
"This password reset link is no longer valid. Ask for a new one and try again.": "Kiungo hiki cha kuweka upya hakifanyi kazi tena. Omba kipya kisha ujaribu tena.",
"Authenticate as this server's IAM role": "Thibitisha kwa jukumu la IAM la seva hii",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Kwa usakinishaji unaoendeshwa kwenye AWS ambao tayari una jukumu lililoambatishwa — jukumu la kazi la ECS, wasifu wa mfano wa EC2, EKS/IRSA. ProjectSend huomba AWS SDK kitambulisho cha muda badala ya kuhifadhi ufunguo wa ufikiaji. Iache imezimwa kwa MinIO, Backblaze, Wasabi na kitu kingine chochote kinachohitaji ufunguo wa ufikiaji na ufunguo wa siri.",
"Saving will delete the access key and secret currently stored here.": "Kuhifadhi kutafuta ufunguo wa ufikiaji na ufunguo wa siri vilivyohifadhiwa hapa.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Tayari kuna upakiaji mwingi mno unaoendelea. Maliza au ghairi mmoja kisha ujaribu tena."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Daha fazla",
"Copy the public link to this file": "Bu dosyanın herkese açık bağlantısını kopyalayın",
"Downloaded :count times, last on :date": ":count kez indirildi, son olarak :date",
"Downloaded once, on :date": "Bir kez indirildi, :date",
"Not downloaded yet": "Henüz indirilmedi",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Uygulama kaydınıza, Belirteç yapılandırması altında \"xms_edov\" isteğe bağlı talebini de ekleyin. Kiracıyı belirtmek, oturum açma için hangi dizinin kefil olduğunu söyler; bu talep ise dizinin, kişinin adresin gerçekten sahibi olduğunu doğruladığını söyler. Bu olmadan, kiracınızdaki başka biri bir iş arkadaşının adresiyle oturum açabilir; bu nedenle ProjectSend yeni hesaplar oluşturur ancak bir Microsoft oturum açma işlemini var olan bir hesaba asla bağlamaz.",
"A password reset goes to this address, so changing it needs your password.": "Parola sıfırlama bu adrese gönderilir, bu yüzden değiştirmek parolanızı gerektirir.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "E-posta adresiniz, oturum açtığınız dizinden veya kimlik sağlayıcısından gelir ve burada değiştirilemez.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Sıfırlama bağlantıları bir saat geçerlidir. Yenisini isteyin, birazdan ulaşır.",
"Send me a new link": "Bana yeni bir bağlantı gönder",
"This link has expired": "Bu bağlantının süresi doldu",
"This password reset link is no longer valid. Ask for a new one and try again.": "Bu sıfırlama bağlantısı artık geçerli değil. Yenisini isteyip tekrar deneyin.",
"Authenticate as this server's IAM role": "Bu sunucunun IAM rolüyle kimlik doğrula",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "AWS üzerinde çalışan ve halihazırda bir rol atanmış kurulumlar için — bir ECS görev rolü, bir EC2 örnek profili, EKS/IRSA. ProjectSend, erişim anahtarı saklamak yerine AWS SDK'dan geçici kimlik bilgileri ister. MinIO, Backblaze, Wasabi ve erişim anahtarı ile gizli anahtar gerektiren diğer her şey için kapalı bırakın.",
"Saving will delete the access key and secret currently stored here.": "Kaydetmek, burada saklanan erişim anahtarını ve gizli anahtarı silecek.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Zaten çok fazla yükleme sürüyor. Birini tamamlayın veya iptal edin, sonra tekrar deneyin."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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"
"More": "Thêm",
"Copy the public link to this file": "Sao chép liên kết công khai tới tệp này",
"Downloaded :count times, last on :date": "Đã tải xuống :count lần, lần cuối :date",
"Downloaded once, on :date": "Đã tải xuống một lần, :date",
"Not downloaded yet": "Chưa được tải xuống",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Đồng thời hãy thêm xác nhận quyền tùy chọn \"xms_edov\" vào đăng ký ứng dụng của bạn, trong phần Cấu hình mã thông báo. Việc nêu tên tenant cho biết thư mục nào đã bảo đảm cho lần đăng nhập; xác nhận quyền đó cho biết thư mục đã kiểm tra rằng người này thực sự sở hữu địa chỉ. Không có nó, một người khác trong tenant của bạn có thể đăng nhập bằng địa chỉ của đồng nghiệp, nên ProjectSend sẽ tạo tài khoản mới nhưng không bao giờ gắn một lần đăng nhập Microsoft vào tài khoản đã tồn tại.",
"A password reset goes to this address, so changing it needs your password.": "Việc đặt lại mật khẩu sẽ gửi tới địa chỉ này, nên thay đổi nó cần mật khẩu của bạn.",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Địa chỉ email của bạn đến từ thư mục hoặc nhà cung cấp danh tính mà bạn dùng để đăng nhập, và không thể thay đổi tại đây.",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Liên kết đặt lại có hiệu lực một giờ. Hãy yêu cầu liên kết mới, nó sẽ đến ngay.",
"Send me a new link": "Gửi cho tôi liên kết mới",
"This link has expired": "Liên kết này đã hết hạn",
"This password reset link is no longer valid. Ask for a new one and try again.": "Liên kết đặt lại này không còn hiệu lực. Hãy yêu cầu liên kết mới và thử lại.",
"Authenticate as this server's IAM role": "Xác thực bằng vai trò IAM của máy chủ này",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Dành cho các bản cài đặt chạy trên AWS đã được gắn sẵn một vai trò — vai trò tác vụ ECS, hồ sơ phiên bản EC2, EKS/IRSA. ProjectSend yêu cầu AWS SDK cấp thông tin đăng nhập tạm thời thay vì lưu khóa truy cập. Hãy để tắt với MinIO, Backblaze, Wasabi và bất kỳ dịch vụ nào khác cần khóa truy cập và khóa bí mật.",
"Saving will delete the access key and secret currently stored here.": "Việc lưu sẽ xóa khóa truy cập và khóa bí mật đang được lưu ở đây.",
"Too many uploads are already in progress. Finish or cancel one and try again.": "Đã có quá nhiều lần tải lên đang diễn ra. Hãy hoàn tất hoặc hủy một lần rồi thử lại."
}
+16 -1
View File
@@ -2017,5 +2017,20 @@
"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": "更多"
"More": "更多",
"Copy the public link to this file": "复制此文件的公开链接",
"Downloaded :count times, last on :date": "已下载 :count 次,最近一次 :date",
"Downloaded once, on :date": "已下载 1 次,:date",
"Not downloaded yet": "尚未下载",
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "还请在应用注册的“令牌配置”中添加可选声明“xms_edov”。指定租户说明是哪个目录为此次登录背书;该声明则说明目录已核实此人确实拥有该地址。没有它,你租户内的其他人就能用同事的地址登录,因此 ProjectSend 会创建新账户,但绝不会把 Microsoft 登录关联到已存在的账户。",
"A password reset goes to this address, so changing it needs your password.": "密码重置会发送到这个地址,所以修改它需要你的密码。",
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "你的邮箱地址来自你用于登录的目录或身份提供方,无法在此处修改。",
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "重置链接有效期为一小时。申请一个新的,稍后即可收到。",
"Send me a new link": "给我发送新链接",
"This link has expired": "此链接已过期",
"This password reset link is no longer valid. Ask for a new one and try again.": "此重置链接已失效。请申请一个新的链接后重试。",
"Authenticate as this server's IAM role": "使用此服务器的 IAM 角色进行认证",
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "适用于已附加角色的 AWS 上的安装 —— ECS 任务角色、EC2 实例配置文件、EKS/IRSA。ProjectSend 会向 AWS SDK 申请临时凭证,而不是保存访问密钥。对于 MinIO、Backblaze、Wasabi 以及其他需要访问密钥和私有密钥的服务,请保持关闭。",
"Saving will delete the access key and secret currently stored here.": "保存后会删除此处当前保存的访问密钥和私有密钥。",
"Too many uploads are already in progress. Finish or cancel one and try again.": "正在进行的上传太多了。请先完成或取消一个,然后重试。"
}
+9
View File
@@ -14,6 +14,15 @@
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# If every page returns a 500 and storage/logs/ is empty, Apache could
# not work out which directory this file is serving, and the rule below
# rewrites to a path that does not exist — over and over, until Apache
# gives up. Some shared hosts need to be told. Uncomment the line and
# set it to the path ProjectSend is served from: "/" at the domain root,
# "/projectsend" in a subdirectory of it.
#
# RewriteBase /
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
+19
View File
@@ -80,6 +80,25 @@ export function AnnouncementBand({ announcement }: { announcement: Announcement
);
}
/**
* The band, reading the shared prop itself.
*
* Self-reading so a theme adds it in one line without threading a prop
* through a page that has no other reason to know about it — the same
* shape as AnnouncementIcon below. Every portal theme renders this, so a
* message reaches a client wherever they are looking rather than only in
* the theme somebody remembered to wire.
*/
export function ViewerAnnouncement() {
const { announcement } = usePage<SharedData>().props;
if (!announcement) {
return null;
}
return <AnnouncementBand announcement={announcement} />;
}
/**
* The header icon, beside the notification bell.
*
@@ -7,6 +7,13 @@ export interface FileDelivery {
method: DeliveryMethod;
/** True when nobody set PROJECTSEND_FILE_DELIVERY and the server was detected. */
detected: boolean;
/**
* Whether the detection had anything to work with. Always true in a
* request; false only when something asks from a console, where
* SERVER_SOFTWARE does not exist and `method` is a default rather
* than a finding.
*/
observed: boolean;
}
/**
@@ -0,0 +1,50 @@
import { useTranslation } from '@/hooks/use-translation';
import { useFormatDate } from '@/hooks/use-format-date';
import { type FileRow } from '@/types/portal';
interface FileDownloadStatsProps {
file: FileRow;
}
/**
* "Did it arrive?" — how often a client's own file has gone out, and when
* it last did.
*
* Renders nothing at all when `downloads` is null, which is every file
* somebody shared *with* this client. That is a privacy rule, not a
* tidiness one: a count on a file shared with several people tells each
* of them about the others' activity. The server sends null rather than a
* zero precisely so this cannot be shown by mistake — so gate on the
* field being present and never on `is_mine`.
*
* Zero *is* rendered, as words. On a link-only account this is the only
* evidence a customer has either way, and "nobody has taken it yet" is an
* answer they came looking for.
*/
export function FileDownloadStats({ file }: FileDownloadStatsProps) {
const { t } = useTranslation();
const { date } = useFormatDate();
if (file.downloads === null) {
return null;
}
// Whole sentences rather than assembled fragments, and a singular
// spelled out rather than a pipe: `t()` does no plural selection —
// the catalogues are flat key/value and a "one|many" string would
// reach the screen with its pipe intact. A count above zero always
// has a date, since the date is the newest of the rows counted.
if (file.downloads.count === 0) {
return <span>{t('Not downloaded yet')}</span>;
}
const when = date(file.downloads.last_at);
return (
<span>
{file.downloads.count === 1
? t('Downloaded once, on :date', { date: when })
: t('Downloaded :count times, last on :date', { count: file.downloads.count, date: when })}
</span>
);
}
@@ -1,5 +1,6 @@
import { Link, router } from '@inertiajs/react';
import { Pencil, X } from 'lucide-react';
import { Check, Link2, Pencil, X } from 'lucide-react';
import { useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Button } from '@/components/ui/button';
@@ -24,12 +25,36 @@ interface FileRowActionsProps {
* 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.
*
* The copy-link control follows the same rule as the other two: it appears
* when the server put a `share_url` on the row and never otherwise. That is
* not the same question as `is_mine` — a file shared *with* this client can
* carry a link that is the sharer's to hand out, not the recipient's — so
* this reads the field and derives nothing.
*/
export function FileRowActions({ file, size = 'sm' }: FileRowActionsProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyLink = (url: string) => {
void navigator.clipboard?.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
return (
<>
{file.share_url !== null && (
<Button
variant="ghost"
size={size}
onClick={() => copyLink(file.share_url as string)}
title={t('Copy the public link to this file')}
>
{copied ? <Check className="size-4" /> : <Link2 className="size-4" />}
<span className="sr-only">{copied ? t('Copied') : t('Copy link')}</span>
</Button>
)}
{file.can_update && (
<Button variant="ghost" size={size} asChild>
<Link href={route('my-files.edit', file.id)}>
@@ -2,7 +2,8 @@ import { AppearanceSwitcher } from '@/components/appearance-switcher';
import { LocaleSwitcher } from '@/components/locale-switcher';
import { PoweredBy } from '@/components/powered-by';
import ProjectSendLogo from '@/components/projectsend-logo';
import { Link } from '@inertiajs/react';
import { type SharedData } from '@/types';
import { Link, usePage } from '@inertiajs/react';
interface AuthLayoutProps {
children: React.ReactNode;
@@ -11,14 +12,34 @@ interface AuthLayoutProps {
description?: string;
}
/**
* Every screen somebody sees before they are signed in — the login form,
* registration, the password-reset pair, the two-factor challenge, first-run
* setup, and the page a share link opens.
*
* An installation that has uploaded a logo shows it here, for the same
* reason it shows on the public listing and in the client portal: these are
* the pages that belong to whoever runs the installation, seen by their
* clients, and the product's own wordmark is a stand-in for a brand rather
* than the brand. Requested in #1777. Unbranded installs are unchanged.
*
* The `branding` prop is null whenever the Branding capability is absent, so
* nothing here needs its own gate — see BrandingServiceProvider.
*/
export default function AuthSimpleLayout({ children, title, description }: AuthLayoutProps) {
const { name, branding } = usePage<SharedData>().props;
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4">
<Link href={route('home')} className="flex flex-col items-center gap-2 font-medium">
<ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
{branding?.logo_url ? (
<img src={branding.logo_url} alt={name} className="mb-1 h-12 w-auto object-contain" />
) : (
<ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
)}
<span className="sr-only">{title}</span>
</Link>
+26 -2
View File
@@ -1,4 +1,4 @@
import { Head, useForm } from '@inertiajs/react';
import { Head, Link, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
@@ -13,6 +13,12 @@ import AuthLayout from '@/layouts/auth-layout';
interface ResetPasswordProps {
token: string;
email: string;
/**
* Whether the server already knows this link will be refused. False
* for an address it cannot place, which is not the same thing — see
* NewPasswordController::linkIsSpent().
*/
expired: boolean;
}
interface ResetPasswordForm {
@@ -23,7 +29,7 @@ interface ResetPasswordForm {
password_confirmation: string;
}
export default function ResetPassword({ token, email }: ResetPasswordProps) {
export default function ResetPassword({ token, email, expired }: ResetPasswordProps) {
const { t } = useTranslation();
const { data, setData, post, processing, errors, reset } = useForm<ResetPasswordForm>({
@@ -40,6 +46,24 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
});
};
// Said before the work rather than after it. Reset links last an hour
// and people open them late; asking for a password twice and then
// refusing it is a bad minute for somebody who is already worried.
if (expired) {
return (
<AuthLayout
title={t('This link has expired')}
description={t('Reset links last one hour. Ask for a new one and it will arrive in a moment.')}
>
<Head title={t('This link has expired')} />
<Button className="w-full" asChild>
<Link href={route('password.request')}>{t('Send me a new link')}</Link>
</Button>
</AuthLayout>
);
}
return (
<AuthLayout title={t('Reset password')} description={t('Please enter your new password below')}>
<Head title={t('Reset password')} />
+8
View File
@@ -4,6 +4,7 @@ import { Bell, Download, Files, FileText, FolderKanban, HardDrive } from 'lucide
import Heading from '@/components/heading';
import { StatTile } from '@/components/stat-tile';
import { ViewerAnnouncement } from '@/components/announcement';
import { Button } from '@/components/ui/button';
import { useFormatDate } from '@/hooks/use-format-date';
import { useTranslation } from '@/hooks/use-translation';
@@ -44,6 +45,13 @@ export default function PortalDashboard({ files_count, groups_count, storage, la
<Head title={t('Dashboard')} />
<div className="space-y-6 px-4 py-6">
{/* The same band the file portal shows, on the screen that
is about the account rather than about the files. Reads
the shared prop itself, so this and /my-files cannot
disagree about what is being said or to whom — core
drops anything not aimed at this viewer. */}
<ViewerAnnouncement />
<Heading title={t('Hello, :name', { name: auth.user.name })} description={t('Here is what has been shared with you')} />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
@@ -9,9 +9,11 @@ import { CategoryBadges } from '@/components/files/category-badges';
import { VersionBadge } from '@/components/files/version-badge';
import Heading from '@/components/heading';
import { Pagination } from '@/components/pagination';
import { FileDownloadStats } from '@/components/portal/file-download-stats';
import { FileRowActions } from '@/components/portal/file-row-actions';
import { FolderRowActions } from '@/components/portal/folder-row-actions';
import { NewFolderButton } from '@/components/portal/new-folder-button';
import { ViewerAnnouncement } from '@/components/announcement';
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
import { PortalFilesToolbarCompact } from '@/components/portal/portal-files-toolbar-compact';
import { RenameFolderDialog } from '@/components/portal/rename-folder-dialog';
@@ -84,6 +86,14 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
<Head title={t('My files')} />
<div className="px-4 py-4">
{/* Above everything the client came here to do, and outside
the row below it: as a flex child it shared the line with
the heading and the buttons, so the band was never full
width and squeezed them into a column beside it. Reads the
shared prop itself; core drops anything not aimed at this
viewer, so a theme never decides who sees it. */}
<ViewerAnnouncement />
<div className="mb-3 flex items-start justify-between">
<Heading title={folder?.name ?? t('My files')} description={t('The files shared with you')} />
<div className="flex items-center gap-2">
@@ -222,6 +232,11 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
<VersionBadge version={file.version} variant="compact" />
</div>
{file.description && <p className="truncate text-[11px] text-neutral-400">{file.description}</p>}
{file.downloads !== null && (
<p className="truncate text-[11px] text-neutral-400">
<FileDownloadStats file={file} />
</p>
)}
<CategoryBadges categories={file.categories} size="xs" className="mt-0.5" />
</div>
</div>
@@ -10,9 +10,11 @@ import { CategoryBadges } from '@/components/files/category-badges';
import { VersionBadge } from '@/components/files/version-badge';
import Heading from '@/components/heading';
import { Pagination } from '@/components/pagination';
import { FileDownloadStats } from '@/components/portal/file-download-stats';
import { FileRowActions } from '@/components/portal/file-row-actions';
import { FolderRowActions } from '@/components/portal/folder-row-actions';
import { NewFolderButton } from '@/components/portal/new-folder-button';
import { ViewerAnnouncement } from '@/components/announcement';
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
import { PortalFilesToolbar } from '@/components/portal/portal-files-toolbar';
import { RenameFolderDialog } from '@/components/portal/rename-folder-dialog';
@@ -93,6 +95,14 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
<Head title={t('My files')} />
<div className="px-4 py-6">
{/* Above everything the client came here to do, and outside
the row below it: as a flex child it shared the line with
the heading and the buttons, so the band was never full
width and squeezed them into a column beside it. Reads the
shared prop itself; core drops anything not aimed at this
viewer, so a theme never decides who sees it. */}
<ViewerAnnouncement />
<div className="flex items-start justify-between">
<Heading title={folder?.name ?? t('My files')} description={t('The files shared with you')} />
<div className="flex items-center gap-2">
@@ -206,6 +216,12 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
</div>
<p className="text-muted-foreground truncate text-xs">
{file.description ?? file.original_name} · {formatBytes(file.size)} · {date(file.created_at)}
{file.downloads !== null && (
<>
{' · '}
<FileDownloadStats file={file} />
</>
)}
</p>
<CategoryBadges categories={file.categories} className="mt-1" />
</div>
@@ -331,6 +347,16 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
<p className="text-muted-foreground truncate text-xs">
{formatBytes(file.size)} · {date(file.created_at)}
</p>
{/* Its own line in the grid, for the
reason gallery gives: a card's
metadata line truncates, and a
sentence appended to it takes the
size and date with it. */}
{file.downloads !== null && (
<p className="text-muted-foreground truncate text-xs">
<FileDownloadStats file={file} />
</p>
)}
<CategoryBadges categories={file.categories} className="mt-1" />
</div>
<div className="flex shrink-0 items-center">
@@ -9,9 +9,11 @@ import { CategoryBadges } from '@/components/files/category-badges';
import { VersionBadge } from '@/components/files/version-badge';
import Heading from '@/components/heading';
import { Pagination } from '@/components/pagination';
import { FileDownloadStats } from '@/components/portal/file-download-stats';
import { FileRowActions } from '@/components/portal/file-row-actions';
import { FolderRowActions } from '@/components/portal/folder-row-actions';
import { NewFolderButton } from '@/components/portal/new-folder-button';
import { ViewerAnnouncement } from '@/components/announcement';
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
import { PortalFilesToolbarDrive } from '@/components/portal/portal-files-toolbar-drive';
import { RenameFolderDialog } from '@/components/portal/rename-folder-dialog';
@@ -85,6 +87,14 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
<Head title={t('My files')} />
<div className="px-4 py-6">
{/* Above everything the client came here to do, and outside
the row below it: as a flex child it shared the line with
the heading and the buttons, so the band was never full
width and squeezed them into a column beside it. Reads the
shared prop itself; core drops anything not aimed at this
viewer, so a theme never decides who sees it. */}
<ViewerAnnouncement />
<div className="flex items-start justify-between">
<Heading title={folder?.name ?? t('My files')} description={t('The files shared with you')} />
<div className="flex items-center gap-2">
@@ -225,6 +235,12 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
</div>
<p className="truncate text-xs text-neutral-500">
{file.description ?? file.original_name} · {date(file.created_at)}
{file.downloads !== null && (
<>
{' · '}
<FileDownloadStats file={file} />
</>
)}
</p>
<CategoryBadges categories={file.categories} className="mt-1" />
</div>
@@ -9,9 +9,11 @@ import { CategoryBadges } from '@/components/files/category-badges';
import { VersionBadge } from '@/components/files/version-badge';
import Heading from '@/components/heading';
import { Pagination } from '@/components/pagination';
import { FileDownloadStats } from '@/components/portal/file-download-stats';
import { FileRowActions } from '@/components/portal/file-row-actions';
import { FolderRowActions } from '@/components/portal/folder-row-actions';
import { NewFolderButton } from '@/components/portal/new-folder-button';
import { ViewerAnnouncement } from '@/components/announcement';
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
import { PortalFilesToolbarGallery } from '@/components/portal/portal-files-toolbar-gallery';
import { RenameFolderDialog } from '@/components/portal/rename-folder-dialog';
@@ -86,6 +88,14 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
<Head title={t('My files')} />
<div>
{/* Above everything the client came here to do, and outside
the row below it: as a flex child it shared the line with
the heading and the buttons, so the band was never full
width and squeezed them into a column beside it. Reads the
shared prop itself; core drops anything not aimed at this
viewer, so a theme never decides who sees it. */}
<ViewerAnnouncement />
<div className="flex items-start justify-between">
<Heading title={folder?.name ?? t('My files')} description={t('The files shared with you')} />
<div className="flex items-center gap-2">
@@ -217,8 +227,17 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
</div>
)}
<div className="flex items-center gap-2 p-3">
<div className="min-w-0 flex-1">
{/* Stacked, not side by side. The actions used
to sit in a flex row beside the text, and a
card in this grid is around 200px wide — so
the icons took what they needed and every line
of text truncated to two characters ("Q…",
"75 …"), with the badges overlapping them.
Giving the text the full width and putting the
actions on their own row below fixes all of
it. */}
<div className="p-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.public && (
@@ -231,9 +250,19 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
<p className="text-muted-foreground truncate text-xs">
{formatBytes(file.size)} · {date(file.created_at)}
</p>
{/* Its own line, not appended to the one
above: a card is narrow and that line
truncates, so a sentence on the end of
it pushes the size and date out of
sight. */}
{file.downloads !== null && (
<p className="text-muted-foreground truncate text-xs">
<FileDownloadStats file={file} />
</p>
)}
<CategoryBadges categories={file.categories} className="mt-1" />
</div>
<div className="flex shrink-0 items-center">
<div className="mt-2 flex flex-wrap items-center">
{comments_enabled && (
<CommentsShellGallery
fileId={file.id}
+29
View File
@@ -43,8 +43,15 @@ export default function Profile({
email: auth.user.email,
custom_field_values,
timezone,
current_password: '',
});
// Changing this address is a credential change: it is where a password
// reset is sent. So the field appears only when the address actually
// differs from the stored one — the rest of the screen keeps saving
// with nothing extra, which is what the server asks for too.
const emailChanged = data.email.trim().toLowerCase() !== auth.user.email.trim().toLowerCase();
const submit: FormEventHandler = (e) => {
e.preventDefault();
@@ -93,6 +100,28 @@ export default function Profile({
<InputError className="mt-2" message={errors.email} />
</div>
{emailChanged && (
<div className="grid gap-2">
<Label htmlFor="current_password">{t('Current password')}</Label>
<Input
id="current_password"
type="password"
className="mt-1 block w-full"
value={data.current_password}
onChange={(e) => setData('current_password', e.target.value)}
required
autoComplete="current-password"
/>
<p className="text-muted-foreground text-sm">
{t('A password reset goes to this address, so changing it needs your password.')}
</p>
<InputError className="mt-2" message={errors.current_password} />
</div>
)}
<div className="grid gap-2">
<Label htmlFor="timezone">{t('Timezone')}</Label>
@@ -197,6 +197,11 @@ function ProviderCard({ provider, open, onToggle }: { provider: ProviderSettings
'Your own tenant, not "common". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.',
)}
</p>
<p className="text-muted-foreground text-sm">
{t(
'Also add the "xms_edov" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague\'s address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.',
)}
</p>
<InputError message={form.errors.tenant_id} />
</div>
)}
+53 -16
View File
@@ -17,6 +17,7 @@ import AppLayout from '@/layouts/app-layout';
interface StorageSettingsProps {
active: boolean;
provider: string;
use_instance_role: boolean;
access_key: string;
has_secret: boolean;
has_key_file: boolean;
@@ -33,6 +34,7 @@ const FORM_ID = 'storage-settings-form';
export default function StorageSettings({
active,
provider,
use_instance_role,
access_key,
has_secret,
has_key_file,
@@ -54,6 +56,7 @@ export default function StorageSettings({
const { data, setData, patch, processing, recentlySuccessful, errors } = useForm({
active: active,
provider: provider,
use_instance_role: use_instance_role,
access_key: access_key,
secret: '',
key_file: '',
@@ -65,6 +68,7 @@ export default function StorageSettings({
});
const isGcs = data.provider === 'gcs';
const usesInstanceRole = !isGcs && data.use_instance_role;
const submit: FormEventHandler = (e) => {
e.preventDefault();
@@ -83,6 +87,7 @@ export default function StorageSettings({
route('system-settings.storage.test'),
{
provider: data.provider,
use_instance_role: data.use_instance_role,
access_key: data.access_key,
secret: data.secret,
key_file: data.key_file,
@@ -158,24 +163,56 @@ export default function StorageSettings({
<InputError message={errors.key_file} />
</div>
) : (
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_access_key">{t('Access key')}</Label>
<Input id="storage_access_key" value={data.access_key} onChange={(e) => setData('access_key', e.target.value)} />
<InputError message={errors.access_key} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_secret">{t('Secret key')}</Label>
<Input
id="storage_secret"
type="password"
placeholder={has_secret ? t('Unchanged') : ''}
value={data.secret}
onChange={(e) => setData('secret', e.target.value)}
<>
<div className="flex items-start gap-2">
<Checkbox
id="use_instance_role"
checked={data.use_instance_role}
onCheckedChange={(checked) => setData('use_instance_role', checked === true)}
/>
<InputError message={errors.secret} />
<div className="grid gap-1">
<Label htmlFor="use_instance_role" className="font-normal">
{t("Authenticate as this server's IAM role")}
</Label>
<p className="text-muted-foreground text-sm">
{t(
'For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.',
)}
</p>
{usesInstanceRole && has_secret && (
<p className="text-muted-foreground text-sm">
{t('Saving will delete the access key and secret currently stored here.')}
</p>
)}
<InputError message={errors.use_instance_role} />
</div>
</div>
</div>
{!usesInstanceRole && (
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_access_key">{t('Access key')}</Label>
<Input
id="storage_access_key"
value={data.access_key}
onChange={(e) => setData('access_key', e.target.value)}
/>
<InputError message={errors.access_key} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_secret">{t('Secret key')}</Label>
<Input
id="storage_secret"
type="password"
placeholder={has_secret ? t('Unchanged') : ''}
value={data.secret}
onChange={(e) => setData('secret', e.target.value)}
/>
<InputError message={errors.secret} />
</div>
</div>
)}
</>
)}
<div className="flex gap-4">
+17
View File
@@ -57,6 +57,23 @@ export interface FileRow {
* renders it or not; it must never filter it.
*/
version: VersionLinks;
/**
* The public URL for a file of this client's own, where the install
* made one. Null on a file somebody shared with them — that link is
* the person who shared it's decision about who may reach the file,
* and handing the recipient the URL would turn "you may download
* this" into "you may pass this on to anyone". The server decides;
* a theme must never derive this from `is_mine`.
*/
share_url: string | null;
/**
* How often this file has been downloaded and when it last was —
* present only on files this client uploaded. Null means "not yours
* to know", never "nobody has": a count on a file shared with several
* clients would tell each of them about the others' activity, so the
* server sends nothing rather than a zero.
*/
downloads: { count: number; last_at: string | null } | null;
categories: CategoryTag[];
/**
* The download cap on this file, already decided for this client by
+5 -1
View File
@@ -9,7 +9,11 @@ Artisan::command('inspire', function () {
})->purpose('Display an inspiring quote');
Schedule::command('projectsend:purge-erasures')->daily();
Schedule::command('projectsend:purge-stale-uploads')->daily();
// Hourly, not daily: this one frees disk that an account is holding
// against its own upload limits, so the gap between a session going stale
// and the sweep noticing is a gap where somebody cannot upload. Daily made
// that gap up to two days wide.
Schedule::command('projectsend:purge-stale-uploads')->hourly();
Schedule::command('projectsend:purge-zip-downloads')->daily();
Schedule::command('projectsend:check-for-updates')->daily();
Schedule::command('projectsend:fetch-news')->daily();
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Support\Facades\Password;
use Inertia\Testing\AssertableInertia;
/**
* An expired reset link should say so before asking for the work, not
* after it.
*
* The scaffolding renders the form without looking at the token, so
* somebody opening a link an hour late typed a password, typed it again to
* confirm, and was then told "this password reset token is invalid" — a
* word nobody outside the code knows, at the end rather than the start.
*
* store() still validates and is still the rule. This is only the screen
* being honest a minute earlier.
*/
beforeEach(function () {
$this->user = User::factory()->create(['email' => 'owner@example.com']);
});
test('a live link still shows the form', function () {
$token = Password::broker()->createToken($this->user);
$this->get("/reset-password/{$token}?email=owner@example.com")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('auth/reset-password')
->where('expired', false));
});
test('a spent link says so instead of asking for a password', function () {
$this->get('/reset-password/not-a-real-token?email=owner@example.com')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
});
test('a link whose token has been used is spent', function () {
$token = Password::broker()->createToken($this->user);
Password::broker()->deleteToken($this->user);
$this->get("/reset-password/{$token}?email=owner@example.com")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
});
/*
|--------------------------------------------------------------------------
| It must not answer whether an account exists
|--------------------------------------------------------------------------
|
| /forgot-password deliberately says "a link will be sent if the account
| exists". This screen must not undo that, and the first version of it did:
| a real address answered "expired" and an unknown one drew the form, so
| the difference between the two answers was the account.
*/
test('an address nobody has gets the same answer as one that exists', function () {
// The oracle, pinned. Not "both are false" or "both are true" — both
// are *the same*, which is the property, and it survives somebody
// later changing which answer that is.
User::factory()->create(['email' => 'known@example.com']);
$expiredFor = function (string $email): bool {
$seen = null;
test()->get("/reset-password/not-a-real-token?email={$email}")->assertOk()->assertInertia(
function (AssertableInertia $page) use (&$seen) {
$seen = $page->toArray()['props']['expired'];
},
);
return (bool) $seen;
};
expect($expiredFor('known@example.com'))->toBe($expiredFor('nobody@example.com'));
});
test('the write refuses both the same way', function () {
// The GET is not the only way to ask. Laravel answers a failed reset
// with passwords.user for an address it cannot find and passwords.token
// for a real one whose token is dead — two different sentences, which
// is the same oracle through the POST. This one predates the screen
// above; it is the scaffolding, shipped in every release.
User::factory()->create(['email' => 'known@example.com']);
$refusalFor = function (string $email): string {
return test()->from('/reset-password/not-a-real-token')
->post('/reset-password', [
'token' => 'not-a-real-token',
'email' => $email,
'password' => 'a-brand-new-password',
'password_confirmation' => 'a-brand-new-password',
])
->assertSessionHasErrors('email')
->getSession()->get('errors')->first('email');
};
expect($refusalFor('known@example.com'))->toBe($refusalFor('nobody@example.com'));
});
test('a missing address reads as expired rather than as a form', function () {
// Same rule seen from the other side. The form needs an address to
// post, so drawing it here would ask for a password it cannot use.
$this->get('/reset-password/not-a-real-token')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
});
test('the real check still happens on the write', function () {
// The screen is a courtesy; store() is the rule. A spent token is
// refused there whatever the page decided to draw.
$this->post('/reset-password', [
'token' => 'not-a-real-token',
'email' => 'owner@example.com',
'password' => 'a-brand-new-password',
'password_confirmation' => 'a-brand-new-password',
])->assertSessionHasErrors('email');
expect(Hash::check('a-brand-new-password', $this->user->fresh()->password))->toBeFalse();
});
+39
View File
@@ -178,3 +178,42 @@ test('core has no route that can change it', function () {
->contains(fn (RouteInstance $route): bool => str_contains($route->uri(), 'branding/attribution')))
->toBeFalse();
});
// The sign-in screens, requested in #1777. One layout serves all of them —
// login, registration, the reset pair, the two-factor challenge, first-run
// setup and the page a share link opens — so what is asserted here is that
// the prop reaches a page nobody has signed in to see, which is the part
// the layout could not do for itself.
test('a guest at the sign-in screen is given the branding logo', function () {
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
$logoUrl = BrandingSetting::query()->sole()->logoUrl();
auth()->logout();
$this->get(route('login'))->assertInertia(
fn (AssertableInertia $page) => $page->component('auth/login')->where('branding.logo_url', $logoUrl),
);
});
test('the sign-in screen falls back to the product logo when nothing was uploaded', function () {
auth()->logout();
$this->get(route('login'))->assertInertia(
fn (AssertableInertia $page) => $page->component('auth/login')->where('branding.logo_url', null),
);
});
test('a withheld capability takes the logo off the sign-in screen too', function () {
// The screen that would remove it 404s without the capability, so a
// login page still wearing somebody's logo would have no way back.
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
config(['projectsend.capabilities_disabled' => 'branding.customize']);
forgetRequestState();
auth()->logout();
$this->get(route('login'))->assertInertia(
fn (AssertableInertia $page) => $page->where('branding.logo_url', null),
);
});
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Clients\ClientAccounts;
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\ValidationException;
/*
|--------------------------------------------------------------------------
| What a client account is
|--------------------------------------------------------------------------
|
| Three surfaces make one now -- the staff screens, /api/v1/clients, and
| the platform control plane in the private package, which reaches this
| class by name because it cannot import a host class. That last one fakes
| this class entirely in its own suite, so nothing on that side can show
| that a client created through it is really a client. This file is where
| that is shown.
*/
function makeAccount(array $arguments = [])
{
return app(ClientAccounts::class)->create(...array_merge([
'name' => 'Ada Lovelace',
'email' => 'ada@example.com',
'password' => 'a-generated-passphrase',
], $arguments));
}
test('the account is a client, active, approved and verified', function () {
$client = makeAccount();
expect($client->type)->toBe(UserType::Client)
->and($client->active)->toBeTrue()
// Created by somebody who already knows who this is: there is
// nothing to approve and no address to confirm.
->and($client->account_requested)->toBeFalse()
->and($client->email_verified_at)->not->toBeNull()
->and($client->role_id)->toBe(
Role::query()->where('name', SystemRole::Client->value)->value('id')
);
});
test('the password is stored hashed, never as it arrived', function () {
$client = makeAccount(['password' => 'a-generated-passphrase']);
expect($client->password)->not->toBe('a-generated-passphrase')
->and(Hash::check('a-generated-passphrase', $client->password))->toBeTrue();
});
test('creating an account is written to the activity log', function () {
$client = makeAccount();
expect(ActivityLog::query()
->where('action', Action::UserCreated->value)
->where('subject_id', $client->id)
->exists())->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| The quota
|--------------------------------------------------------------------------
*/
test('an omitted quota is stored as zero, which means inherit', function () {
// 0 is not "no space" -- ClientStorageUsage::quotaMb() reads the site
// default for it at enforcement time, which is what makes changing a
// plan's allowance one setting rather than a sweep over every account.
expect(makeAccount()->storage_quota_mb)->toBe(0);
});
test('a quota given is the quota stored', function () {
expect(makeAccount(['storageQuotaMb' => 500])->storage_quota_mb)->toBe(500);
});
/*
|--------------------------------------------------------------------------
| The seat cap
|--------------------------------------------------------------------------
|
| Enforced here rather than left to each caller: the platform sets this cap
| and the platform is also what calls the control plane, so this is what
| stops a leaked control token minting accounts without limit.
*/
test('a full installation refuses to create another client', function () {
config()->set('projectsend.platform.max_clients', 1);
makeAccount();
expect(fn () => makeAccount(['email' => 'grace@example.com']))
->toThrow(ValidationException::class);
});
test('the refusal happens before anything is written', function () {
config()->set('projectsend.platform.max_clients', 1);
makeAccount();
try {
makeAccount(['email' => 'grace@example.com']);
} catch (ValidationException) {
// Expected.
}
expect(User::query()->where('email', 'grace@example.com')->exists())->toBeFalse();
});
test('the refusal names the field the caller asked it to name', function () {
config()->set('projectsend.platform.max_clients', 1);
makeAccount();
try {
makeAccount(['email' => 'grace@example.com', 'emailField' => 'contact_email']);
$this->fail('Expected the seat guard to refuse.');
} catch (ValidationException $e) {
expect($e->errors())->toHaveKey('contact_email');
}
});
/*
|--------------------------------------------------------------------------
| The welcome
|--------------------------------------------------------------------------
*/
test('the welcome email is sent by default', function () {
Notification::fake();
app(Settings::class)->set(Setting::EmailNotificationsEnabled, true);
$client = makeAccount();
Notification::assertSentTo($client, ClientWelcomeNotification::class);
});
test('a caller that sends its own welcome can turn this one off', function () {
// Two mails about one account read as a mistake. The portal's is the
// one that can explain what the customer signed up for.
Notification::fake();
app(Settings::class)->set(Setting::EmailNotificationsEnabled, true);
$client = makeAccount(['welcome' => false]);
Notification::assertNotSentTo($client, ClientWelcomeNotification::class);
});
test('no welcome goes out when this installation sends no mail at all', function () {
Notification::fake();
app(Settings::class)->set(Setting::EmailNotificationsEnabled, false);
$client = makeAccount();
Notification::assertNotSentTo($client, ClientWelcomeNotification::class);
});
+90 -8
View File
@@ -84,6 +84,42 @@ test('the default storage quota setting prefills a new client\'s quota field', f
);
});
test('both client screens show the quota that will actually be enforced, floor included', function () {
// The screens present this as what happens, not as a value being
// edited, so they have to show the effective number. The edit screen
// in particular mirrors quotaMb()'s resolution client-side to draw the
// usage bar: handed the raw setting on a floored installation, it
// computes an effective quota of 0, prints "unlimited", and hides the
// bar entirely — for a client whose next upload is about to be
// rejected for exceeding a limit the screen said did not exist.
config()->set('projectsend.platform.default_client_quota_mb', 2048);
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 0);
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
$this->actingAs($this->admin)->get('/clients/create')->assertInertia(
fn (AssertableInertia $page) => $page->where('default_storage_quota_mb', 2048),
);
$this->actingAs($this->admin)->get("/clients/{$client->id}")->assertInertia(
fn (AssertableInertia $page) => $page->where('default_storage_quota_mb', 2048),
);
});
test('the settings form still edits the stored setting, never the floor', function () {
// The other half, and the reason this is not one change applied
// everywhere. That field is read, then written back on save. Prefilled
// with the floor, the next save of that page would write the
// platform's number into the setting as the administrator's own
// choice — where it would outlive the floor being removed.
config()->set('projectsend.platform.default_client_quota_mb', 2048);
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 0);
$this->actingAs($this->admin)->get('/system/settings/clients')->assertInertia(
fn (AssertableInertia $page) => $page->where('default_client_storage_quota_mb', 0),
);
});
test('clearing the storage quota field to blank on the edit form resets it to inherit the site default', function () {
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 150);
$client = User::factory()->client()->create(['storage_quota_mb' => 100]);
@@ -129,20 +165,25 @@ test('a chunked session whose declared size already exceeds quota is rejected at
expect(UploadSession::query()->count())->toBe(0);
});
test('a client who under-declares size then exceeds quota once assembled is rejected at completion', function () {
test('a client whose quota fills while the transfer is running is rejected at completion', function () {
$client = User::factory()->client()->create(['storage_quota_mb' => 1]);
grantUploadPermission($client);
makeClientFile($client, 1000 * 1024); // ~1000 KB already used, out of a 1 MB quota
$this->actingAs($client);
// Declared size (11 bytes) passes the session-creation check, but the
// real assembled bytes (~50 KB) push the client over quota.
$sessionId = createChunkedSession(11, 'lied-about-size.txt');
// Declared honestly, and there is room for it when the session opens.
$sessionId = createChunkedSession(50 * 1024, 'honest.txt');
putChunkedPart($sessionId, 1, str_repeat('a', 50 * 1024));
// The rest of the quota goes while the bytes are in flight — another
// device, a staff member uploading on their behalf, a second transfer
// finishing first. A big file takes a long time and the check at
// session creation is only true of the moment it was made, which is
// why completion asks again rather than trusting it.
makeClientFile($client, 1000 * 1024);
$this->postJson("/uploads/{$sessionId}/complete")->assertJsonValidationErrors('size');
expect(File::query()->where('uploaded_by', $client->id)->count())->toBe(1) // only the pre-existing fixture file
expect(File::query()->where('uploaded_by', $client->id)->count())->toBe(1) // only the file that filled the quota
->and(UploadSession::query()->find($sessionId))->toBeNull();
$paths = Storage::disk('files')->allFiles();
@@ -170,6 +211,46 @@ test('ClientStorageUsage::quotaMb() resolves a client with no custom quota to th
expect(app(ClientStorageUsage::class)->quotaMb($client))->toBe(250);
});
test('a platform floor holds a client the installation never gave a quota to', function () {
// The hole this closes: the setting's own default is 0, 0 means
// unlimited, and an account that arrived without an explicit quota --
// a self-registered one, say -- inherits it. On an installation a
// platform runs for other people that is unmetered hosting one account
// away, so a platform may put a floor under it from the environment,
// exactly as it sets the seat caps.
config()->set('projectsend.platform.default_client_quota_mb', 1);
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 0);
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
grantUploadPermission($client);
makeClientFile($client, 1000 * 1024);
$this->actingAs($client);
$this->postJson('/uploads', [
'filename' => 'over-the-floor.pdf',
'size' => 200 * 1024,
'type' => 'application/pdf',
])->assertJsonValidationErrors('size');
});
test('a floor is under the setting, never over it', function () {
// An administrator who has chosen a number keeps it, including a
// larger one. The floor is for the installation that chose nothing.
config()->set('projectsend.platform.default_client_quota_mb', 100);
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 250);
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
expect(app(ClientStorageUsage::class)->quotaMb($client))->toBe(250);
});
test('an install with no platform behind it is unaffected', function () {
// Nothing set anywhere is still unlimited. This must not become a
// ceiling that appears on self-hosted installs by default.
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 0);
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
expect(app(ClientStorageUsage::class)->quotaMb($client))->toBe(0);
});
test('ClientStorageUsage::quotaMb() lets a client\'s own custom quota override the site default', function () {
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 250);
$client = User::factory()->client()->create(['storage_quota_mb' => 500]);
@@ -216,12 +297,13 @@ test('the same is true when the real byte count is what pushes them over', funct
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 1);
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
grantUploadPermission($client);
makeClientFile($client, 1000 * 1024);
$this->actingAs($client);
$sessionId = createChunkedSession(11, 'lied-about-size.txt');
$sessionId = createChunkedSession(50 * 1024, 'honest.txt');
putChunkedPart($sessionId, 1, str_repeat('a', 50 * 1024));
makeClientFile($client, 1000 * 1024);
$response = $this->postJson("/uploads/{$sessionId}/complete")->assertJsonValidationErrors('size');
expect($response->json('errors.size.0'))->toBe('This upload would exceed your storage quota of 1 MB.');
+117 -7
View File
@@ -164,19 +164,23 @@ test('a staff account without the upload permission cannot create sessions', fun
$this->postJson('/uploads', ['filename' => 'x.zip', 'size' => 10])->assertForbidden();
});
test('complete refuses a file whose real assembled size exceeds the limit, even when a tiny size was declared', function () {
test('complete refuses a file whose real assembled size exceeds the limit', function () {
$this->actingAs($this->admin);
// A 1 MB cap. The session is declared as a single byte, so it sails
// through store()'s check against the client-supplied size.
app(Settings::class)->set(Setting::MaxFileSizeMb, 1);
$sessionId = createSession(1, 'sneaky.zip');
// Declared honestly and staged honestly, under a 2 MB cap.
app(Settings::class)->set(Setting::MaxFileSizeMb, 2);
$sessionId = createSession(1600 * 1024, 'sneaky.zip');
// But the real parts stream ~1.5 MB.
$chunk = str_repeat('a', 800 * 1024);
putPart($sessionId, 1, $chunk)->assertOk();
putPart($sessionId, 2, $chunk)->assertOk();
// The cap moves while the transfer is running. Declaring a size is not
// the same as being allowed to store it, which is why complete()
// re-asks rather than trusting what store() decided — the parts have
// been on disk for as long as the upload took.
app(Settings::class)->set(Setting::MaxFileSizeMb, 1);
$this->postJson("/uploads/{$sessionId}/complete")->assertStatus(422);
// Nothing is kept: no File row, the assembled bytes are removed, and the
@@ -286,7 +290,10 @@ test('a part within the size limit is still accepted', function () {
$session = $this->actingAs($user)->postJson('/uploads', [
'filename' => 'ok.pdf',
'size' => 1024,
// Declared truthfully: a session only holds what it said it would,
// so the part below has to fit inside this number as well as
// inside the per-part cap.
'size' => 512 * 1024,
'type' => 'application/pdf',
])->assertOk()->json('uploadId');
@@ -475,3 +482,106 @@ test('a second complete is refused while one is already finalising the session',
$this->postJson("/uploads/{$sessionId}/complete")->assertOk();
expect(File::query()->count())->toBe(1);
});
/**
* GHSA-6jh6-gvj5-pv8v. The per-part cap bounded one request and nothing
* else: a session could declare one byte, then stage 10,000 parts of twice
* the part size, and none of it ever became a File row, so none of it
* counted against a quota or showed up anywhere. Sessions were unlimited
* too, and the sweeper only came round daily.
*/
test('a session cannot stage more bytes than it declared', function () {
$user = User::factory()->create();
grantChunkedUploadPermission($user);
$this->actingAs($user);
$sessionId = createSession(1, 'one-byte.zip');
// The reporter's shape exactly: one byte declared, a part far under the
// per-part cap, and nothing to stop it before this fix.
putPart($sessionId, 1, str_repeat('a', 2 * 1024 * 1024))->assertStatus(413);
expect(Illuminate\Support\Facades\File::exists(partsRoot().'/'.$sessionId.'/1.part'))->toBeFalse()
->and(UploadSession::query()->findOrFail($sessionId)->staged_bytes)->toBe(0);
// The one byte it did declare is still welcome, and the session is
// still usable: a refusal must not poison the upload.
putPart($sessionId, 1, 'a')->assertOk();
expect(UploadSession::query()->findOrFail($sessionId)->staged_bytes)->toBe(1);
});
test('staged bytes are released when a part is replaced, refused or falls short', function () {
$this->actingAs($this->admin);
$sessionId = createSession(10, 'refunds.zip');
$session = fn (): UploadSession => UploadSession::query()->findOrFail($sessionId);
putPart($sessionId, 1, 'aaaa')->assertOk();
expect($session()->staged_bytes)->toBe(4);
// Re-sending a part replaces it rather than adding to it — an ordinary
// resume must not spend the room twice.
putPart($sessionId, 1, 'bb')->assertOk();
expect($session()->staged_bytes)->toBe(2);
// A part that does not fit leaves nothing behind, including in the
// running total: otherwise a client's own retries would exhaust a
// session that has plenty of room left.
putPart($sessionId, 2, str_repeat('c', 64))->assertStatus(413);
expect($session()->staged_bytes)->toBe(2);
putPart($sessionId, 2, str_repeat('c', 8))->assertOk();
expect($session()->staged_bytes)->toBe(10);
});
test('open sessions count against a client quota, so it cannot be spent twice', function () {
$client = User::factory()->client()->create(['storage_quota_mb' => 1]);
grantChunkedUploadPermission($client);
$this->actingAs($client);
// The whole megabyte, declared but not yet sent. Before this fix the
// quota only ever looked at finished files, so a second session was
// told there was a full megabyte free — and so was a third.
createSession(1024 * 1024, 'first.zip');
$this->postJson('/uploads', [
'filename' => 'second.zip',
'size' => 1024 * 1024,
'type' => 'application/octet-stream',
])->assertStatus(422)->assertJsonValidationErrors('size');
expect(UploadSession::query()->where('user_id', $client->id)->count())->toBe(1);
});
test('an abandoned session gives its room back once it is swept', function () {
$client = User::factory()->client()->create(['storage_quota_mb' => 1]);
grantChunkedUploadPermission($client);
$this->actingAs($client);
$abandoned = createSession(1024 * 1024, 'abandoned.zip');
UploadSession::query()->whereKey($abandoned)->update(['created_at' => now()->subDays(2)]);
$this->artisan('projectsend:purge-stale-uploads')->assertSuccessful();
// Held room is only held while the session is: the quota is a ceiling,
// not a debt somebody is stuck with because a transfer died.
createSession(1024 * 1024, 'second-attempt.zip');
});
test('one account cannot hold unlimited sessions open', function () {
config(['projectsend.uploads.max_open_sessions' => 3]);
$this->actingAs($this->admin);
createSession(1024, 'a.zip');
createSession(1024, 'b.zip');
createSession(1024, 'c.zip');
// Staff have no quota to spend, so the session count is the only thing
// bounding what they can hold on the temporary volume.
$this->postJson('/uploads', [
'filename' => 'd.zip',
'size' => 1024,
'type' => 'application/octet-stream',
])->assertStatus(422)->assertJsonValidationErrors('filename');
});
@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\File;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
/**
* What a client is told about how often a file has been taken.
*
* "Did it arrive?" is the question, and on a hosted free account — where
* a link is the whole of the sharing — the count is the only evidence
* either way. But a download entry says somebody fetched the file, so a
* count on a file shared with several clients tells each of them about
* the others. Only the person who put the file there is entitled to it.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
app(Settings::class)->set(Setting::Theme, 'default');
});
function downloadAt(File $file, ?User $actor, string $when, Action $action = Action::FileDownloaded): void
{
ActivityLog::query()->create([
'actor_id' => $actor?->id,
'actor_name' => $actor?->name,
'actor_type' => $actor?->type->value,
'action' => $action,
'subject_type' => $file->getMorphClass(),
'subject_id' => $file->id,
'created_at' => $when,
]);
}
function statsRow(User $client, string $name): array
{
$row = null;
test()->actingAs($client)->get(route('my-files.index'))->assertInertia(
function (AssertableInertia $page) use ($name, &$row) {
$row = collect($page->toArray()['props']['files'])->firstWhere('name', $name);
},
);
expect($row)->not->toBeNull("No row named {$name} in the portal listing.");
return $row;
}
test('a client is told how often their own file went out and when it last did', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine']);
downloadAt($file, null, '2026-09-01 10:00:00', Action::ShareLinkDownloaded);
downloadAt($file, null, '2026-09-06 18:30:00', Action::ShareLinkDownloaded);
$downloads = statsRow($client, 'Mine')['downloads'];
expect($downloads['count'])->toBe(2)
->and($downloads['last_at'])->toStartWith('2026-09-06T18:30:00');
});
test('every way a file can leave is counted, not just one of them', function () {
// The same three actions DownloadAllowance counts. A number that left
// out public-site downloads would quietly under-report exactly the
// sharing this is meant to report on.
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine']);
downloadAt($file, $this->admin, '2026-09-01 10:00:00', Action::FileDownloaded);
downloadAt($file, null, '2026-09-02 10:00:00', Action::ShareLinkDownloaded);
downloadAt($file, null, '2026-09-03 10:00:00', Action::PublicFileDownloaded);
expect(statsRow($client, 'Mine')['downloads']['count'])->toBe(3);
});
test('an untouched file of their own says so, rather than saying nothing', function () {
// Zero is an answer the customer came looking for. It has to be
// distinguishable from "not yours to know", which is why the server
// sends a zero here and a null below.
$client = User::factory()->client()->create();
File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Untouched']);
expect(statsRow($client, 'Untouched')['downloads'])->toBe(['count' => 0, 'last_at' => null]);
});
test('a file shared with them carries no numbers at all', function () {
// The disclosure this exists to prevent: a count on a file shared
// with several people tells each of them about the others' activity.
// Null, not zero — a zero would itself be a claim.
$client = User::factory()->client()->create();
$other = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Theirs']);
shareFileWith($file, $client);
shareFileWith($file, $other);
downloadAt($file, $other, '2026-09-01 10:00:00');
expect(statsRow($client, 'Theirs')['downloads'])->toBeNull();
});
test('nothing but a download is counted', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine']);
downloadAt($file, $client, '2026-09-01 10:00:00', Action::FileUpdated);
downloadAt($file, $client, '2026-09-02 10:00:00', Action::ShareLinkCreated);
expect(statsRow($client, 'Mine')['downloads']['count'])->toBe(0);
});
test('one file\'s downloads never land on another\'s', function () {
$client = User::factory()->client()->create();
$one = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'One']);
File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Two']);
downloadAt($one, null, '2026-09-01 10:00:00', Action::ShareLinkDownloaded);
expect(statsRow($client, 'One')['downloads']['count'])->toBe(1)
->and(statsRow($client, 'Two')['downloads']['count'])->toBe(0);
});
test('the listing costs one query for the counts however many rows it has', function () {
$client = User::factory()->client()->create();
foreach (range(1, 6) as $n) {
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => "File {$n}"]);
downloadAt($file, null, '2026-09-01 10:00:00', Action::ShareLinkDownloaded);
}
$queries = 0;
DB::listen(function ($query) use (&$queries) {
if (str_contains($query->sql, 'max(created_at)')) {
$queries++;
}
});
$this->actingAs($client)->get(route('my-files.index'))->assertOk();
expect($queries)->toBe(1);
});
test('a client with no files of their own asks nothing at all', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Theirs']);
shareFileWith($file, $client);
$queries = 0;
DB::listen(function ($query) use (&$queries) {
if (str_contains($query->sql, 'max(created_at)')) {
$queries++;
}
});
$this->actingAs($client)->get(route('my-files.index'))->assertOk();
expect($queries)->toBe(0);
});
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Files\Sharing\CreateShareLink;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
/**
* Which public URLs a client is shown in their own portal.
*
* The rule is narrow on purpose: a link this client created, on a file
* this client uploaded. Both halves. A link somebody else minted on a
* file shared *with* them is that person's decision about who may reach
* the file, and showing the recipient the URL would quietly turn "you may
* download this" into "you may pass this on to anyone".
*
* Asserted on the rendered props rather than on the resolver alone,
* because a theme reading `share_url` off the row is trusting that the
* narrowing already happened.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
app(Settings::class)->set(Setting::Theme, 'default');
});
function rowFor(User $client, string $name): array
{
$row = null;
test()->actingAs($client)->get(route('my-files.index'))->assertInertia(
function (AssertableInertia $page) use ($name, &$row) {
$row = collect($page->toArray()['props']['files'])->firstWhere('name', $name);
},
);
expect($row)->not->toBeNull("No row named {$name} in the portal listing.");
return $row;
}
test('a client is shown the link on a file they uploaded', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine']);
$link = app(CreateShareLink::class)->for($file, $client);
expect(rowFor($client, 'Mine')['share_url'])->toBe(route('share.show', $link->token));
});
test('a client is not shown a staff link on a file shared with them', function () {
// The disclosure this whole class exists to prevent.
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Theirs']);
shareFileWith($file, $client);
app(CreateShareLink::class)->for($file, $this->admin);
expect(rowFor($client, 'Theirs')['share_url'])->toBeNull();
});
test('a client is not shown a staff link on a file they uploaded themselves', function () {
// The case that isolates the second half of the rule: the file *is*
// theirs, so ownership alone would let this through. A link staff
// minted is staff's decision about who may reach the file — it may
// exist for a reason the customer is not part of, and on the shared
// instance it may sit beside the one link they were promised.
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine, staff link']);
app(CreateShareLink::class)->for($file, $this->admin);
expect(rowFor($client, 'Mine, staff link')['share_url'])->toBeNull();
});
test('a staff link never displaces the client\'s own', function () {
// Ordering is by id, so a staff link minted first would be the one
// an unfiltered lookup returned.
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Both links']);
app(CreateShareLink::class)->for($file, $this->admin);
$own = app(CreateShareLink::class)->for($file, $client);
expect(rowFor($client, 'Both links')['share_url'])->toBe(route('share.show', $own->token));
});
test('a client is not shown their own link on a file that is no longer theirs', function () {
// Both halves of the rule, not either: a link they minted before the
// file was reassigned is not a link to a file they still own.
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Reassigned']);
app(CreateShareLink::class)->for($file, $client);
$file->update(['uploaded_by' => $this->admin->id]);
shareFileWith($file, $client);
expect(rowFor($client, 'Reassigned')['share_url'])->toBeNull();
});
test('one client is never shown another client\'s link', function () {
$client = User::factory()->client()->create();
$other = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Shared with both']);
shareFileWith($file, $client);
shareFileWith($file, $other);
app(CreateShareLink::class)->for($file, $other);
expect(rowFor($client, 'Shared with both')['share_url'])->toBeNull();
});
test('a file with no link says so rather than inventing one', function () {
$client = User::factory()->client()->create();
File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Unlinked']);
expect(rowFor($client, 'Unlinked')['share_url'])->toBeNull();
});
/*
|--------------------------------------------------------------------------
| A link that would not work
|--------------------------------------------------------------------------
|
| The only thing a client can do with this is copy it. A URL that answers
| "this link has expired" is worse than no URL at all.
*/
test('an expired link is left out', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Expired']);
app(CreateShareLink::class)->for($file, $client, expiresAt: now()->subDay());
expect(rowFor($client, 'Expired')['share_url'])->toBeNull();
});
test('a spent link is left out', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Spent']);
$link = app(CreateShareLink::class)->for($file, $client, maxDownloads: 1);
$link->update(['downloads_count' => 1]);
expect(rowFor($client, 'Spent')['share_url'])->toBeNull();
});
test('a live link is still shown when a dead one sits beside it', function () {
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Two links']);
app(CreateShareLink::class)->for($file, $client, expiresAt: now()->subDay());
$live = app(CreateShareLink::class)->for($file, $client);
expect(rowFor($client, 'Two links')['share_url'])->toBe(route('share.show', $live->token));
});
test('the listing costs one query for the links however many rows it has', function () {
$client = User::factory()->client()->create();
foreach (range(1, 5) as $n) {
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => "File {$n}"]);
app(CreateShareLink::class)->for($file, $client);
}
$queries = 0;
DB::listen(function ($query) use (&$queries) {
if (str_contains($query->sql, 'share_links')) {
$queries++;
}
});
$this->actingAs($client)->get(route('my-files.index'))->assertOk();
expect($queries)->toBe(1);
});
test('a client with no files of their own asks nothing at all', function () {
// The empty case has no ids to look up, so it must not run a query
// with an empty IN clause on every listing.
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Theirs']);
shareFileWith($file, $client);
$queries = 0;
DB::listen(function ($query) use (&$queries) {
if (str_contains($query->sql, 'share_links')) {
$queries++;
}
});
$this->actingAs($client)->get(route('my-files.index'))->assertOk();
expect($queries)->toBe(0);
});
test('the link the client is shown really works', function () {
// The end of the chain: what is rendered is a URL a stranger can
// fetch. Everything above tests who is told; this tests that being
// told is worth something.
$client = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Fetchable']);
app(CreateShareLink::class)->for($file, $client);
$url = rowFor($client, 'Fetchable')['share_url'];
$this->post(route('logout'));
$this->get($url)->assertOk()->assertInertia(
fn (AssertableInertia $page) => $page->component('share/show')->where('status', 'active'),
);
expect(ShareLink::query()->count())->toBe(1);
});
+47
View File
@@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Storage;
@@ -203,3 +204,49 @@ test('previews, thumbnails and zips travel the same way downloads do', function
$response->assertOk()->assertHeaderMissing('X-Accel-Redirect');
expect(strlen($response->streamedContent()))->toBeGreaterThan(0);
});
/*
|--------------------------------------------------------------------------
| Asking from a console
|--------------------------------------------------------------------------
|
| detect() reads SERVER_SOFTWARE, which only exists inside a request. A
| console process therefore has nothing to look at and falls to the `php`
| default — correct for that process, and wrong as a statement about the
| installation, which is exactly how somebody running `artisan tinker` on
| a healthy nginx box will read it. It cost somebody an afternoon before
| it was recognised as an artefact of where the question was asked.
*/
test('a console reading says the method was not observed', function () {
config()->set('projectsend.file_delivery', null);
$described = app(FileDelivery::class)->describe();
expect($described['observed'])->toBeFalse()
// Still `php`, because that is what this process would actually do.
->and($described['method'])->toBe('php');
});
test('a reading taken during a request is observed', function () {
config()->set('projectsend.file_delivery', null);
request()->server->set('SERVER_SOFTWARE', 'nginx/1.27.0');
$described = app(FileDelivery::class)->describe();
expect($described['observed'])->toBeTrue()
->and($described['method'])->toBe('nginx');
});
// An explicit setting is somebody's decision and needs nothing observed to
// be true — the value stands wherever it is read from.
test('a stated method is always observed, console or not', function () {
config()->set('projectsend.file_delivery', 'xsendfile');
$described = app(FileDelivery::class)->describe();
expect($described['method'])->toBe('xsendfile')
->and($described['detected'])->toBeFalse()
->and($described['observed'])->toBeTrue();
});
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
use App\Models\User;
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 App\Modules\Identity\Permissions\Permission;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* GHSA-rxf8-wh8v-jm9j, and it is the sibling of GHSA-237r-jx85-j3hr rather
* than a new discovery: that advisory decided that putting content in a
* public folder is publication, put the rule in Folder::uploadableBy(), and
* wired it into the upload paths. Content arrives in a folder four other
* ways — moved, bulk-moved, reparented through the edit form, or carried in
* by its own folder being dragged somewhere — and none of them asked. So an
* editor deliberately denied the publication permission could publish to the
* anonymous site by choosing where things land.
*
* The fix to a report deserves the scrutiny the report got. These tests are
* one per sink for that reason, and each has a private-destination control
* beside it: the boundary is about publishing, not about moving.
*/
beforeEach(function () {
Storage::fake('files');
// A staff user must exist or every request redirects to setup.
$this->admin = User::factory()->create();
$this->publicFolder = Folder::query()->create([
'name' => 'Brochures', 'slug' => 'brochures', 'path' => '/', 'public' => true,
]);
$this->privateFolder = Folder::query()->create([
'name' => 'Internal', 'slug' => 'internal', 'path' => '/', 'public' => false,
]);
});
/**
* An editor: may change files, may not publish them. The exact role the
* permission matrix says cannot reach the public site.
*/
function editorWhoCannotPublish(): User
{
$role = Role::query()->create(['name' => 'Editor '.Str::random(6)]);
foreach ([Permission::Upload, Permission::EditFiles, Permission::EditOthersFiles, Permission::CreateOwnFolders] as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
}
return User::factory()->create(['role_id' => $role->id]);
}
function editorWhoCanPublish(): User
{
$user = editorWhoCannotPublish();
RolePermission::query()->create(['role_id' => $user->role_id, 'permission' => Permission::UploadPublic->value]);
return $user;
}
function privateFileOwnedBy(User $owner, ?Folder $folder = null): File
{
return File::factory()->create([
'name' => 'Salaries',
'slug' => 'salaries-'.Str::random(6),
'uploaded_by' => $owner->id,
'folder_id' => $folder?->id,
'public' => false,
]);
}
test('the drag-and-drop move cannot publish', function () {
$editor = editorWhoCannotPublish();
$file = privateFileOwnedBy($editor, $this->privateFolder);
$this->actingAs($editor)
->patch("/files/{$file->id}/move", ['folder_id' => $this->publicFolder->id])
->assertForbidden();
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
});
test('the same move into a private folder still works', function () {
$editor = editorWhoCannotPublish();
$file = privateFileOwnedBy($editor);
$this->actingAs($editor)
->patch("/files/{$file->id}/move", ['folder_id' => $this->privateFolder->id])
->assertRedirect();
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
});
test('an editor who may publish can still move into a public folder', function () {
$editor = editorWhoCanPublish();
$file = privateFileOwnedBy($editor, $this->privateFolder);
$this->actingAs($editor)
->patch("/files/{$file->id}/move", ['folder_id' => $this->publicFolder->id])
->assertRedirect();
expect($file->fresh()->folder_id)->toBe($this->publicFolder->id);
});
test('bulk edit cannot publish a selection', function () {
$editor = editorWhoCannotPublish();
$one = privateFileOwnedBy($editor, $this->privateFolder);
$two = privateFileOwnedBy($editor, $this->privateFolder);
$this->actingAs($editor)->patch('/files/bulk-edit', [
'file_ids' => [$one->id, $two->id],
'folder_action' => 'move',
'folder_id' => $this->publicFolder->id,
'description_action' => 'no_change',
'expiration_action' => 'no_change',
])->assertForbidden();
expect($one->fresh()->folder_id)->toBe($this->privateFolder->id)
->and($two->fresh()->folder_id)->toBe($this->privateFolder->id);
});
test('bulk edit into a private folder still works', function () {
$editor = editorWhoCannotPublish();
$file = privateFileOwnedBy($editor);
$this->actingAs($editor)->patch('/files/bulk-edit', [
'file_ids' => [$file->id],
'folder_action' => 'move',
'folder_id' => $this->privateFolder->id,
'description_action' => 'no_change',
'expiration_action' => 'no_change',
])->assertRedirect();
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
});
test('the edit form cannot publish by reparenting', function () {
$editor = editorWhoCannotPublish();
$file = privateFileOwnedBy($editor, $this->privateFolder);
$this->actingAs($editor)->patch("/files/{$file->id}", [
'name' => 'Salaries',
'folder_id' => $this->publicFolder->id,
])->assertForbidden();
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
});
test('the API twin cannot publish by reparenting either', function () {
$editor = editorWhoCannotPublish();
$file = privateFileOwnedBy($editor, $this->privateFolder);
// The abilities a token may hold are bounded by the role behind it, so
// this token is exactly as unable to publish as its owner.
$token = $editor->createToken('t', [
Permission::EditFiles->value,
Permission::EditOthersFiles->value,
])->plainTextToken;
$this->withToken($token)
->patchJson("/api/v1/files/{$file->id}", ['folder_id' => $this->publicFolder->id])
->assertForbidden();
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
});
test('dragging a whole folder into a public one cannot publish its contents', function () {
$editor = editorWhoCannotPublish();
$file = privateFileOwnedBy($editor, $this->privateFolder);
$this->actingAs($editor)
->patch("/folders/{$this->privateFolder->id}/move", ['parent_id' => $this->publicFolder->id])
->assertForbidden();
expect($this->privateFolder->fresh()->parent_id)->toBeNull()
->and($file->fresh()->isEffectivelyPublic())->toBeFalse();
});
test('moving a folder somewhere private still works', function () {
$editor = editorWhoCannotPublish();
$nest = Folder::query()->create(['name' => 'Nested', 'slug' => 'nested', 'path' => '/', 'public' => false]);
$this->actingAs($editor)
->patch("/folders/{$nest->id}/move", ['parent_id' => $this->privateFolder->id])
->assertRedirect();
expect($nest->fresh()->parent_id)->toBe($this->privateFolder->id);
});
test('a private file stays unreachable to a stranger across every reparent path', function () {
// The end of the chain the advisory follows, and the only assertion that
// is really about impact: placement makes isEffectivelyPublic() true, and
// the anonymous routes treat that as the whole of the authorization.
app(Settings::class)->set(Setting::PublicListingEnabled, true);
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
$editor = editorWhoCannotPublish();
$file = privateFileOwnedBy($editor, $this->privateFolder);
$this->get("/public/files/{$file->slug}/download")->assertNotFound();
foreach ([
fn () => $this->actingAs($editor)->patch("/files/{$file->id}/move", ['folder_id' => $this->publicFolder->id]),
fn () => $this->actingAs($editor)->patch("/files/{$file->id}", ['name' => 'Salaries', 'folder_id' => $this->publicFolder->id]),
fn () => $this->actingAs($editor)->patch("/folders/{$this->privateFolder->id}/move", ['parent_id' => $this->publicFolder->id]),
] as $attempt) {
$attempt();
// The anonymous fetch first, because that is the claim: not that a
// flag stayed off, but that a stranger with no session, no token
// and no assignment still cannot read the bytes.
$this->get("/public/files/{$file->slug}/download")->assertNotFound();
expect($file->fresh()->isEffectivelyPublic())->toBeFalse();
}
});
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Putting a file in a public folder publishes it, because
* File::isEffectivelyPublic() is "my own flag, or my folder's". So the
* destination is a way to publish without ever touching the switch that
* `upload_public` guards.
*
* The client half of this has always been gated, on
* `upload_to_public_folders` — see the picker in MyFilesController, whose
* comment calls that the established meaning of the two keys. The staff
* half never asked, so on a staff role that key did nothing at all.
*
* Reported as GHSA-237r-jx85-j3hr.
*/
beforeEach(function () {
Storage::fake('files');
$this->public = Folder::query()->create([
'name' => 'Brochures', 'slug' => 'brochures', 'path' => '/', 'public' => true,
]);
$this->private = Folder::query()->create([
'name' => 'Internal', 'slug' => 'internal', 'path' => '/', 'public' => false,
]);
});
function publicFolderStaff(array $permissions): User
{
$role = Role::query()->create(['name' => 'Role '.Str::random(6)]);
foreach ($permissions as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
}
return User::factory()->create(['role_id' => $role->id]);
}
function uploadInto(User $staff, Folder $folder)
{
return test()->actingAs($staff)->post('/files', [
'file' => UploadedFile::fake()->create('brochure.pdf', 8),
'folder_id' => $folder->id,
]);
}
test('an uploader without either public key cannot publish through a folder', function () {
$staff = publicFolderStaff([Permission::Upload]);
uploadInto($staff, $this->public)->assertForbidden();
});
test('the same uploader can still upload into a private folder', function () {
// The tightening is about publishing, not about uploading.
$staff = publicFolderStaff([Permission::Upload]);
uploadInto($staff, $this->private)->assertRedirect();
});
test('upload_to_public_folders is what opens it', function () {
// The key already exists and already means this for clients. It simply
// did nothing on a staff role.
$staff = publicFolderStaff([Permission::Upload, Permission::UploadToPublicFolders]);
uploadInto($staff, $this->public)->assertRedirect();
});
test('upload_public opens it too', function () {
// Somebody who may set a file public may certainly put one where
// everything is.
$staff = publicFolderStaff([Permission::Upload, Permission::UploadPublic]);
uploadInto($staff, $this->public)->assertRedirect();
});
test('a public ancestor counts, not just the folder itself', function () {
// isEffectivelyPublic() walks up, so a private folder inside a public
// one is still published. A check on the folder's own flag would miss
// exactly this.
$child = Folder::query()->create([
'name' => 'Drafts', 'slug' => 'drafts', 'path' => '/'.$this->public->id.'/',
'parent_id' => $this->public->id, 'public' => false,
]);
$staff = publicFolderStaff([Permission::Upload]);
uploadInto($staff, $child)->assertForbidden();
});
test('an administrator is unaffected', function () {
expect(Folder::uploadableBy(User::factory()->create(), $this->public))->toBeTrue();
});
test('the chunked and API paths answer the same way', function () {
// Every upload route asks Folder::uploadableBy(), which is the point
// of it being there — one answer rather than four.
$staff = publicFolderStaff([Permission::Upload]);
expect(Folder::uploadableBy($staff, $this->public))->toBeFalse()
->and(Folder::uploadableBy($staff, $this->private))->toBeTrue();
});
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
use App\Modules\Files\Thumbnails\ImageAudience;
use App\Modules\Files\Thumbnails\ImageRendition;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
use Illuminate\Support\Facades\Cache;
/*
|--------------------------------------------------------------------------
| One render, however many ask at once
|--------------------------------------------------------------------------
|
| Renditions are generated on demand and cached by existence, and nothing
| between the callers stopped two requests decoding the same image at the
| same time — the atomic rename settled which file survived, not whether
| both had done the work.
|
| The trigger is not an attack. A public listing emits one thumbnail URL
| per file, a browser opens six or more connections at once, and the first
| visit to a gallery of ordinary camera images was six simultaneous
| decodes, each holding four bytes per source pixel, on a container sized
| for one. PublicGroupsController reaches the generator with no account.
*/
function sourceImage(int $width = 400, int $height = 300): string
{
$path = sys_get_temp_dir().'/single-flight-'.bin2hex(random_bytes(6)).'.jpg';
$image = imagecreatetruecolor($width, $height);
imagefilledrectangle($image, 0, 0, $width, $height, imagecolorallocate($image, 30, 90, 150));
imagejpeg($image, $path, 70);
imagedestroy($image);
return $path;
}
function destinationPath(): string
{
$path = sys_get_temp_dir().'/rendition-'.bin2hex(random_bytes(6)).'.jpg';
@unlink($path);
return $path;
}
afterEach(function () {
foreach (glob(sys_get_temp_dir().'/single-flight-*') ?: [] as $f) {
@unlink($f);
}
foreach (glob(sys_get_temp_dir().'/rendition-*') ?: [] as $f) {
@unlink($f);
}
});
test('it renders when nothing else holds the lock', function () {
$source = sourceImage();
$destination = destinationPath();
app(ThumbnailGenerator::class)->generate(
$source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
);
expect(is_file($destination))->toBeTrue()
->and(filesize($destination))->toBeGreaterThan(0);
});
// The whole point: a waiter that gets in after the winner finished must
// read the file rather than decode the source a second time.
test('a request that waited serves what the winner made instead of rendering again', function () {
$source = sourceImage();
$destination = destinationPath();
// Stand in for the winner: the rendition is already there.
file_put_contents($destination, 'already rendered');
$before = filemtime($destination);
app(ThumbnailGenerator::class)->generate(
$source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
);
// Untouched — not re-encoded over the top.
expect(file_get_contents($destination))->toBe('already rendered')
->and(filemtime($destination))->toBe($before);
});
// An empty file is what a render killed mid-flight leaves behind. It is
// not a rendition, and treating it as one serves a broken image for as
// long as the file lives, because nothing invalidates a cached rendition.
test('an empty file is not treated as somebody else\'s finished work', function () {
$source = sourceImage();
$destination = destinationPath();
file_put_contents($destination, '');
app(ThumbnailGenerator::class)->generate(
$source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
);
expect(filesize($destination))->toBeGreaterThan(0);
});
// Deliberately not rendering anyway on timeout: falling through would
// reinstate the pile-on at the moment the system is already struggling.
// One failed thumbnail beats a container that dies and takes the warm
// cache with it.
test('it refuses rather than piling on when the wait times out', function () {
// Shortened so the suite does not pay the real wait; the behaviour
// under test is what happens when it elapses, not its length.
config()->set('projectsend.rendition_lock_wait_seconds', 1);
$source = sourceImage();
$destination = destinationPath();
// Somebody else is mid-render and has not finished.
$held = Cache::lock('rendition:'.sha1($destination), 120);
expect($held->get())->toBeTrue();
$generator = app(ThumbnailGenerator::class);
expect(fn () => $generator->generate(
$source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
))->toThrow(RuntimeException::class);
// And it did not decode the source behind the holder's back.
expect(is_file($destination))->toBeFalse();
$held->release();
});
// The lock is per rendition, not global: two different images must not
// queue behind each other.
test('two different renditions do not block one another', function () {
$source = sourceImage();
$mine = destinationPath();
$theirs = destinationPath();
$held = Cache::lock('rendition:'.sha1($theirs), 120);
expect($held->get())->toBeTrue();
app(ThumbnailGenerator::class)->generate(
$source, $mine, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
);
expect(is_file($mine))->toBeTrue();
$held->release();
});
test('the lock is released, so the next request is not blocked by the last', function () {
$source = sourceImage();
$destination = destinationPath();
$generator = app(ThumbnailGenerator::class);
$generator->generate($source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail);
expect(Cache::lock('rendition:'.sha1($destination), 5)->get())->toBeTrue();
});
// A render that throws must not leave the lock held, or one oversized
// image would wedge that rendition for everybody until the TTL expired.
test('a failed render still releases the lock', function () {
$destination = destinationPath();
$generator = app(ThumbnailGenerator::class);
expect(fn () => $generator->generate(
'/nonexistent/source.jpg', $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
))->toThrow(RuntimeException::class);
expect(Cache::lock('rendition:'.sha1($destination), 5)->get())->toBeTrue();
});
// A stray empty environment variable would otherwise make every
// concurrent request fail instantly instead of waiting — the exact
// opposite of what this is for, produced by doing nothing wrong.
//
// Asserted on the resolved value rather than on the clock: Laravel's
// block() measures in whole seconds, so a one-second wait can elapse on
// the very next tick and a timing assertion here would be flaky rather
// than wrong.
test('a zero, empty or nonsense wait still waits', function () {
$resolve = function ($value): int {
config()->set('projectsend.rendition_lock_wait_seconds', $value);
$method = new ReflectionMethod(ThumbnailGenerator::class, 'lockWaitSeconds');
return $method->invoke(app(ThumbnailGenerator::class));
};
// Never zero, whatever arrives.
expect($resolve(0))->toBe(1)
->and($resolve(-5))->toBe(1)
// Not a number at all: fall back to the default rather than to
// nothing, which is what an unset or misspelled variable gives.
->and($resolve(''))->toBe(15)
->and($resolve(null))->toBe(15)
->and($resolve('nonsense'))->toBe(15)
// And an honest value is honoured.
->and($resolve(30))->toBe(30)
->and($resolve('45'))->toBe(45);
});
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Events\FileWasStored;
use App\Modules\Files\Models\File;
use App\Modules\Files\Sharing\CreateShareLink;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Identity\Permissions\Permission;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
});
/*
|--------------------------------------------------------------------------
| One seam for every upload path
|--------------------------------------------------------------------------
|
| Dispatched from StoreUploadedFile rather than from a controller, because
| that is where the chunked flow and the synchronous POST converge. A
| listener registered elsewhere — a package, say — should not have to know
| which route a file arrived by.
*/
test('storing a file announces it, whichever path stored it', function () {
Event::fake([FileWasStored::class]);
$this->actingAs($this->admin)->post('/files', [
'file' => Illuminate\Http\UploadedFile::fake()->create('report.pdf', 12, 'application/pdf'),
'name' => '',
'description' => '',
])->assertRedirect();
Event::assertDispatched(FileWasStored::class, function (FileWasStored $event): bool {
return $event->file->original_name === 'report.pdf'
&& $event->uploader->is($this->admin);
});
});
test('a client uploading through the portal announces it too', function () {
// The title above says "whichever path stored it" and only the plain
// staff POST proved it. This is the path the hosted free tier hangs
// on: a *client*, through the resumable flow, whose upload is what
// cloud-modules listens for to mint the public link.
//
// Worth a test of its own rather than trusting the shared
// StoreUploadedFile, because the package's own suite cannot tell us —
// it fakes both the event and the link-minting, so a chunked path that
// stopped dispatching would leave every one of its tests green and the
// free tier silently inert.
Event::fake([FileWasStored::class]);
$client = User::factory()->client()->create();
RolePermission::query()->firstOrCreate([
'role_id' => $client->role_id,
'permission' => Permission::Upload->value,
]);
$this->actingAs($client);
$session = $this->postJson('/uploads', [
'filename' => 'holiday.jpg',
'size' => 11,
'type' => 'image/jpeg',
])->assertOk()->json('uploadId');
$signed = $this->getJson("/uploads/{$session}/parts/1/sign")->assertOk()->json('url');
$this->call('PUT', $signed, [], [], [], [], 'hello world');
$this->postJson("/uploads/{$session}/complete")->assertOk();
Event::assertDispatched(FileWasStored::class, function (FileWasStored $event) use ($client): bool {
return $event->file->original_name === 'holiday.jpg'
&& $event->uploader->is($client);
});
});
// The row has to be complete when a listener sees it, or a listener that
// reads the file back gets a half-built one.
test('the file it carries is already stored and readable', function () {
$seen = null;
Event::listen(FileWasStored::class, function (FileWasStored $event) use (&$seen): void {
$seen = File::query()->find($event->file->id);
});
$this->actingAs($this->admin)->post('/files', [
'file' => Illuminate\Http\UploadedFile::fake()->create('a.pdf', 5, 'application/pdf'),
'name' => '',
'description' => '',
])->assertRedirect();
expect($seen)->not->toBeNull()
->and($seen->uploaded_by)->toBe($this->admin->id)
->and(Storage::disk($seen->disk)->exists($seen->path))->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| Minting a link, from outside a request
|--------------------------------------------------------------------------
*/
test('it mints a long random token and never a chosen one by default', function () {
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
$link = app(CreateShareLink::class)->for($file, $this->admin);
// The token is the whole authorization for /s/{token} — there is
// nothing behind it — so its only defence is being unguessable.
// 32 characters is about 190 bits, more than a UUID's 122.
expect(strlen($link->token))->toBe(32)
->and($link->expires_at)->toBeNull()
->and($link->max_downloads)->toBeNull()
->and($link->created_by)->toBe($this->admin->id);
});
test('two links for the same file never share a token', function () {
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
$action = app(CreateShareLink::class);
expect($action->for($file, $this->admin)->token)
->not->toBe($action->for($file, $this->admin)->token);
});
// The controller still owns the permission questions — whether this
// person may set an expiry or a cap is a fact about them, and the action
// has no viewer to ask.
test('the staff form still refuses an expiry to somebody without the permission', function () {
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
$limited = staffWithPermissions(['upload', 'edit_files']);
$file->forceFill(['uploaded_by' => $limited->id])->save();
$this->actingAs($limited)->post("/files/{$file->id}/share-links", [
'expires_at' => now()->addWeek()->toDateString(),
'max_downloads' => 3,
])->assertRedirect();
$link = $file->shareLinks()->sole();
expect($link->expires_at)->toBeNull()
->and($link->max_downloads)->toBeNull();
});
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Uploads\UploadSession;
use Illuminate\Support\Facades\DB;
/**
* The part of GHSA-6jh6-gvj5-pv8v's fix the ordinary suite cannot see.
*
* A session's staged-byte total is a running count that goes up when a
* part is claimed and down when the part turns out smaller, is replaced,
* or never arrives. The column is `BIGINT UNSIGNED`, and on MySQL that
* type does not clamp: an expression that would go below zero raises
* SQLSTATE 22003 — and it raises it in a `WHERE` as readily as in a `SET`,
* so the bound written to prevent the underflow is itself the statement
* that underflows.
*
* **SQLite has no unsigned integers.** Every one of these cases passes
* there whether the arithmetic is arranged correctly or not, which is why
* this file skips loudly instead of passing quietly.
*
* To run it:
*
* docker compose exec -T -e DB_CONNECTION=mysql -e DB_HOST=db \
* -e DB_DATABASE=staged_bytes_check -e DB_USERNAME=root -e DB_PASSWORD=root \
* app vendor/bin/pest tests/Feature/Files/UploadSessionStagedBytesMysqlTest.php
*/
beforeEach(function () {
if (DB::connection()->getDriverName() !== 'mysql') {
test()->markTestSkipped('Needs MySQL: SQLite has no unsigned integers and cannot show an underflow.');
}
$this->session = UploadSession::query()->create([
'user_id' => User::factory()->create()->id,
'original_name' => 'staged.zip',
'size' => 100,
]);
});
function stagedBytes(): int
{
return (int) UploadSession::query()->findOrFail(test()->session->id)->staged_bytes;
}
test('the column really is unsigned', function () {
// Asserted rather than assumed: everything below only means something
// if the column in use is the one that cannot go negative.
// information_schema rather than SHOW COLUMNS: the latter takes no
// bound parameter for its LIKE, and interpolating a table name into a
// query is not a habit worth keeping for a test's convenience.
$type = DB::selectOne(
'select column_type as type from information_schema.columns
where table_schema = database() and table_name = ? and column_name = ?',
['upload_sessions', 'staged_bytes'],
)->type ?? '';
expect(strtolower((string) $type))->toContain('unsigned');
});
test('a session fills to its declared size and no further', function () {
expect($this->session->reserveStaged(60))->toBeTrue()
->and(stagedBytes())->toBe(60);
expect($this->session->reserveStaged(60))->toBeFalse()
->and(stagedBytes())->toBe(60);
expect($this->session->reserveStaged(40))->toBeTrue()
->and(stagedBytes())->toBe(100);
expect($this->session->reserveStaged(1))->toBeFalse()
->and(stagedBytes())->toBe(100);
});
test('a claim larger than the whole session is refused rather than attempted', function () {
// The upper bound is rearranged to `staged_bytes <= size - delta`, and
// that rearrangement is only valid while the right-hand side is not
// negative. Without this early exit a claim of 400 against a session of
// 100 compares against 0, which an empty session satisfies.
expect($this->session->reserveStaged(400))->toBeFalse()
->and(stagedBytes())->toBe(0);
});
test('a refund larger than what is held changes nothing instead of erroring', function () {
$this->session->reserveStaged(40);
// The shape that raised SQLSTATE 22003: settling a 100-byte claim that
// was never fully counted. It has to be a refusal, not an exception —
// this runs in putPart()'s finally, where a throw would replace the
// real response with a 500.
$this->session->settleStaged(100, 0);
expect(stagedBytes())->toBe(40);
});
test('a refund of exactly what is held empties the session', function () {
$this->session->reserveStaged(40);
$this->session->settleStaged(40, 0);
expect(stagedBytes())->toBe(0);
// And the room really is free again, not merely reported as zero.
expect($this->session->reserveStaged(100))->toBeTrue()
->and(stagedBytes())->toBe(100);
});
@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
use App\Models\User;
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 Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
use Laravel\Sanctum\Sanctum;
/**
* A group belongs to the people in it, and a client-scoped staff member
* holds only some of them.
*
* GroupMembershipScopeTest beside this one covers *reach*: what joining a
* group would hand somebody. This covers the group object itself — being
* told it exists, and renaming, deleting or publishing it. The two are
* different questions and were answered by the same predicate, which only
* asked the first.
*
* Reported as GHSA-r3hg-3fxw-rcmr.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$role = Role::query()->create(['name' => 'Reps '.Str::random(6), 'client_scoped' => true]);
foreach ([Permission::ManageGroups, Permission::EditGroups, Permission::DeleteGroups, Permission::CreateGroups] as $permission) {
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
}
$this->rep = User::factory()->create(['role_id' => $role->id]);
$this->mine = User::factory()->client()->create(['name' => 'Mine']);
$this->rep->assignedClients()->sync([$this->mine->id]);
$this->stranger = User::factory()->client()->create(['name' => 'Not Mine']);
// The group at the centre of the report: somebody else's client is its
// only member, and nothing has been shared with it yet — so every
// "does this group reach past my library" check answers no, vacuously.
$this->theirs = Group::query()->create(['name' => 'Theirs Only', 'slug' => 'theirs-only', 'public' => false]);
$this->theirs->members()->syncWithoutDetaching([$this->stranger->id]);
$this->ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
$this->ours->members()->syncWithoutDetaching([$this->mine->id]);
});
function listedGroupNames(User $viewer): array
{
$names = [];
test()->actingAs($viewer)->get('/groups')->assertOk()->assertInertia(
function (AssertableInertia $page) use (&$names) {
$names = collect($page->toArray()['props']['groups']['data'] ?? $page->toArray()['props']['groups'])
->pluck('name')->all();
},
);
return $names;
}
/*
|--------------------------------------------------------------------------
| Being told it exists
|--------------------------------------------------------------------------
*/
test('the listing hides a group made entirely of other people\'s clients', function () {
expect(listedGroupNames($this->rep))->not->toContain('Theirs Only');
});
test('the listing still shows a group holding one of their own clients', function () {
expect(listedGroupNames($this->rep))->toContain('Ours');
});
test('an unscoped administrator still sees every group', function () {
expect(listedGroupNames($this->admin))->toContain('Theirs Only')->toContain('Ours');
});
test('the API listing hides it too', function () {
Sanctum::actingAs($this->rep, ['manage_groups']);
$names = collect($this->getJson('/api/v1/groups')->assertOk()->json('data'))->pluck('name')->all();
expect($names)->not->toContain('Theirs Only')->toContain('Ours');
});
/*
|--------------------------------------------------------------------------
| Changing it
|--------------------------------------------------------------------------
|
| The higher half of the report. Renaming, deleting or publishing a group
| lands on every member, and none of this group's members are theirs.
*/
test('they cannot open the edit form for it', function () {
$this->actingAs($this->rep)->get("/groups/{$this->theirs->id}")->assertNotFound();
});
test('they cannot rename it', function () {
$this->actingAs($this->rep)
->patch("/groups/{$this->theirs->id}", ['name' => 'Renamed', 'public' => false])
->assertNotFound();
expect($this->theirs->fresh()->name)->toBe('Theirs Only');
});
test('they cannot publish it', function () {
// The consequence the report calls the serious one: a public group
// becomes an anonymous listing for whatever is shared with it later.
$this->actingAs($this->rep)
->patch("/groups/{$this->theirs->id}", ['name' => 'Theirs Only', 'public' => true])
->assertNotFound();
expect($this->theirs->fresh()->public)->toBeFalse();
});
test('they cannot delete it out from under its members', function () {
$this->actingAs($this->rep)->delete("/groups/{$this->theirs->id}")->assertNotFound();
expect(Group::query()->whereKey($this->theirs->id)->exists())->toBeTrue();
});
test('the API refuses the same three', function () {
Sanctum::actingAs($this->rep, ['edit_groups', 'delete_groups']);
$this->getJson("/api/v1/groups/{$this->theirs->id}")->assertNotFound();
$this->patchJson("/api/v1/groups/{$this->theirs->id}", ['name' => 'Renamed'])->assertNotFound();
$this->deleteJson("/api/v1/groups/{$this->theirs->id}")->assertNotFound();
expect($this->theirs->fresh()->name)->toBe('Theirs Only');
});
/*
|--------------------------------------------------------------------------
| What must keep working
|--------------------------------------------------------------------------
|
| The pins. The fix suggested in the report puts the membership check
| inside groupReachesNoFurther(), which allowsGroupMembership() also
| calls — and that would have broken both of these.
*/
test('they can still rename a group of their own client', function () {
$this->actingAs($this->rep)
->patch("/groups/{$this->ours->id}", ['name' => 'Ours Renamed', 'public' => false])
->assertRedirect();
expect($this->ours->fresh()->name)->toBe('Ours Renamed');
});
test('they can still put the first member into a group they just made', function () {
// A group nobody has joined belongs to nobody, and this is the case
// StaffLibraryScope's own docblock says must keep working.
$fresh = Group::query()->create(['name' => 'Brand New', 'slug' => 'brand-new', 'public' => false]);
$this->actingAs($this->rep)
->post("/groups/{$fresh->id}/members", ['user_id' => $this->mine->id])
->assertRedirect();
expect($fresh->members()->pluck('users.id')->all())->toContain($this->mine->id);
});
test('they can still rename an empty group', function () {
$fresh = Group::query()->create(['name' => 'Brand New', 'slug' => 'brand-new', 'public' => false]);
$this->actingAs($this->rep)
->patch("/groups/{$fresh->id}", ['name' => 'Named At Last', 'public' => false])
->assertRedirect();
expect($fresh->fresh()->name)->toBe('Named At Last');
});
test('a mixed group stays changeable, with its stranger unnamed', function () {
// The boundary between this report and GHSA-whmp-p9hv-r7j7. "Every
// member must be mine" is the obvious reading of the fix and it is
// wrong: it turns that advisory's narrowing back into a 404. A group
// holding one of their clients is theirs to work with; what protects
// the stranger in it is that they are never named, and that anything
// shared with the group beyond this viewer's library still refuses
// the change.
$mixed = Group::query()->create(['name' => 'Mixed', 'slug' => 'mixed', 'public' => false]);
$mixed->members()->syncWithoutDetaching([$this->mine->id, $this->stranger->id]);
$this->actingAs($this->rep)
->patch("/groups/{$mixed->id}", ['name' => 'Mixed Renamed', 'public' => false])
->assertRedirect();
expect($mixed->fresh()->name)->toBe('Mixed Renamed');
});
test('an unscoped administrator can still change anything', function () {
$this->actingAs($this->admin)
->patch("/groups/{$this->theirs->id}", ['name' => 'Admin Renamed', 'public' => false])
->assertRedirect();
expect($this->theirs->fresh()->name)->toBe('Admin Renamed');
});
@@ -183,3 +183,51 @@ test('reassign falls back to cascade when the target is no longer valid', functi
expect(File::find($file->id))->toBeNull()
->and(ActivityLog::query()->where('action', Action::AccountContentCascadeDeleted->value)->exists())->toBeTrue();
});
test('a staff member\'s content is never handed to a client', function () {
// The installation-wide reassignment target is one id for every
// erasure, and the picker offers clients — legitimately, because
// erasing a client and handing their files to another client is what
// that setting is for. Applied to a *staff* account it means something
// else entirely: a staff library is usually everything, and a client
// named as the target inherits the lot.
$client = User::factory()->client()->create(['name' => 'Inheriting Client']);
app(Settings::class)->set(Setting::AccountErasureContentAction, 'reassign');
app(Settings::class)->set(Setting::AccountErasureReassignTo, $client->id);
$leaving = User::factory()->create();
$file = File::factory()->create(['uploaded_by' => $leaving->id]);
app(AccountEraser::class)->erase($leaving);
// Cascade, not reassign: never orphaned, and never disclosed either.
expect(File::find($file->id))->toBeNull()
->and(ActivityLog::query()->where('action', Action::AccountContentCascadeDeleted->value)->exists())->toBeTrue();
});
test('a client\'s content can still go to a client', function () {
// Unchanged, and deliberately: this is the case the setting exists for.
$inheritor = User::factory()->client()->create();
app(Settings::class)->set(Setting::AccountErasureContentAction, 'reassign');
app(Settings::class)->set(Setting::AccountErasureReassignTo, $inheritor->id);
$leaving = User::factory()->client()->create();
$file = File::factory()->create(['uploaded_by' => $leaving->id]);
app(AccountEraser::class)->erase($leaving);
expect(File::find($file->id)?->uploaded_by)->toBe($inheritor->id);
});
test('a staff member\'s content still goes to another staff member', function () {
$inheritor = User::factory()->create();
app(Settings::class)->set(Setting::AccountErasureContentAction, 'reassign');
app(Settings::class)->set(Setting::AccountErasureReassignTo, $inheritor->id);
$leaving = User::factory()->create();
$file = File::factory()->create(['uploaded_by' => $leaving->id]);
app(AccountEraser::class)->erase($leaving);
expect(File::find($file->id)?->uploaded_by)->toBe($inheritor->id);
});
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\AccountLookup;
use Illuminate\Support\Facades\DB;
/**
* The one test that can see GHSA-wgxf-v8cr-37mj.
*
* The defect was never in PHP: `where('email', $address)` asked the
* database what equality means, and the documented collation —
* `utf8mb4_unicode_ci`, in INSTALL.md and in config/database.php — folds
* accents. `administrator@example.com` and `administrator@éxample.com`
* compare equal, and those are two different domains: the second is
* `xn--xample-9ua.com`, which somebody else can own and honestly verify at
* an OIDC provider.
*
* **The suite runs on SQLite, whose `=` is byte-exact, so none of this
* exists there.** That is why the vulnerability lived through six releases
* with a green suite, and why this file skips rather than passing: a test
* that silently proves nothing is worse than one that says it did not run.
*
* To run it:
*
* docker compose exec -T -e DB_CONNECTION=mysql -e DB_HOST=db \
* -e DB_DATABASE=collation_check -e DB_USERNAME=root -e DB_PASSWORD=root \
* app vendor/bin/pest tests/Feature/Identity/AccountLookupCollationTest.php
*/
beforeEach(function () {
if (DB::connection()->getDriverName() !== 'mysql') {
test()->markTestSkipped('Needs MySQL: SQLite compares byte-exactly and cannot show a collation fault.');
}
});
test('the database really does fold the accent', function () {
// Asserted rather than assumed, because everything below is only
// meaningful if this is true of the connection actually in use. An
// installation on utf8mb4_bin has never had the bug.
$folds = DB::selectOne("SELECT _utf8mb4'a@example.com' COLLATE utf8mb4_unicode_ci = _utf8mb4'a@éxample.com' COLLATE utf8mb4_unicode_ci AS c")->c;
expect((int) $folds)->toBe(1);
});
test('the raw query matches an address it should not', function () {
// The defect itself, shown rather than described: this is exactly what
// SocialAuthenticator used to run.
User::factory()->create(['email' => 'administrator@example.com']);
$found = User::query()->where('email', 'administrator@éxample.com')->first();
expect($found)->not->toBeNull('the collation no longer folds — the rest of this file is moot');
});
test('the lookup refuses the address the collation would have accepted', function () {
// The fix. Same database, same collation, same row present.
User::factory()->create(['email' => 'administrator@example.com']);
expect(app(AccountLookup::class)->byEmail('administrator@éxample.com'))->toBeNull();
});
test('and still finds the real one', function () {
$user = User::factory()->create(['email' => 'administrator@example.com']);
expect(app(AccountLookup::class)->byEmail('administrator@example.com')?->id)->toBe($user->id)
->and(app(AccountLookup::class)->byEmail('ADMINISTRATOR@Example.com')?->id)->toBe($user->id);
});
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\AccountLookup;
/**
* Which account an address names.
*
* **This file cannot, on its own, prove the bug it was written for.** The
* suite runs on SQLite (`phpunit.xml`), whose `=` is byte-exact, so the
* collation that folds `é` into `e` does not exist here and the takeover
* never reproduces. A test written the obvious way — seed an account,
* sign in through OIDC with the accented address, assert refused — passes
* on unfixed code, for the wrong reason.
*
* So the split is deliberate. These pin the *comparison*, which is where
* the decision now lives and which is driver-independent.
* AccountLookupCollationTest beside this one exercises the whole chain and
* skips unless the connection is MySQL; it is the only one that can see
* the original defect, and it has to be run deliberately.
*
* Reported as GHSA-wgxf-v8cr-37mj.
*/
beforeEach(function () {
$this->lookup = app(AccountLookup::class);
});
test('an accented domain is a different address', function () {
// The whole finding in one line. éxample.com is xn--xample-9ua.com,
// a name somebody else can register and honestly prove they own.
expect($this->lookup->isSameAddress('administrator@example.com', 'administrator@éxample.com'))
->toBeFalse();
});
test('case is still folded, because that is a real requirement', function () {
// Addresses are stored lowercased and a provider may send any case.
// Folding case without folding accents is the line being drawn.
expect($this->lookup->isSameAddress('admin@example.com', 'ADMIN@Example.com'))->toBeTrue();
});
test('surrounding whitespace does not make it a different mailbox', function () {
expect($this->lookup->isSameAddress('admin@example.com', ' admin@example.com '))->toBeTrue();
});
test('a null on either side matches nothing', function () {
expect($this->lookup->isSameAddress(null, 'admin@example.com'))->toBeFalse()
->and($this->lookup->isSameAddress('admin@example.com', null))->toBeFalse();
});
test('other confusables are refused too', function (string $lookalike) {
expect($this->lookup->isSameAddress('admin@example.com', $lookalike))->toBeFalse();
})->with([
'accented o' => ['admin@exämple.com'],
'cyrillic a' => ['аdmin@example.com'],
'trailing dot' => ['admin@example.com.'],
'different tld' => ['admin@example.co'],
]);
/*
|--------------------------------------------------------------------------
| The lookup itself
|--------------------------------------------------------------------------
|
| These pass on SQLite whether or not the fix is present, and are here for
| the ordinary behaviour rather than for the defect.
*/
test('it finds the account that holds the address', function () {
$user = User::factory()->create(['email' => 'owner@example.com']);
expect($this->lookup->byEmail('owner@example.com')?->id)->toBe($user->id);
});
test('it finds nothing for an address nobody holds', function () {
User::factory()->create(['email' => 'owner@example.com']);
expect($this->lookup->byEmail('somebody@example.com'))->toBeNull();
});
test('a deleted account is out of sight unless asked for', function () {
// A deleted account keeps its address until erasure, which is what
// AvailableEmailRule is built on — so the erasure command has to be
// able to reach it and the sign-in paths must not.
$user = User::factory()->create(['email' => 'gone@example.com']);
$user->delete();
expect($this->lookup->byEmail('gone@example.com'))->toBeNull()
->and($this->lookup->byEmail('gone@example.com', withTrashed: true)?->id)->toBe($user->id);
});

Some files were not shown because too many files have changed in this diff Show More