mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 05:25:51 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2029309126 | |||
| d83d2d9acb | |||
| 06c364d29a | |||
| 046be36861 | |||
| 4a35c25894 | |||
| 58497ef776 | |||
| d751314196 | |||
| 00d118559d | |||
| abaca20261 | |||
| b16d780ebe | |||
| 602c7bed94 | |||
| 76f79d53a0 | |||
| 3f81dd5eab | |||
| 1cefdee610 | |||
| f2e7820f5c | |||
| a92feed3ad | |||
| 73d93495c9 | |||
| 2eb23dbc07 | |||
| 13b56186f4 | |||
| e272f19045 | |||
| 28e18497b5 | |||
| b44c6bf098 | |||
| eade690f73 | |||
| 3e15237f90 | |||
| 9cc469b111 | |||
| 4469648d82 | |||
| 26205082c2 | |||
| ab6e9eecf3 | |||
| 1dc274e896 | |||
| 7045da7450 | |||
| d58e48301f | |||
| c49811f3c0 | |||
| 351da21e8d | |||
| c172d0d645 |
@@ -44,12 +44,6 @@ on:
|
||||
- 'docker/production/dockerhub-overview.md'
|
||||
- '.github/screenshots/**'
|
||||
|
||||
# A second push supersedes the first: there is no value in finishing a run
|
||||
# for a commit nobody will look at again.
|
||||
concurrency:
|
||||
group: tests-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# A second push supersedes the first — the later run covers a superset of
|
||||
# what the earlier one was checking, so finishing both buys nothing and
|
||||
# costs a runner.
|
||||
|
||||
+65
-391
@@ -13,6 +13,51 @@ Anything under **Upgrade notes** is something you have to do, not something we d
|
||||
This section collects changes as they land; the release process turns it into a numbered entry when
|
||||
a version is cut.
|
||||
|
||||
## 2.2.1 — 28 August 2026
|
||||
|
||||
A security release. Most of it closes ways somebody could reach past a boundary the rest of the
|
||||
application already enforced — including two that could lock you out of your own installation.
|
||||
|
||||
**Merged**
|
||||
|
||||
- [#1708](https://github.com/projectsend/projectsend/pull/1708) — Let an enforced user reach the far side of the confirm-password screen
|
||||
- [#1716](https://github.com/projectsend/projectsend/pull/1716) — Refuse the last administrator deleting themselves, and keep setup shut
|
||||
- [#1710](https://github.com/projectsend/projectsend/pull/1710) — Stop a folder deleting the files inside it that its owner may not delete
|
||||
- [#1714](https://github.com/projectsend/projectsend/pull/1714) — Hold the group edit screen to the same library boundary as the rest
|
||||
- [#1717](https://github.com/projectsend/projectsend/pull/1717) — Keep a private reply private after the client is deleted
|
||||
- [#1713](https://github.com/projectsend/projectsend/pull/1713) — Refuse self-deactivation over the API however the boolean is written
|
||||
- [#1709](https://github.com/projectsend/projectsend/pull/1709) — Ask the seat cap where a pending client is approved through edit()
|
||||
- [#1715](https://github.com/projectsend/projectsend/pull/1715) — Add a file to a zip once, however many ways the selection reaches it
|
||||
- [#1707](https://github.com/projectsend/projectsend/pull/1707) — Leave the test workflow one concurrency block, so it parses again
|
||||
- [#1711](https://github.com/projectsend/projectsend/pull/1711) — Stop the update tests emptying bootstrap/cache for every other worker
|
||||
- [#1712](https://github.com/projectsend/projectsend/pull/1712) — Make the storage durability dashboard test assert the verdict
|
||||
|
||||
**Also fixed**
|
||||
|
||||
- The plain-text version of an email no longer shows the link twice, wrapped in brackets.
|
||||
- The message you get when an account would exceed a limit no longer reads "limited to 1 staff
|
||||
accounts".
|
||||
|
||||
### Upgrade notes
|
||||
|
||||
- **Nothing to do.** Drop in the new files and run `php artisan migrate` as usual; this release adds
|
||||
no migrations, no settings and no new environment values.
|
||||
|
||||
- **One thing changes behaviour.** If somebody on your team has been deleting a folder as a way of
|
||||
clearing out files other people uploaded, that now refuses and says how many files are in the way.
|
||||
It is the same rule the file list has always applied one screen over — the folder was the way
|
||||
around it, and what it removed was not recoverable.
|
||||
|
||||
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who reported, diagnosed and fixed
|
||||
every one of the above.
|
||||
|
||||
### Issues closed since 2.2.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
|
||||
|
||||
## 2.2.0 — 27 August 2026
|
||||
|
||||
A big release. Most of it closes holes in who can see what. The rest is a handful of new things.
|
||||
@@ -77,401 +122,30 @@ installed ProjectSend by hand.
|
||||
a banner naming the problem and the fix.
|
||||
|
||||
- **If you run behind a reverse proxy, check `TRUSTED_PROXIES`.** It is now read correctly, which it
|
||||
was not before — see the fix below. Set it in `.env`, and do not run `config:cache`, which stops
|
||||
`.env` being read at all.
|
||||
was not before. Set it in `.env`, and do not run `config:cache`, which stops `.env` being read at
|
||||
all.
|
||||
|
||||
### Added
|
||||
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who found, diagnosed and fixed most
|
||||
of the boundary work above, and to [@mstewart14](https://github.com/mstewart14),
|
||||
[@elibrachas](https://github.com/elibrachas), [@mueller7382](https://github.com/mueller7382) and
|
||||
[@pabloalvarez44](https://github.com/pabloalvarez44) for reports and fixes.
|
||||
|
||||
- **Google Cloud Storage as a storage backend.** External storage used to mean S3 and nothing else.
|
||||
The Storage settings screen now asks which provider you are using first, and offers Google Cloud
|
||||
Storage alongside the S3-compatible option: choose it, paste a service account key with read and
|
||||
write access to your bucket, and new uploads go there. The key is stored encrypted and never shown
|
||||
again, and **Test connection** checks it can actually reach the bucket before you switch anything
|
||||
over — using a probe that works with a least-privilege key, rather than one that needs permission
|
||||
to read the bucket's own settings. Downloads and previews are handed to the visitor as a
|
||||
short-lived signed link, exactly as they already were for S3.
|
||||
### Issues closed since 2.1.0
|
||||
|
||||
Nothing changes for an existing installation. Configurations saved before this release are S3, are
|
||||
still S3, and are not asked to say so. Files already stored stay where they are — the setting
|
||||
applies to new uploads, and there is still no migration between backends.
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- **A maximum size for zip downloads.** A new Settings → Downloads screen sets the largest selection
|
||||
anyone can ask for as a single zip — 2 GB out of the box, any figure you like, or 0 for no limit.
|
||||
Building an archive costs disk space and occupies the background worker for as long as it takes to
|
||||
write, so one person asking for a whole library at once used to hold up every notification email
|
||||
behind it. Ask for more than the limit and you are told how large your selection is and what the
|
||||
ceiling is, rather than simply refused; each person can have one archive being prepared at a time,
|
||||
for the same reason.
|
||||
|
||||
- **ProjectSend tells you if nothing is building your zip downloads.** The change below gives zip
|
||||
building its own queue, which a manual install's background worker has to be told about. Miss that
|
||||
and the failure is silent: email keeps going out, zip downloads simply never finish, and nothing
|
||||
in any log says why. Staff who can see system information now get a banner naming the problem and
|
||||
the one-line fix, so nobody has to work it out from a spinner that never stops.
|
||||
|
||||
- **Zip downloads no longer hold up your email.** Preparing a large archive can take a while, and it
|
||||
used to run on the same queue as everything else — so one big zip could delay every notification
|
||||
email behind it. Zip building now has a queue of its own, and the Docker images run a second
|
||||
background worker for it.
|
||||
|
||||
**Manual installs:** your background worker has to be told about the new queue, or zips will never
|
||||
finish and nothing will say why. `update.sh` spots this and offers to fix the worker service for
|
||||
you, keeping a copy of the old one — so for most people there is nothing to do but say yes. If you
|
||||
update by hand, or your worker already names its own queues (the updater will say so rather than
|
||||
edit a deliberate arrangement), add `zips` to its `--queue` list and reload systemd. Docker
|
||||
installations need no change. See INSTALL.md for the two-worker setup if you would rather keep the
|
||||
two kinds of work apart.
|
||||
|
||||
- **A deleted account's email address can be used again.** Deleting an account keeps its record for
|
||||
a grace period before erasing it for good, and the address stays reserved until that happens — but
|
||||
only accounts that deleted *themselves* were ever scheduled for erasure. An account an
|
||||
administrator deleted sat in that state permanently, and its address could never be reused, with
|
||||
nothing on screen to explain why. Every deletion now schedules the erasure the same way, whoever
|
||||
performed it, and the staff screens explain a reserved address rather than saying only that it is
|
||||
taken: which date it frees up, or which command frees it sooner. Public registration deliberately
|
||||
keeps the plain "already taken" message, since telling a stranger the address once had an account
|
||||
here is the disclosure that message exists to avoid.
|
||||
|
||||
Accounts deleted before this change keep their old state on purpose — stamping them during an
|
||||
update would quietly start a countdown to erasure that nobody chose. The console command named in
|
||||
the new message handles those.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1678](https://github.com/projectsend/projectsend/pull/1678), closing
|
||||
[#1648](https://github.com/projectsend/projectsend/issues/1648))
|
||||
|
||||
- **A staff role limited to its own clients now stays limited.** Several ways around that limit are
|
||||
closed together, because any one of them made the rest decorative. A role holding the "manage
|
||||
users" permission could edit its own role and simply switch the limit off; it could hand itself
|
||||
clients it was never assigned; it could promote any client on the installation to a staff account,
|
||||
which is the most far-reaching thing that can be done to a client record. Uploading into, or
|
||||
moving a file into, a folder belonging to somebody else's clients is refused too, as is browsing
|
||||
the folder pickers past your own tree. None of this was reachable with any role that ships with
|
||||
ProjectSend — each needed a custom role built on the roles screen — but the combinations are ones
|
||||
the screen offers, so anyone who built one should update.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1681](https://github.com/projectsend/projectsend/pull/1681),
|
||||
[#1694](https://github.com/projectsend/projectsend/pull/1694),
|
||||
[#1697](https://github.com/projectsend/projectsend/pull/1697),
|
||||
[#1700](https://github.com/projectsend/projectsend/pull/1700) and
|
||||
[#1702](https://github.com/projectsend/projectsend/pull/1702))
|
||||
|
||||
- **A public file's private notes stay private.** The comment thread on a publicly listed file is
|
||||
meant to show what any visitor sees. It was instead answering signed-in visitors as themselves, so
|
||||
simply having an account — any account — showed staff-only notes on that file, or the messages
|
||||
addressed to that file's clients. Being signed in now shows you what a visitor sees, plus your own
|
||||
comments, unless you were entitled to see the file anyway.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1695](https://github.com/projectsend/projectsend/pull/1695))
|
||||
|
||||
- **A client is no longer shown the names of folders they cannot open.** Browsing into a folder in
|
||||
the client portal listed every subfolder inside it, including ones shared with somebody else.
|
||||
Opening one was always refused, so what escaped was the name — which can be enough, when folders
|
||||
are named after the people they belong to.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1690](https://github.com/projectsend/projectsend/pull/1690))
|
||||
|
||||
- **The maximum file size now applies to large uploads.** Big files are sent in pieces, and the size
|
||||
limit was only checked against the size the sender *claimed* before sending anything. Declaring a
|
||||
tiny upload and then sending gigabytes passed every check. The assembled file is now measured
|
||||
against the limit before it is accepted.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1682](https://github.com/projectsend/projectsend/pull/1682))
|
||||
|
||||
- **A download limit now holds when a zip is collected.** Preparing an archive never spent anybody's
|
||||
download allowance, and only collecting one did — so an archive prepared while a file was still
|
||||
available stayed collectable after its limit was spent, and several could be held that way at
|
||||
once. The limit is now checked at the moment the archive is handed over, which is also the moment
|
||||
it is spent. Archives also record exactly which files went into them, so the download history
|
||||
counts what was actually delivered rather than re-guessing it afterwards.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1692](https://github.com/projectsend/projectsend/pull/1692))
|
||||
|
||||
- **Public downloads work on installations using external storage.** The public listing's download
|
||||
link always answered as though the file were on the server's own disk, so on an installation
|
||||
keeping files in object storage it pointed at a path that had never been written. Its neighbours
|
||||
on the same page — thumbnails and previews — already handled both. Now it does too.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1693](https://github.com/projectsend/projectsend/pull/1693))
|
||||
|
||||
- **A large upload cannot be finished twice at once.** A retry or a double submit arriving while the
|
||||
first was still assembling could interleave with it, storing bytes that no longer matched the
|
||||
file's own checksum, or recording the same upload twice. Finishing an upload now takes a lock for
|
||||
that upload, and a second attempt is turned away rather than joining in.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1686](https://github.com/projectsend/projectsend/pull/1686))
|
||||
|
||||
- **Deleting an account either finishes or does nothing.** Removing an account and dealing with the
|
||||
files it owns were two separate steps with nothing holding them together, so a failure in the
|
||||
second left the account gone and its files still pointing at it — most easily when the person
|
||||
chosen to inherit them was deleted in between. Both now happen together or not at all. Relatedly,
|
||||
a file's stored bytes are now removed once the deletion is committed rather than as it happens, so
|
||||
a cancelled bulk deletion no longer restores records whose files are already gone.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1688](https://github.com/projectsend/projectsend/pull/1688) and
|
||||
[#1691](https://github.com/projectsend/projectsend/pull/1691))
|
||||
|
||||
- **Creating something with a create-only role no longer ends in an error page.** Roles can grant
|
||||
permission to create clients, staff accounts, groups or categories without permission to edit
|
||||
them. Creating one worked, but the page it sent you to afterwards was the edit page, which such a
|
||||
role may not open — so the record was created and you were shown a permission error, with no way
|
||||
to tell whether it had worked. You now land back on the create form with the confirmation message.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1684](https://github.com/projectsend/projectsend/pull/1684))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Accounts migrated from v1 can sign in again.** On some installations brought over from
|
||||
ProjectSend Legacy, every migrated person got an error page instead of a login screen — while
|
||||
anybody whose account was created in v2 signed in perfectly. The cause was the label on the stored
|
||||
password. Older versions of PHP wrote `$2a$` or `$2b$` where newer ones write `$2y$`; all three are
|
||||
the same algorithm, but ProjectSend only recognised the last one and gave up before it had even
|
||||
looked at the password. Upgrading relabels the affected accounts in place. Nothing about anybody's
|
||||
password changes, so there is no reset mail to send and nothing for you to do — the password they
|
||||
already had simply starts working again. The migration tool no longer creates the problem in the
|
||||
first place, from version 1.0.3 onwards.
|
||||
([#1706](https://github.com/projectsend/projectsend/issues/1706), reported by
|
||||
[@pabloalvarez44](https://github.com/pabloalvarez44))
|
||||
|
||||
- **Sessions no longer break behind a reverse proxy.** Signing in, or submitting the first-run setup
|
||||
form, could answer with a page-filling error instead — most visibly for anyone running behind
|
||||
Traefik, Nginx Proxy Manager or Caddy. `TRUSTED_PROXIES` was being read too early in the boot
|
||||
sequence to be seen at all, so the setting had never had any effect on a web request. Without it
|
||||
ProjectSend believed every visitor was arriving from the proxy over plain HTTP, built its links and
|
||||
cookies accordingly, and rejected the form that came back as though it had come from somewhere
|
||||
else. Docker installations that set the value as an environment variable were unaffected the whole
|
||||
time; manual installs, where the guide tells you to put it in `.env`, were not — which is why this
|
||||
looked so inconsistent. **Upgrade note:** if you run behind a proxy, set `TRUSTED_PROXIES` and do
|
||||
not run `config:cache`, which stops `.env` being read at all. Both are covered in INSTALL.md.
|
||||
([#1672](https://github.com/projectsend/projectsend/issues/1672), reported by
|
||||
[@mstewart14](https://github.com/mstewart14); fixed by
|
||||
[@elibrachas](https://github.com/elibrachas) in
|
||||
[#1674](https://github.com/projectsend/projectsend/pull/1674))
|
||||
|
||||
- **Saving something after your session has expired now takes you to the login page.** Instead of
|
||||
being told to sign in again, you got an unexplained error — the dashboard's widget settings and
|
||||
several settings screens were the usual places to meet it. The cause was a detail of how browsers
|
||||
follow redirects: they repeat the original request at the new address, so "save this" became "save
|
||||
this to the login page", which the login page has no idea what to do with. It now answers in a way
|
||||
that sends the browser to read the page rather than repeat the save. The same thing could happen to
|
||||
an account that was deactivated while someone was working in it, or one being asked to set up
|
||||
two-factor authentication, and both are fixed with it.
|
||||
([#1673](https://github.com/projectsend/projectsend/issues/1673), reported by
|
||||
[@mstewart14](https://github.com/mstewart14); found, diagnosed and fixed by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1680](https://github.com/projectsend/projectsend/pull/1680))
|
||||
|
||||
- **An upload that cannot be stored now fails instead of disappearing.** When files are kept in
|
||||
object storage and the storage backend refuses a write — an expired key, a bucket that has been
|
||||
renamed or removed, a permission that changed underneath you — the upload used to report success
|
||||
and record the file anyway. The entry appeared in the file list, and the download it promised was
|
||||
never going to work, because the bytes had gone nowhere. The upload now stops and says so, and no
|
||||
file is recorded. Installations keeping files on local disk were never affected.
|
||||
|
||||
- **Downloads and thumbnails for installations using external storage.** Two places assumed every
|
||||
file sat on the server's own disk, which stopped being true the moment S3-compatible storage was
|
||||
switched on. A share link to a file held in a bucket produced a broken download, and a public
|
||||
listing could not draw a thumbnail for one at all — while the same file downloaded and previewed
|
||||
correctly everywhere else, which made it look like the share link or the listing was at fault
|
||||
rather than where the file lived. Both now read the file from wherever it actually is. Nothing
|
||||
changes for installations keeping files on local disk, which is most of them.
|
||||
|
||||
- **One confirmation message instead of two.** Saving a new client, system user or role showed the
|
||||
same green "Client created." twice, stacked. So did deleting one. It was only ever cosmetic —
|
||||
nothing happened twice — but it read as though something had, which is the last thing a
|
||||
confirmation should do. Saves that stay on the same screen, such as the email settings, were never
|
||||
affected.
|
||||
([#1675](https://github.com/projectsend/projectsend/issues/1675), reported and diagnosed by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
|
||||
- **Connecting a provider to an account that already has one.** Signing in with Google, Microsoft or
|
||||
a custom provider worked, but attaching one to an existing account did not: the **Connect** button
|
||||
on Settings → Connected accounts appeared to do nothing at all. The button asks the server in the
|
||||
background, and the server answered by redirecting to the provider — a redirect a browser will not
|
||||
follow out of a background request to another site. The page sat there with no consent screen and
|
||||
no error to explain it, so the only reading available was that the button was dead. The server now
|
||||
tells the browser to go to the provider itself, and the flow starts as it should. Signing in from
|
||||
the login page was never affected, and neither is it now.
|
||||
([#1676](https://github.com/projectsend/projectsend/pull/1676), found and fixed by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
|
||||
- **Downloads on a host where the web server is not PHP's user.** A download is not served by PHP:
|
||||
PHP checks permissions and then hands the web server the path to stream. Where the two run as
|
||||
different users — cPanel and Plesk commonly arrange it that way — the web server could not open
|
||||
the file, because uploads are written readable only by the account that wrote them. The rest of
|
||||
the site gave no sign of it: uploading worked, the library listed everything, and only downloads
|
||||
failed, in the browser as `ERR_INVALID_RESPONSE`. Setting `FILES_WEB_SERVER_READABLE=true` now
|
||||
writes uploads so the web server can read them. It is opt-in, and deliberately so — the modes it
|
||||
uses are readable by every account on the machine, which is the wrong trade on a server where the
|
||||
web server and PHP are the same user, as they are in the Docker image and on most servers people
|
||||
set up themselves. The install guide has the full procedure, including the one thing no
|
||||
application setting can fix: a PHP-FPM pool with a restrictive umask, which caps new directories
|
||||
no matter what ProjectSend asks for.
|
||||
([#1668](https://github.com/projectsend/projectsend/issues/1668), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
|
||||
- **An installation that builds its own containers is no longer told to pull.** ProjectSend prints
|
||||
the update instructions for the way you installed it, and it had two answers where it needed
|
||||
three: anything running in a container was handed `docker compose pull && docker compose up -d`,
|
||||
including the Compose stack that builds from a checkout of the repository. There is no image
|
||||
behind those containers to pull, so both commands ran, reported success and changed nothing — and
|
||||
the dashboard went on offering the same release. Those installations are now recognised and given
|
||||
`git pull && docker compose up -d --build` instead, with the two extra steps a checkout needs when
|
||||
a release moves its dependencies or its frontend.
|
||||
([#1661](https://github.com/projectsend/projectsend/issues/1661), reported by
|
||||
[@mueller7382](https://github.com/mueller7382))
|
||||
|
||||
- **The dashboard no longer fails on shared hosting.** To decide which update instructions to print,
|
||||
ProjectSend asks whether it is running inside a container by looking for a file in the root of the
|
||||
filesystem. On shared hosting PHP is usually confined to your own directory, and looking outside it
|
||||
is treated as an error rather than as a "no" — so the one page that asks the question, the
|
||||
dashboard, returned a 500 while every other page worked. It now takes the restriction as the answer
|
||||
it always was: a server that keeps PHP inside a single directory is not our container image, and
|
||||
gets the manual update instructions, which is correct for shared hosting anyway. Nothing to change
|
||||
on your side, and no setting you would have been able to change if there were.
|
||||
([#1663](https://github.com/projectsend/projectsend/issues/1663), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
|
||||
- **502 Bad Gateway behind a reverse proxy.** Every page carried a `Link:` header listing its
|
||||
frontend assets, duplicating tags the page already had in its `<head>` — twenty of them on the
|
||||
login screen, more on a heavier page. nginx buffers a response's headers into a single block that
|
||||
defaults to 4 KB, so the file list, at over 6 KB of headers, was refused with `upstream sent too
|
||||
big header` and the proxy answered 502. Which pages went over depended on how many assets they
|
||||
loaded, so it looked like an intermittent fault: the login screen appeared, and then the
|
||||
application did not. The duplicate header is gone — the same pages now send under 1.3 KB — and no
|
||||
browser loses anything, because the tags it actually reads were always in the document. The
|
||||
install guide gained the proxy buffer settings for anyone on an older version or behind a proxy
|
||||
holding a tighter default.
|
||||
([#1664](https://github.com/projectsend/projectsend/issues/1664), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
|
||||
- **`docker logs` now shows the web server's log.** The container runs nginx, PHP-FPM, the queue
|
||||
worker and the scheduler, and all of them reported to Docker except the one you need when a
|
||||
request fails: nginx opened the log files named in its own configuration and wrote to them inside
|
||||
the container, where nothing looks. The effect was that a proxy problem produced no logs on either
|
||||
side — the reason for every 502 and every 403 existed, in a file nobody knew to open. Both its
|
||||
access and error logs now go to the container's output, and the Docker guide has a section on
|
||||
running behind a reverse proxy that says which side a given message points at.
|
||||
|
||||
- **A zip download is never offered over an archive that was not written.** Archives are built in the
|
||||
background, and the writing all happens at the very end — so a source file deleted while the build
|
||||
waited its turn, or a disk that filled up, produced no archive at all while the download was still
|
||||
marked ready. Clicking it then failed with an unexplained error. The same went for a selection
|
||||
whose files had all become unavailable: an archive with nothing in it is not written to disk
|
||||
either. Both now fail the build and say why. Large archives were affected differently: a build
|
||||
taking longer than a minute was killed by the queue worker and the download simply spun forever,
|
||||
waiting for something that had already stopped. Builds now get the time they need, a build the
|
||||
queue gives up on reports itself as failed, and the partial files an interrupted build leaves
|
||||
behind are cleaned up rather than sitting on disk unnoticed.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1687](https://github.com/projectsend/projectsend/pull/1687))
|
||||
|
||||
- **Comment moderation now stops at the same boundary everything else does.** A staff role can be
|
||||
limited to its own assigned clients, and everything in the library respects that — listings,
|
||||
downloads, file details, and the moderation queue itself. Deleting or approving a single comment
|
||||
did not. Someone with a client-limited role who also held the comment moderation permission could
|
||||
remove any comment on the installation by its id, including conversations belonging to clients
|
||||
they were not assigned to, on files they could not open. No role that ships with ProjectSend
|
||||
combines those two things, so this needed a custom role to reach; if you have built one, it is
|
||||
worth updating for. The boundary now lives in the rule itself rather than being restated by each
|
||||
screen, which is how the gap opened in the first place.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1698](https://github.com/projectsend/projectsend/pull/1698))
|
||||
|
||||
- **The dashboard's recent activity now respects a limited role's boundary.** A staff role can be
|
||||
limited to its own assigned clients, and the activity page has always honoured that — showing only
|
||||
entries about files, folders and clients in that person's scope. The dashboard's Recent activity
|
||||
widget did not: it listed the eight most recent entries from the whole installation, file names
|
||||
and all, to someone who would be refused the files themselves. The Client Manager role ships with
|
||||
the permission this widget needs, so any installation using it was affected. Both screens now
|
||||
answer the same way. Nothing changes for an administrator or any unrestricted role.
|
||||
|
||||
- **Cached previews are no longer mistaken for stray files.** The tool that finds files sitting on
|
||||
disk with no database record knew to ignore cached thumbnails, but had never been told about the
|
||||
larger previews added alongside them. So every cached preview was listed as an unclaimed file:
|
||||
offered for import on the orphan-files screen, and deleted by the daily cleanup once past its
|
||||
grace period. Importing one also created a file entry pointing at a path the preview cache owns,
|
||||
which then vanished the next time that cache was cleared. The list of what counts as a generated
|
||||
copy is now derived from the copies themselves, so a new kind cannot be left off it again.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1683](https://github.com/projectsend/projectsend/pull/1683))
|
||||
|
||||
- **Group membership now respects a limited role's boundary.** A staff role can be limited to its
|
||||
own assigned clients. Adding somebody to a group, or taking them out, checked only that the person
|
||||
held the "edit groups" permission — not that the group was any of their business. Because joining a
|
||||
group hands the new member everything shared with it, someone with a limited role could put one of
|
||||
their own clients into any group on the installation and, through that client, reach files they
|
||||
had been refused a moment earlier. Approving or denying a membership request was the same write
|
||||
through a second door, and the requests screen listed every pending request by name and email,
|
||||
including clients outside the viewer's roster. All of it is now held to the same boundary the rest
|
||||
of the library uses, and the sidebar count agrees with the screen behind it. No role that ships
|
||||
with ProjectSend combines the two permissions this needed, so reaching it took a custom role.
|
||||
Nothing changes for an administrator or any unrestricted role.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1701](https://github.com/projectsend/projectsend/pull/1701))
|
||||
|
||||
- **Declining a group membership request now happens once.** Approving a request that had already
|
||||
been decided was refused; declining one was not, and declining is not a repeatable act. Each
|
||||
repeat re-dated the decision — which is what the client's waiting period before asking again
|
||||
counts from — so the same stale request, sent again, could keep somebody out of a group
|
||||
indefinitely without anyone deciding anything. It also wrote a second entry in the activity log
|
||||
and sent the client a second "your request was declined" email for one decision. The queue only
|
||||
ever lists requests still waiting, so nothing on screen offered this. Both actions now behave the
|
||||
same way.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1705](https://github.com/projectsend/projectsend/pull/1705))
|
||||
|
||||
- **The dashboard's expired-files list says whose files it is showing.** For a staff role limited to
|
||||
its own clients it lists that person's own uploads, since an expired file is already out of reach
|
||||
of the clients it was shared with. It now says so — "Your expired files", and a line explaining
|
||||
what is not in the list — rather than presenting a short list as though it were the whole picture.
|
||||
A warning about what is due to be deleted is worth nothing if it is quietly narrower than it looks.
|
||||
|
||||
- **A limited staff role no longer reaches every client record, or every file name on the
|
||||
dashboard.** Two more places where holding a permission was treated as holding a boundary. The
|
||||
clients screen listed every client on the installation by name and email, and a role limited to
|
||||
its own assigned clients could open, rename, or delete any of them — the same through the API.
|
||||
Separately, the dashboard's largest-files, expired-files and top-clients widgets named files and
|
||||
clients from across the whole installation, which mattered more because the Client Manager role
|
||||
that ships with ProjectSend holds the permission those widgets need. Both now use the same rule
|
||||
the rest of the library already did. Installation-wide totals stay installation-wide: a count
|
||||
carries no names. Nothing changes for an administrator or any unrestricted role.
|
||||
|
||||
- **Notification settings accept only the switches they offer.** Saving your notification
|
||||
preferences would store a row for any name a request happened to carry, including ones nothing in
|
||||
ProjectSend can send. Such a row was never read again and could not be seen or removed from the
|
||||
screen, so the table quietly collected entries nobody could reach. The form now checks what comes
|
||||
back against the same list it offered, so the two cannot drift apart. Nothing reachable from the
|
||||
screen changes — it only ever sends back switches it was given.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1689](https://github.com/projectsend/projectsend/pull/1689))
|
||||
|
||||
- **A two-factor recovery code is now spent exactly once.** Using a code removed it from your list
|
||||
by rewriting the whole list, so two sign-in attempts arriving at the same moment could each save
|
||||
their own copy and put back the code the other had just spent. Nobody could get in who was not
|
||||
already holding a valid code, but a code you had crossed off a printed sheet — or watched somebody
|
||||
type — could quietly start working again, which is the one thing recovery codes promise not to do.
|
||||
The code is now removed from the record as it stands at that moment, under a lock, so a second
|
||||
attempt cannot undo the first.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1704](https://github.com/projectsend/projectsend/pull/1704))
|
||||
|
||||
- **A file can no longer be filed into a folder that has been deleted.** Deleting a folder deletes
|
||||
everything inside it, so a file that lands in one afterwards sits somewhere that was already
|
||||
emptied — reachable by link and in search, but missing from the folder listing its uploader would
|
||||
look in. Uploading or moving a file into a deleted folder now says so instead, and picks up the
|
||||
case where a folder is deleted while a large upload is still transferring: the finished file lands
|
||||
at the top level rather than being thrown away, since the transfer had already happened. The
|
||||
message says the folder no longer exists rather than that the value was invalid.
|
||||
(found, diagnosed and fixed by [@denkfabrik-li](https://github.com/denkfabrik-li) in
|
||||
[#1703](https://github.com/projectsend/projectsend/pull/1703))
|
||||
|
||||
- **A limited staff role can no longer rename or delete a group it has no part in.** Group
|
||||
membership was already held to that boundary; the group itself was not, which was the sharper half
|
||||
— sharing a file with a group is how its members reach that file, so deleting the group takes the
|
||||
access away from every one of them, including clients outside the person's own list. A role
|
||||
limited to its own clients can still manage any group that shares nothing beyond what it can
|
||||
already see, so a group it created, or one holding its own clients, stays fully editable. Nothing
|
||||
changes for an administrator or any unrestricted role.
|
||||
- [#1627](https://github.com/projectsend/projectsend/issues/1627) — Errors while installing via Docker
|
||||
- [#1648](https://github.com/projectsend/projectsend/issues/1648) — A deleted account's email address can never be used again
|
||||
- [#1661](https://github.com/projectsend/projectsend/issues/1661) — Docker update instructions do not update ProjectSend when using official Compose setup
|
||||
- [#1662](https://github.com/projectsend/projectsend/issues/1662) — Preview files not available on v2.1.0
|
||||
- [#1663](https://github.com/projectsend/projectsend/issues/1663) — Dashboard 500s on shared hosting: container detection trips open_basedir
|
||||
- [#1664](https://github.com/projectsend/projectsend/issues/1664) — INSTALL.md: the nginx-in-front-of-Apache path needs the buffer advice too
|
||||
- [#1668](https://github.com/projectsend/projectsend/issues/1668) — INSTALL.md: X-Accel downloads fail when nginx and PHP-FPM run as different users
|
||||
- [#1672](https://github.com/projectsend/projectsend/issues/1672) — Projectsend 2 behind Traefik issues 419 when logging in or hitting an error?
|
||||
- [#1673](https://github.com/projectsend/projectsend/issues/1673) — Projectsend 2: Setting Widget Columns throws error
|
||||
- [#1675](https://github.com/projectsend/projectsend/issues/1675) — Success toast shows twice after create/delete redirects
|
||||
- [#1706](https://github.com/projectsend/projectsend/issues/1706) — V1 migration imports $2a$ bcrypt hashes that cause HTTP 500 on login
|
||||
|
||||
## 2.1.0 — 18 August 2026
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientFieldContext;
|
||||
use App\Modules\Clients\ClientPortalCustomFields;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\StaffAccounts;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
@@ -24,6 +25,7 @@ class ProfileController extends Controller
|
||||
public function __construct(
|
||||
private readonly ClientPortalCustomFields $customFields,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly StaffAccounts $accounts,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -104,6 +106,19 @@ class ProfileController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
// The rule every other door into this already asks: Staff update(),
|
||||
// guardDeletable(), and both role-conversion directions. This one
|
||||
// did not, and self-deletion is the one door where the account
|
||||
// being removed is certainly signed in — so the last active
|
||||
// administrator could take themselves out, leaving no live staff
|
||||
// row at all. EnsureSetupIsComplete then reopens first-run setup to
|
||||
// anybody who asks, which is the other half of this and is closed
|
||||
// below.
|
||||
$this->accounts->guardLastAdministrator(
|
||||
$user,
|
||||
removesAdmin: $this->accounts->isAdministratorRole($user->role_id),
|
||||
);
|
||||
|
||||
Auth::logout();
|
||||
|
||||
// Self-deletion: soft delete now, permanent GDPR erasure after
|
||||
|
||||
@@ -191,7 +191,11 @@ class ClientsController extends Controller
|
||||
$client->storage_quota_mb = $validated['storage_quota_mb'] ?? 0;
|
||||
}
|
||||
|
||||
// Approval, and so the moment the seat is spent — same rule the
|
||||
// web edit screen and approve() answer to. Inside the branch, so a
|
||||
// capped installation can still edit a client it already holds.
|
||||
if (($validated['active'] ?? false) && $client->account_requested) {
|
||||
$this->seats->guardClient('active');
|
||||
$client->account_requested = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,11 +99,23 @@ class ClientsController extends Controller
|
||||
'pagination' => Pagination::meta($clients),
|
||||
'filters' => $filters,
|
||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
||||
// Null on a self-hosted install: no limit, nothing to say.
|
||||
'seats' => $this->seats->clientState(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
public function create(): RedirectResponse|Response
|
||||
{
|
||||
// The same courtesy UsersController::create() does: a full
|
||||
// installation is an ordinary state on a managed plan, so say so
|
||||
// before somebody fills in a form that cannot be submitted. The
|
||||
// guard in store() is still the rule; this is only the door.
|
||||
$seats = $this->seats->clientState();
|
||||
|
||||
if ($seats !== null && $seats['full']) {
|
||||
return redirect()->route('clients.index')->with('error', $seats['message']);
|
||||
}
|
||||
|
||||
return Inertia::render('clients/create', [
|
||||
'custom_fields' => $this->customFieldDefinitions(),
|
||||
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
@@ -234,8 +246,13 @@ class ClientsController extends Controller
|
||||
]);
|
||||
|
||||
// Activating a pending account through the edit screen counts as
|
||||
// approval and clears the request flag.
|
||||
// approval and clears the request flag — which is the moment a
|
||||
// seat is spent, so the cap is asked here for the same reason
|
||||
// AccountRequestsController::approve() asks it one screen over.
|
||||
// Inside the branch, not above it: an installation at its cap must
|
||||
// still be able to rename a client it already has.
|
||||
if ($client->account_requested && $validated['active']) {
|
||||
$this->seats->guardClient('active');
|
||||
$client->account_requested = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -220,10 +220,27 @@ class FileComments
|
||||
return null;
|
||||
}
|
||||
|
||||
// Asked of the column, not of the relation. client_context_id is
|
||||
// cascadeOnDelete, but a user is soft-deleted, so the cascade
|
||||
// never fires: the column goes on pointing at a row that is still
|
||||
// there while the relation resolves to null. Branching on the
|
||||
// relation therefore read "this is Alice's conversation" as "this
|
||||
// has no conversation" — and a null context on a Clients comment
|
||||
// is the branch every client on the file reads (see
|
||||
// VisibleCommentScope's opening rule). A private reply became a
|
||||
// circular, and canAssignClient below was skipped on the way.
|
||||
if ($replyTo->client_context_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$client = $replyTo->clientContext;
|
||||
|
||||
if ($client === null) {
|
||||
return null;
|
||||
// The column points at somebody, and that somebody is gone.
|
||||
// There is nobody to answer, and the one outcome that must
|
||||
// not follow from a filled column is the broadcast above, so
|
||||
// this refuses rather than falling through to it.
|
||||
throw new AuthorizationException('You cannot reply in this conversation.');
|
||||
}
|
||||
|
||||
if (! $this->library->canAssignClient($author, $client)) {
|
||||
|
||||
@@ -113,18 +113,32 @@ class FileComment extends Model
|
||||
* The name to show. Snapshotted for guests at write time; read live
|
||||
* for accounts so a rename is reflected everywhere at once.
|
||||
*
|
||||
* author_id cascades on delete, so a row that has one always has the
|
||||
* account behind it — there is no deleted-author case to snapshot
|
||||
* against, unlike the activity log's actor_name.
|
||||
* A deleted account is still read. author_id cascades on delete, but
|
||||
* a user is soft-deleted and the cascade never fires, so the row
|
||||
* behind a deleted commenter is still there — and reading it through
|
||||
* the plain relation returned null, which sent a named client's
|
||||
* comment out as "Anonymous". That is what a guest comment looks
|
||||
* like, and a guest comment is governed by different rules; the two
|
||||
* must not be able to look the same. Whether the author is a guest is
|
||||
* decided by author_id alone, which is also what isFromGuest() asks.
|
||||
*/
|
||||
public function authorName(): string
|
||||
{
|
||||
if ($this->author_id === null) {
|
||||
return $this->guest_name ?? (string) __('Anonymous');
|
||||
}
|
||||
|
||||
$author = $this->author;
|
||||
|
||||
if ($author !== null) {
|
||||
return $author->name;
|
||||
}
|
||||
|
||||
return $this->guest_name ?? (string) __('Anonymous');
|
||||
// Trashed: the row is still there, the relation simply will not
|
||||
// hand it over. Nothing comes back only once the grace-period
|
||||
// erasure has removed the row for real.
|
||||
$name = $this->author()->withTrashed()->value('name');
|
||||
|
||||
return is_string($name) ? $name : (string) __('Anonymous');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,10 +405,32 @@ class FoldersController extends Controller
|
||||
return back();
|
||||
}
|
||||
|
||||
public function destroy(Folder $folder): RedirectResponse
|
||||
public function destroy(Request $request, Folder $folder): RedirectResponse
|
||||
{
|
||||
Gate::authorize('delete', $folder);
|
||||
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// Deleting a folder cascades to every file in its subtree, and a
|
||||
// File's `deleted` hook removes the bytes from disk — there is no
|
||||
// restore. Authorizing the folder is not authorizing its contents:
|
||||
// FilePolicy::delete asks for `delete_others_files` on somebody
|
||||
// else's upload, and for the library boundary on top of that, and
|
||||
// neither question is asked anywhere on this path.
|
||||
//
|
||||
// MyFoldersController::destroy already refuses for the client half
|
||||
// of the same cascade, in the same words. This is the staff half.
|
||||
$blocked = $this->undeletableFileCount($viewer, $folder);
|
||||
|
||||
if ($blocked > 0) {
|
||||
return back()->with('error', trans_choice(
|
||||
'This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.',
|
||||
$blocked,
|
||||
['count' => (string) $blocked],
|
||||
));
|
||||
}
|
||||
|
||||
$name = $folder->name;
|
||||
$parentId = $folder->parent_id;
|
||||
|
||||
@@ -419,6 +441,50 @@ class FoldersController extends Controller
|
||||
return redirect()->route('files.index', $parentId !== null ? ['folder' => $parentId] : [])->with('success', __('Folder deleted.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many files in this folder's subtree the viewer may not delete.
|
||||
*
|
||||
* Asked as one count rather than FilePolicy::delete per file: a folder
|
||||
* can hold thousands, Gate resolves a fresh policy for every check, and
|
||||
* a per-row policy check on a listing is the cost 0a8b609e went to
|
||||
* some trouble to remove. The two halves of FilePolicy::delete are
|
||||
* expressible in SQL — the permission half is constant for this
|
||||
* viewer, and the library half is the query StaffLibraryScope already
|
||||
* memoises per request.
|
||||
*
|
||||
* Somebody holding both delete permissions and no library scope can
|
||||
* delete anything in the subtree by construction, so they never pay for
|
||||
* the query at all.
|
||||
*/
|
||||
private function undeletableFileCount(User $viewer, Folder $folder): int
|
||||
{
|
||||
$mayDeleteOwn = $viewer->can('delete_files');
|
||||
$mayDeleteOthers = $viewer->can('delete_others_files');
|
||||
$scoped = $viewer->isClientScoped();
|
||||
|
||||
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return File::query()
|
||||
->whereIn('folder_id', $folder->subtreeFolderIds())
|
||||
->where(function (Builder $outer) use ($viewer, $mayDeleteOwn, $mayDeleteOthers, $scoped): void {
|
||||
if (! $mayDeleteOwn) {
|
||||
$outer->orWhere('uploaded_by', $viewer->id);
|
||||
}
|
||||
|
||||
if (! $mayDeleteOthers) {
|
||||
$outer->orWhere(fn (Builder $others): Builder => $others
|
||||
->whereNull('uploaded_by')->orWhere('uploaded_by', '!=', $viewer->id));
|
||||
}
|
||||
|
||||
if ($scoped) {
|
||||
$outer->orWhereNotIn('id', $this->scope->files($viewer)->select('id'));
|
||||
}
|
||||
})
|
||||
->count();
|
||||
}
|
||||
|
||||
private function resolveParent(?User $user, ?int $parentId): ?Folder
|
||||
{
|
||||
if ($user === null || $parentId === null) {
|
||||
|
||||
@@ -135,7 +135,14 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
// count, is what lets the download action log exactly what it
|
||||
// hands over instead of resolving the selection a second time
|
||||
// against a scope that may have moved since.
|
||||
$addedIds = [];
|
||||
//
|
||||
// Keyed by id rather than appended to a list, because it is
|
||||
// also what keeps a file out of the archive twice. The loose
|
||||
// selection cannot repeat itself — one whereIn on the primary
|
||||
// key — but a selected folder can hold a file that was also
|
||||
// named loosely, and the cap is 10000 sources, so the check
|
||||
// has to be a lookup rather than a scan.
|
||||
$added = [];
|
||||
|
||||
foreach ((clone $visible)->whereIn('id', $zipDownload->file_ids)->get() as $file) {
|
||||
// Re-checked here for the same reason visibility is: the
|
||||
@@ -150,11 +157,11 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
$entryName = $this->dedupeName($usedNames, $this->entrySegment($file->original_name));
|
||||
$zip->addFile($this->localPathFor($file, $tempFiles), $entryName);
|
||||
$totalSize += $file->size;
|
||||
$addedIds[] = $file->id;
|
||||
$added[$file->id] = true;
|
||||
}
|
||||
|
||||
foreach (Folder::query()->whereIn('id', $zipDownload->folder_ids)->get() as $folder) {
|
||||
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $addedIds);
|
||||
foreach ($this->outermostFolders($zipDownload->folder_ids) as $folder) {
|
||||
$totalSize += $this->addFolder($zip, $folder, $requester, $usedNames, $tempFiles, $visible, $skipped, $added);
|
||||
}
|
||||
|
||||
// Re-checked here, not only in ZipDownloadsController: the
|
||||
@@ -197,7 +204,7 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
@unlink($tempFile);
|
||||
}
|
||||
|
||||
if ($written !== true || $addedIds === []) {
|
||||
if ($written !== true || $added === []) {
|
||||
if ($written !== true) {
|
||||
// What the requester sees stays generic: a libzip
|
||||
// string means nothing to them and can name a server
|
||||
@@ -229,8 +236,8 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
'status' => ZipDownload::STATUS_READY,
|
||||
'path' => $relativePath,
|
||||
'total_size' => $totalSize,
|
||||
'file_count' => count($addedIds),
|
||||
'contained_file_ids' => $addedIds,
|
||||
'file_count' => count($added),
|
||||
'contained_file_ids' => array_keys($added),
|
||||
'skipped_files' => $skipped === [] ? null : $skipped,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
@@ -329,9 +336,9 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
* @param array<int, string> $tempFiles
|
||||
* @param Builder<File> $visible every file the requester may read
|
||||
* @param list<array{id: int, name: string}> $skipped
|
||||
* @param list<int> $addedIds every file really written into the archive
|
||||
* @param array<int, true> $added every file really written into the archive, keyed by id
|
||||
*/
|
||||
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$addedIds): int
|
||||
private function addFolder(ZipArchive $zip, Folder $folder, User $requester, array &$usedNames, array &$tempFiles, Builder $visible, array &$skipped, array &$added): int
|
||||
{
|
||||
$allowance = app(DownloadAllowance::class);
|
||||
|
||||
@@ -342,6 +349,15 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
$totalSize = 0;
|
||||
|
||||
foreach ((clone $visible)->whereIn('folder_id', $subtreeIds)->get() as $file) {
|
||||
// Already in the archive under another part of the selection —
|
||||
// named loosely, or inside a folder selected before this one.
|
||||
// Skipped rather than added again: a second entry is a second
|
||||
// copy of the same bytes, and delivery charges one download
|
||||
// however many copies went out.
|
||||
if (isset($added[$file->id])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Holding the folder does not entitle the requester to a file
|
||||
// inside it whose own allowance is spent — same reason the
|
||||
// per-file visibility filter is re-derived rather than
|
||||
@@ -357,12 +373,38 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
$entryPath = $this->dedupeName($usedNames, $entryPath);
|
||||
$zip->addFile($this->localPathFor($file, $tempFiles), $entryPath);
|
||||
$totalSize += $file->size;
|
||||
$addedIds[] = $file->id;
|
||||
$added[$file->id] = true;
|
||||
}
|
||||
|
||||
return $totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected folders with the redundant ones dropped: one that sits
|
||||
* inside another selected folder is already covered by it.
|
||||
*
|
||||
* Zipping both would reach the same file twice, and which of the two
|
||||
* paths the surviving entry ended up under would be decided by
|
||||
* whatever order the database returned the rows in. Keeping the outer
|
||||
* folder keeps the fuller path — Reports/Q1/report.pdf rather than
|
||||
* Q1/report.pdf — and gives the same archive on every run.
|
||||
*
|
||||
* @param list<int> $folderIds
|
||||
* @return Collection<int, Folder>
|
||||
*/
|
||||
private function outermostFolders(array $folderIds): Collection
|
||||
{
|
||||
/** @var Collection<int, Folder> $folders */
|
||||
$folders = Folder::query()->whereIn('id', $folderIds)->orderBy('id')->get();
|
||||
|
||||
return $folders
|
||||
->reject(fn (Folder $folder): bool => $folders->contains(
|
||||
fn (Folder $other): bool => $other->id !== $folder->id
|
||||
&& str_starts_with($folder->path, $other->subtreePathPrefix()),
|
||||
))
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Folder> $foldersById Every folder in the root's subtree, keyed by id.
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -56,9 +57,20 @@ class GroupsController extends Controller
|
||||
return GroupResource::collection($this->polling->paginate($request, $query, 'groups'));
|
||||
}
|
||||
|
||||
public function show(Group $group): GroupResource
|
||||
public function show(Request $request, Group $group): GroupResource
|
||||
{
|
||||
return new GroupResource($group->loadCount('members')->load('members'));
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// The web edit screen's boundary, on its API twin: this is the read
|
||||
// half of the group that update() and destroy() below already refuse
|
||||
// to touch, and it hands back the membership with addresses.
|
||||
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||
|
||||
return new GroupResource($group->loadCount('members')->load([
|
||||
'members' => fn (BelongsToMany $members) => $members
|
||||
->whereIn('users.id', $this->scope->clients($viewer)->select('id')),
|
||||
]));
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Support\Pagination;
|
||||
use App\Support\PublicUrl;
|
||||
use App\Support\Rules;
|
||||
@@ -102,8 +101,18 @@ class GroupsController extends Controller
|
||||
return $target->with('success', __('Group created.'));
|
||||
}
|
||||
|
||||
public function edit(Group $group): Response
|
||||
public function edit(Request $request, Group $group): Response
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// The same reach question update() and destroy() ask, asked one
|
||||
// step earlier. Without it this was the one group route holding no
|
||||
// library boundary at all: a scoped staff member could open a group
|
||||
// whose contents they cannot see, read its membership off the
|
||||
// screen, and only be refused on save.
|
||||
abort_unless($this->scope->allowsGroupChange($viewer, $group), 404);
|
||||
|
||||
return Inertia::render('groups/edit', [
|
||||
'group' => [
|
||||
'id' => $group->id,
|
||||
@@ -112,14 +121,24 @@ class GroupsController extends Controller
|
||||
'description' => $group->description,
|
||||
'public' => $group->public,
|
||||
],
|
||||
'members' => $group->members()->orderBy('name')->get()
|
||||
// Both lists narrow through StaffLibraryScope::clients(), which
|
||||
// is the listing half of the rule this screen's buttons are
|
||||
// already guarded with: a member outside the roster cannot be
|
||||
// removed here (allowsGroupMembership refuses it), and a client
|
||||
// outside it cannot be added. Naming them anyway, with their
|
||||
// address, was the same mistake the client list made before
|
||||
// that method existed. An unscoped viewer sees everything,
|
||||
// unchanged.
|
||||
'members' => $group->members()
|
||||
->whereIn('users.id', $this->scope->clients($viewer)->select('id'))
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (User $member): array => [
|
||||
'id' => $member->id,
|
||||
'name' => $member->name,
|
||||
'email' => $member->email,
|
||||
])->all(),
|
||||
'available_clients' => User::query()
|
||||
->where('type', UserType::Client)
|
||||
'available_clients' => $this->scope->clients($viewer)
|
||||
->whereNotIn('id', $group->members()->pluck('users.id'))
|
||||
->orderBy('name')
|
||||
->get()
|
||||
|
||||
@@ -13,9 +13,12 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
||||
* @mixin Group
|
||||
*
|
||||
* Members carry a name and an email, which is what the group edit screen
|
||||
* already shows to anyone holding `edit_groups`. They are attached only
|
||||
* when explicitly loaded, so a listing of groups does not become a bulk
|
||||
* export of every client's address.
|
||||
* shows the same viewer. That is a claim about the screen, so it holds
|
||||
* only for as long as the screen does: both narrow the list to the
|
||||
* clients the viewer may act on, and the controller loading this relation
|
||||
* is where that narrowing is applied. They are attached only when
|
||||
* explicitly loaded, so a listing of groups does not become a bulk export
|
||||
* of every client's address.
|
||||
*/
|
||||
class GroupResource extends JsonResource
|
||||
{
|
||||
|
||||
@@ -26,10 +26,12 @@ use Inertia\Response;
|
||||
/**
|
||||
* Moving an account between staff and clients.
|
||||
*
|
||||
* Community edition only, by the same route group as every other
|
||||
* staff-account screen — managed installations create staff accounts
|
||||
* outside the application, so a converter there would be a second,
|
||||
* unmanaged way to create one.
|
||||
* Both editions since 2.2.0, by the same route group as every other
|
||||
* staff-account screen: whoever may create a staff account may promote
|
||||
* one, and a managed installation limits that by seats rather than by
|
||||
* closing the screen — AccountConversion asks SeatAllowance on both
|
||||
* directions, because a promotion spends a staff seat and a demotion
|
||||
* spends a client one.
|
||||
*
|
||||
* The rules live in AccountConversion, which calls StaffAccounts for the
|
||||
* authority questions. This controller is the request shape and the
|
||||
|
||||
@@ -26,12 +26,18 @@ use Illuminate\Validation\ValidationException;
|
||||
/**
|
||||
* Staff accounts over the API — the API twin of the /users screens.
|
||||
*
|
||||
* **Community only.** Every route is behind `capability:users.manage`, so
|
||||
* a cloud install answers 403 `capability_unavailable`: managed
|
||||
* installations create staff accounts outside the application, and an API
|
||||
* that could mint them there would be a second, unmanaged door into the
|
||||
* same thing.
|
||||
* The routes are still registered in every edition so the committed
|
||||
* Both editions since 2.2.0. Every route is behind
|
||||
* `capability:users.manage`, which cloud installations now hold as well:
|
||||
* a platform sells staff seats and the tenant fills them, so an API that
|
||||
* creates one is the same door the screen is, not a second unmanaged one
|
||||
* (see Capability::UsersManage). How many it may create is
|
||||
* SeatAllowance's question, asked here through StaffAccounts, and an
|
||||
* installation at its limit answers 422 rather than 403.
|
||||
*
|
||||
* The capability stays in front of the routes rather than being dropped:
|
||||
* it is the seam an edition difference would have to travel through, and
|
||||
* an installation without it answers 403 `capability_unavailable`. The
|
||||
* routes are registered in every edition either way, so the committed
|
||||
* OpenAPI document is identical everywhere — the middleware refuses, the
|
||||
* route table does not lie.
|
||||
*
|
||||
@@ -176,15 +182,27 @@ class UsersController extends Controller
|
||||
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
|
||||
]);
|
||||
|
||||
// Read through Request::boolean() rather than off the validated
|
||||
// array, for the reason RolesController::guardScopeRemoval spells
|
||||
// out: the `boolean` rule accepts 0 and "0" as well as false but
|
||||
// does not cast, so a strict comparison lets through a value the
|
||||
// model's own `boolean` cast then stores as false anyway. The same
|
||||
// value goes to the guard and to the write.
|
||||
$deactivating = array_key_exists('active', $validated) && ! $request->boolean('active');
|
||||
|
||||
// The same refusal the web screen makes, and for the same reason:
|
||||
// locking yourself out is never what was meant.
|
||||
if ($user->is($actor) && ($validated['active'] ?? true) === false) {
|
||||
if ($user->is($actor) && $deactivating) {
|
||||
throw ValidationException::withMessages([
|
||||
'active' => __('You cannot deactivate your own account.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'active', 'password']));
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'email', 'password']));
|
||||
|
||||
if (array_key_exists('active', $validated)) {
|
||||
$attributes['active'] = $request->boolean('active');
|
||||
}
|
||||
|
||||
if (array_key_exists('role_id', $validated)) {
|
||||
$attributes['role_id'] = (int) $validated['role_id'];
|
||||
|
||||
@@ -97,8 +97,16 @@ class SetupController extends Controller
|
||||
return Inertia::render('setup-success');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trashed staff count, for the reason EnsureSetupIsComplete gives:
|
||||
* this asks whether the installation was ever set up, and store()
|
||||
* below is the door a stranger walks through if the answer is wrong.
|
||||
* 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.
|
||||
*/
|
||||
private function setupIsComplete(): bool
|
||||
{
|
||||
return User::query()->where('type', UserType::Staff)->exists();
|
||||
return User::query()->withTrashed()->where('type', UserType::Staff)->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\StaffAccounts;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Support\Pagination;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -26,9 +27,17 @@ use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
/**
|
||||
* Staff ("system users") management — community edition only; managed
|
||||
* installations create them outside the application. Clients are a different
|
||||
* population managed by the Clients module: they never appear here.
|
||||
* Staff ("system users") management. Clients are a different population
|
||||
* managed by the Clients module: they never appear here.
|
||||
*
|
||||
* Available on both editions since 2.2.0. A managed installation is sold a
|
||||
* number of seats and fills them itself — see Capability::UsersManage for
|
||||
* why capacity is the platform's and who fills it is the tenant's.
|
||||
*
|
||||
* That makes a full installation an ordinary state rather than an error,
|
||||
* so `index()` reports the seat position and `create()` refuses to open a
|
||||
* form nothing can be submitted through. SeatAllowance::guardStaff() still
|
||||
* runs in `store()`: this is the courtesy, that is the rule.
|
||||
*/
|
||||
class UsersController extends Controller
|
||||
{
|
||||
@@ -37,6 +46,7 @@ class UsersController extends Controller
|
||||
private readonly AccountContentDeletion $accountDeletion,
|
||||
private readonly ApiTokens $apiTokens,
|
||||
private readonly StaffAccounts $accounts,
|
||||
private readonly SeatAllowance $seats,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -99,11 +109,24 @@ class UsersController extends Controller
|
||||
'roles' => Role::query()->orderBy('name')->get(['id', 'name'])
|
||||
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
|
||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
||||
// Null on a self-hosted install: no limit, nothing to say.
|
||||
'seats' => $this->seats->staffState(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
public function create(): RedirectResponse|Response
|
||||
{
|
||||
// Turned away here rather than on submit. Somebody reaching this
|
||||
// by link or bookmark used to fill in a name, an email and a
|
||||
// password they had to invent, and learn the installation was full
|
||||
// from a validation error under the email field — which reads as a
|
||||
// fault with the address rather than a fact about the plan.
|
||||
$seats = $this->seats->staffState();
|
||||
|
||||
if ($seats !== null && $seats['full']) {
|
||||
return redirect()->route('users.index')->with('error', $seats['message']);
|
||||
}
|
||||
|
||||
return Inertia::render('users/create', [
|
||||
'roles' => $this->roleOptions(),
|
||||
'clients' => $this->clientOptions(),
|
||||
|
||||
@@ -40,12 +40,17 @@ class EnforceTwoFactor
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// password.confirm is on this list because the two-factor mutation
|
||||
// password.confirm* is on this list because the two-factor mutation
|
||||
// routes now require it: without the exemption, enrolling would
|
||||
// redirect to the confirm-password screen, which this middleware
|
||||
// would redirect straight back to two-factor.show — a loop that
|
||||
// locks the user out of the only exit.
|
||||
if ($request->routeIs('two-factor.*', 'password.confirm', 'logout', 'locale.update')) {
|
||||
//
|
||||
// The pattern covers both halves of that screen. Naming only the
|
||||
// GET left the form rendering and its submission redirected away,
|
||||
// so the password was never confirmed and the loop stayed shut
|
||||
// one step further along than before.
|
||||
if ($request->routeIs('two-factor.*', 'password.confirm*', 'logout', 'locale.update')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,16 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* sent to the first-run setup screen. The database is the only source of
|
||||
* truth — no install flags. Client accounts do not count: setup is about
|
||||
* having an administrator.
|
||||
*
|
||||
* Trashed staff count. "Has this installation been set up" is not the
|
||||
* same question as "does it have a working administrator right now", and
|
||||
* only the first one belongs here: a soft-deleted staff row is still
|
||||
* evidence that setup happened, and an installation that has lost its
|
||||
* last administrator needs a recovery path, not a stranger filling in
|
||||
* the first-run form. Deleting a staff account is guarded against
|
||||
* reaching zero (StaffAccounts::guardLastAdministrator), so this is the
|
||||
* second lock rather than the first — but the first one is asked at five
|
||||
* separate doors, and this one is asked once.
|
||||
*/
|
||||
class EnsureSetupIsComplete
|
||||
{
|
||||
@@ -25,7 +35,7 @@ class EnsureSetupIsComplete
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (User::query()->where('type', UserType::Staff)->exists()) {
|
||||
if (User::query()->withTrashed()->where('type', UserType::Staff)->exists()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,10 +73,14 @@ enum Capability: string
|
||||
// package is installed, not a flag an installation can set. Present
|
||||
// in this enum even so, because a package cannot extend a closed one
|
||||
// — core has to publish the key before anything can gate on it.
|
||||
// Cloud-only — staff seats on a managed instance belong to the
|
||||
// platform that sold them rather than to the instance, so the tenant's
|
||||
// own /users screens stay closed (see UsersManage above) and a control
|
||||
// plane creates, deactivates and password-resets them from outside.
|
||||
// Cloud-only — marks an installation that a platform provisioned and
|
||||
// looks after, for the screens that have to know the difference.
|
||||
//
|
||||
// It does not close the tenant's own /users screens. It used to say
|
||||
// so, and that stopped being true when UsersManage opened on both
|
||||
// editions: a platform sells the seats, the tenant decides who sits
|
||||
// in them. Capacity is the platform's, and it arrives as
|
||||
// PROJECTSEND_PLATFORM_MAX_STAFF_USERS rather than as a shut door.
|
||||
//
|
||||
// The seat *number* deliberately does not live here. There are no
|
||||
// billing or plan tiers in this application to key off — the same
|
||||
|
||||
@@ -4,9 +4,23 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Installation\Console;
|
||||
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Migrations\Migrator;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* What this installation is, as a fact rather than a screen.
|
||||
@@ -33,6 +47,53 @@ use Illuminate\Console\Command;
|
||||
* an inactive account, or a soft-deleted one — and the divergence looks
|
||||
* like a billing fault rather than a counting one. So there is one
|
||||
* definition and this reads it.
|
||||
*
|
||||
* ### Is anybody there
|
||||
*
|
||||
* `activity.last_staff_login_at` answers the one question a platform
|
||||
* cannot answer from outside: whether a human still uses this
|
||||
* installation. It is a timestamp and nothing else — no name, no address,
|
||||
* no session. Only interactive sign-ins reach it, because that is all
|
||||
* Laravel's Login event fires for: an integration polling the API every
|
||||
* hour must not make a dormant installation look busy.
|
||||
*
|
||||
* Derived from the activity log rather than denormalised onto `users`. A
|
||||
* column would need a migration, a listener change and a backfill to save
|
||||
* one indexed MAX() over a table that is small on exactly the
|
||||
* installations anybody asks this about. The log is never pruned, and
|
||||
* erasure anonymises entries rather than deleting them (`actor_type`
|
||||
* survives on purpose — see AccountEraser), so the answer does not change
|
||||
* when the person who gave it is forgotten.
|
||||
*
|
||||
* ### Storage is the application's number, not the disk's
|
||||
*
|
||||
* `storage.bytes` is what this installation holds, summed from the rows
|
||||
* that record it. Measuring the directory instead was correct until
|
||||
* external storage went live, and silently stopped being: an upload that
|
||||
* resolves to a bucket leaves nothing on the volume to measure, so a
|
||||
* figure taken from the filesystem freezes while the account keeps
|
||||
* filling. `by_disk` is the same sum split by where the bytes went, which
|
||||
* is the only way to see what is still sitting on local disk from before
|
||||
* a cutover.
|
||||
*
|
||||
* Trashed files are excluded because they hold no bytes: File's `deleted`
|
||||
* hook removes them, so a soft-deleted row is a record of something that
|
||||
* is gone rather than something still costing anything.
|
||||
*
|
||||
* ### Health is what a container cannot show from outside
|
||||
*
|
||||
* A tenant's queue worker dying is invisible to anything watching the
|
||||
* container: it is still up, and zips quietly stop building while mail
|
||||
* stops going out. Same for migrations that failed after a deploy — the
|
||||
* application answers every request and is a schema behind. Neither is a
|
||||
* secret; both are already visible to anyone who can open the database,
|
||||
* which is anyone who can run this command.
|
||||
*
|
||||
* ### What core cannot answer
|
||||
*
|
||||
* `modules` is filled by whatever packages are installed, through
|
||||
* ResolvingInstallationStatus. A platform that provisioned a bucket knows
|
||||
* what it asked for; only the installation knows what loaded.
|
||||
*/
|
||||
class StatusCommand extends Command
|
||||
{
|
||||
@@ -40,7 +101,7 @@ class StatusCommand extends Command
|
||||
|
||||
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
|
||||
|
||||
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats): int
|
||||
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats, Settings $settings): int
|
||||
{
|
||||
$status = [
|
||||
'version' => (string) config('projectsend.version'),
|
||||
@@ -60,6 +121,31 @@ class StatusCommand extends Command
|
||||
'limit' => $seats->clientLimit(),
|
||||
],
|
||||
],
|
||||
'activity' => [
|
||||
// Null means "no staff account has ever signed in here",
|
||||
// and is emitted rather than left out for the same reason
|
||||
// an unlimited seat count is: a watcher has to be able to
|
||||
// tell that apart from "we got no answer". Collapsing the
|
||||
// two is how a broken probe reads as a dormant fleet.
|
||||
'last_staff_login_at' => $this->lastStaffLoginAt(),
|
||||
],
|
||||
'storage' => $this->storage(),
|
||||
'health' => $this->health(),
|
||||
'settings' => [
|
||||
// Echoed back rather than assumed: an operator writes the
|
||||
// environment variable, and this is the installation
|
||||
// saying what it actually applied. Read the way
|
||||
// EnforceTwoFactor reads it, down to what an unreadable
|
||||
// value falls back to -- reporting a stricter answer than
|
||||
// the middleware enforces would be worse than reporting
|
||||
// none at all.
|
||||
'two_factor_enforcement' => $this->enforcement($settings),
|
||||
],
|
||||
// Cast so an installation with no packages emits {} rather
|
||||
// than [] -- an empty PHP array encodes as a list, and a
|
||||
// reader unmarshalling a map breaks on the day it happens to
|
||||
// be empty rather than on the day it is written.
|
||||
'modules' => (object) $this->modules(),
|
||||
];
|
||||
|
||||
if ($this->option('json')) {
|
||||
@@ -72,10 +158,145 @@ class StatusCommand extends Command
|
||||
$this->line('Capabilities: '.(implode(', ', $status['capabilities']) ?: 'none'));
|
||||
$this->line('Staff seats: '.$this->seatLine($status['seats']['staff']));
|
||||
$this->line('Clients: '.$this->seatLine($status['seats']['clients']));
|
||||
$this->line('Last staff login: '.($status['activity']['last_staff_login_at'] ?? 'never'));
|
||||
$this->line('Storage: '.number_format($status['storage']['bytes']).' bytes in '.$status['storage']['files'].' files');
|
||||
$this->line('Health: '.$status['health']['pending_migrations'].' migrations pending, '
|
||||
.$status['health']['failed_jobs'].' failed jobs, '
|
||||
.array_sum(array_filter($status['health']['queues'], 'is_int')).' queued');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function enforcement(Settings $settings): string
|
||||
{
|
||||
$value = $settings->get(Setting::TwoFactorEnforcement);
|
||||
|
||||
$enforcement = (is_string($value) ? TwoFactorEnforcement::tryFrom($value) : null)
|
||||
?? TwoFactorEnforcement::None;
|
||||
|
||||
return $enforcement->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this installation holds, from the rows that record it.
|
||||
*
|
||||
* @return array{bytes: int, files: int, by_disk: object}
|
||||
*/
|
||||
private function storage(): array
|
||||
{
|
||||
$perDisk = File::query()
|
||||
->groupBy('disk')
|
||||
->selectRaw('disk, sum(size) as bytes, count(*) as files')
|
||||
->get();
|
||||
|
||||
return [
|
||||
'bytes' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('bytes')),
|
||||
'files' => (int) $perDisk->sum(fn (File $row): int => (int) $row->getAttribute('files')),
|
||||
// Keyed by disk name rather than a list, because the reader
|
||||
// wants one of them by name — "how much is still local" — and
|
||||
// not to walk a list looking for it.
|
||||
// Same reason as `modules`: an installation holding no files
|
||||
// at all must still answer with a map.
|
||||
'by_disk' => (object) $perDisk
|
||||
->mapWithKeys(fn (File $row): array => [
|
||||
(string) $row->getAttribute('disk') => [
|
||||
'bytes' => (int) $row->getAttribute('bytes'),
|
||||
'files' => (int) $row->getAttribute('files'),
|
||||
],
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{pending_migrations: int, failed_jobs: int, queues: array<string, int|null>}
|
||||
*/
|
||||
private function health(): array
|
||||
{
|
||||
return [
|
||||
'pending_migrations' => $this->pendingMigrations(),
|
||||
'failed_jobs' => $this->failedJobs(),
|
||||
// The two this application actually runs workers for. A depth
|
||||
// is not a fault on its own -- a busy installation has one --
|
||||
// but a depth that only ever grows is a worker that died, and
|
||||
// nothing outside the container can see the difference.
|
||||
'queues' => [
|
||||
'default' => $this->queueDepth('default'),
|
||||
'zips' => $this->queueDepth('zips'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function pendingMigrations(): int
|
||||
{
|
||||
/** @var Migrator $migrator */
|
||||
$migrator = app('migrator');
|
||||
|
||||
// Every path, not just database/migrations: a package registers
|
||||
// its own, and a package migration left unrun is exactly the kind
|
||||
// of half-deploy this is here to report.
|
||||
$files = $migrator->getMigrationFiles(array_merge([database_path('migrations')], $migrator->paths()));
|
||||
|
||||
return count(array_diff(array_keys($files), $migrator->getRepository()->getRan()));
|
||||
}
|
||||
|
||||
private function failedJobs(): int
|
||||
{
|
||||
$table = config('queue.failed.table');
|
||||
|
||||
if (! is_string($table) || $table === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DB::table($table)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Null rather than a crash when the queue cannot be reached, and null
|
||||
* rather than zero: an unreachable Redis is not an empty queue, and a
|
||||
* reader watching for a worker that died would read the second as
|
||||
* everything being fine.
|
||||
*
|
||||
* This command is a probe, and a probe that dies on one unreachable
|
||||
* dependency tells the reader nothing about the facts it could still
|
||||
* have answered.
|
||||
*/
|
||||
private function queueDepth(string $queue): ?int
|
||||
{
|
||||
try {
|
||||
return Queue::size($queue);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|int|bool|null>
|
||||
*/
|
||||
private function modules(): array
|
||||
{
|
||||
$event = new ResolvingInstallationStatus;
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
return $event->facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent interactive staff sign-in, or null if there has
|
||||
* never been one.
|
||||
*/
|
||||
private function lastStaffLoginAt(): ?string
|
||||
{
|
||||
$latest = ActivityLog::query()
|
||||
->where('action', Action::Login->value)
|
||||
->where('actor_type', UserType::Staff->value)
|
||||
->max('created_at');
|
||||
|
||||
// `action` and `actor_type` carry an index each, so this narrows
|
||||
// on one of them rather than reading the log.
|
||||
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{used: int, limit: int|null} $seat
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Installation\Events;
|
||||
|
||||
/**
|
||||
* "What else is worth knowing about this installation?" — asked once,
|
||||
* by `projectsend:status`, of whatever packages happen to be installed.
|
||||
*
|
||||
* Core cannot answer for them. A managed installation's storage backend
|
||||
* and the version of the package providing it live in
|
||||
* projectsend/cloud-modules, which this repository is public and must
|
||||
* not reference; a control plane still has to be able to observe them,
|
||||
* and observing is exactly what that command is for.
|
||||
*
|
||||
* The distinction this exists to preserve: a platform writing eight
|
||||
* environment variables knows what it *asked for*. Only the installation
|
||||
* knows what actually loaded. Those came apart once — a bucket was
|
||||
* provisioned and a token minted while the container ignored both,
|
||||
* because its image predated the module that reads them, and the
|
||||
* configuration sitting beside the files looked perfectly correct.
|
||||
*
|
||||
* Listened to by *string* class name from a package, same as every
|
||||
* other hook here — see docs/extension-points-architecture.md.
|
||||
*/
|
||||
final class ResolvingInstallationStatus
|
||||
{
|
||||
/**
|
||||
* What listeners have reported, keyed by name.
|
||||
*
|
||||
* Scalars and null only: this is serialised to JSON for a reader
|
||||
* that is not this application, and a shape it has to walk is a
|
||||
* shape it has to be taught. Null is a real answer — "asked, and
|
||||
* the thing is not here" — and it must survive to the document
|
||||
* rather than being dropped, for the reason the whole file's null
|
||||
* handling exists: absent and "nothing to report" are different
|
||||
* facts, and a reader that cannot tell them apart guesses.
|
||||
*
|
||||
* @var array<string, string|int|bool|null>
|
||||
*/
|
||||
public array $facts = [];
|
||||
|
||||
public function report(string $key, string|int|bool|null $value): void
|
||||
{
|
||||
$this->facts[$key] = $value;
|
||||
}
|
||||
}
|
||||
@@ -126,8 +126,11 @@ class QuickStart
|
||||
];
|
||||
}
|
||||
|
||||
// Community only, and the example the brief named: a managed
|
||||
// installation has no staff accounts of its own to hand out.
|
||||
// Both editions since 2.2.0. A managed installation was once the
|
||||
// example of a site with no staff accounts of its own to hand out;
|
||||
// it is sold seats and fills them itself now, so this step belongs
|
||||
// on its list too. The capability stays in the condition as the
|
||||
// seam an edition difference would travel through.
|
||||
if ($this->permissions->allows($user, Permission::CreateUsers)
|
||||
&& $this->capabilities->has(Capability::UsersManage)) {
|
||||
$items[] = [
|
||||
|
||||
@@ -25,6 +25,12 @@ use Illuminate\Validation\ValidationException;
|
||||
* like a billing fault rather than a counting one. So `staffUsed()` and
|
||||
* `clientUsed()` are public and are what `guard*()` reads.
|
||||
*
|
||||
* That second consumer stopped being hypothetical on 2026-08-27: the
|
||||
* hosted fleet console shows these numbers per tenant, read from
|
||||
* `projectsend:status --json`. So the rules below are load-bearing on a
|
||||
* screen support staff read, and changing one changes what they are told
|
||||
* before it changes what a customer hits.
|
||||
*
|
||||
* ### What counts
|
||||
*
|
||||
* A soft-deleted account does not. Its address stays reserved until
|
||||
@@ -84,6 +90,27 @@ class SeatAllowance
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff seat position, for a screen rather than a guard.
|
||||
*
|
||||
* Null on a self-hosted install: there is no limit, so there is
|
||||
* nothing for a screen to say about one.
|
||||
*
|
||||
* @return array{limit: int, used: int, full: bool, message: string|null}|null
|
||||
*/
|
||||
public function staffState(): ?array
|
||||
{
|
||||
return $this->state($this->staffLimit(), $this->staffUsed(), fn (): string => $this->staffFullMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{limit: int, used: int, full: bool, message: string|null}|null
|
||||
*/
|
||||
public function clientState(): ?array
|
||||
{
|
||||
return $this->state($this->clientLimit(), $this->clientUsed(), fn (): string => $this->clientFullMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException when one more staff account would exceed
|
||||
* what this installation may hold.
|
||||
@@ -96,11 +123,7 @@ class SeatAllowance
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
$field => __('This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.', [
|
||||
'count' => (string) $limit,
|
||||
]),
|
||||
]);
|
||||
throw ValidationException::withMessages([$field => $this->staffFullMessage()]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,13 +138,61 @@ class SeatAllowance
|
||||
return;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
$field => __('This installation is limited to :count clients. Remove one, or ask for a larger plan.', [
|
||||
'count' => (string) $limit,
|
||||
]),
|
||||
throw ValidationException::withMessages([$field => $this->clientFullMessage()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a screen is closed, in the words the guard would have used.
|
||||
*
|
||||
* A door that turns somebody away and a guard that refuses them are
|
||||
* the same rule met at two moments, so they say the same sentence. Two
|
||||
* wordings of one limit is how a person ends up believing there are
|
||||
* two limits.
|
||||
*/
|
||||
public function staffFullMessage(): string
|
||||
{
|
||||
return __('Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.', [
|
||||
'count' => (string) $this->staffLimit(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function clientFullMessage(): string
|
||||
{
|
||||
return __('Clients on this installation are limited to :count. Remove one, or ask for a larger plan.', [
|
||||
'count' => (string) $this->clientLimit(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* `full` is derived here rather than in each caller, and from the same
|
||||
* comparison `guard*()` refuses on. A screen that works out for itself
|
||||
* whether there is room can disagree with the guard about the edge --
|
||||
* `used > limit` after an operator lowers a limit is the obvious one --
|
||||
* and then the button is offered for a form that cannot be submitted,
|
||||
* which is the whole fault this is here to prevent.
|
||||
*
|
||||
* The message travels with the state so a screen never has to write
|
||||
* its own version of the refusal.
|
||||
*
|
||||
* @param callable(): string $message
|
||||
* @return array{limit: int, used: int, full: bool, message: string|null}|null
|
||||
*/
|
||||
private function state(?int $limit, int $used, callable $message): ?array
|
||||
{
|
||||
if ($limit === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$full = $used >= $limit;
|
||||
|
||||
return [
|
||||
'limit' => $limit,
|
||||
'used' => $used,
|
||||
'full' => $full,
|
||||
'message' => $full ? $message() : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Absent, empty and non-numeric all mean unlimited. An operator who
|
||||
* mistypes the variable gets the self-hosted behaviour rather than an
|
||||
|
||||
@@ -118,7 +118,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => '2.2.0',
|
||||
'version' => '2.2.1',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Ningú no està creant les descàrregues en zip",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Hi ha una descàrrega en zip esperant des del :date i cap procés en segon pla no l’ha recollida. Les descàrregues en zip ara fan servir una cua pròpia, així que un procés iniciat abans d’aquesta versió mira la cua equivocada — tota la resta, correu inclòs, continua funcionant.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "L’ordre del teu procés en segon pla necessita --queue=default,zips. A INSTALL.md hi ha el fitxer de servei complet.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Aquesta instal·lació està limitada a :count comptes de sistema. Elimina'n un o demana un pla més gran.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Aquesta instal·lació està limitada a :count clients. Elimina'n un o demana un pla més gran."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Els comptes de sistema d'aquesta instal·lació estan limitats a :count. Elimina'n un o demana un pla més gran.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Els clients d'aquesta instal·lació estan limitats a :count. Elimina'n un o demana un pla més gran.",
|
||||
":used of :limit staff seats used": "Usats :used de :limit comptes de sistema",
|
||||
":used of :limit client accounts used": "Usats :used de :limit comptes de client",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Aquesta carpeta no es pot eliminar: conté :count fitxer que no pots eliminar.|Aquesta carpeta no es pot eliminar: conté :count fitxers que no pots eliminar."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Nikdo nevytváří stažení ZIP",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Stažení ZIP čeká od :date a žádný proces na pozadí si ho nevzal. Stahování ZIP má teď vlastní frontu, takže proces spuštěný před touto verzí sleduje špatnou — všechno ostatní, včetně e-mailu, funguje dál.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Příkaz tvého procesu na pozadí potřebuje --queue=default,zips. Celý soubor služby najdeš v INSTALL.md.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Tato instalace je omezena na :count systémových účtů. Odeber jeden nebo si vyžádej větší tarif.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Tato instalace je omezena na :count klientů. Odeber jednoho nebo si vyžádej větší tarif."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Počet systémových účtů v této instalaci je omezen na :count. Odeber jeden nebo si vyžádej větší tarif.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Počet klientů v této instalaci je omezen na :count. Odeber jednoho nebo si vyžádej větší tarif.",
|
||||
":used of :limit staff seats used": "Využito :used z :limit systémových účtů",
|
||||
":used of :limit client accounts used": "Využito :used z :limit klientských účtů",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Tuto složku nelze smazat: obsahuje :count soubor, který nemůžeš smazat.|Tuto složku nelze smazat: obsahuje :count soubory, které nemůžeš smazat.|Tuto složku nelze smazat: obsahuje :count souborů, které nemůžeš smazat."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Niemand erstellt die ZIP-Downloads",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Ein ZIP-Download wartet seit dem :date und kein Hintergrundprozess hat ihn übernommen. ZIP-Downloads laufen jetzt über eine eigene Warteschlange, daher überwacht ein vor dieser Version gestarteter Prozess die falsche — alles andere, auch E-Mail, funktioniert weiter.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Der Befehl Ihres Hintergrundprozesses braucht --queue=default,zips. In INSTALL.md steht die vollständige Service-Datei.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Diese Installation ist auf :count Systemkonten begrenzt. Entfernen Sie eines oder fragen Sie nach einem größeren Tarif.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Diese Installation ist auf :count Kunden begrenzt. Entfernen Sie einen oder fragen Sie nach einem größeren Tarif."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Die Systemkonten dieser Installation sind auf :count begrenzt. Entfernen Sie eines oder fragen Sie nach einem größeren Tarif.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Die Kunden dieser Installation sind auf :count begrenzt. Entfernen Sie einen oder fragen Sie nach einem größeren Tarif.",
|
||||
":used of :limit staff seats used": "Belegt: :used von :limit Systemkonten",
|
||||
":used of :limit client accounts used": "Belegt: :used von :limit Kundenkonten",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Dieser Ordner kann nicht gelöscht werden: Er enthält :count Datei, die Sie nicht löschen dürfen.|Dieser Ordner kann nicht gelöscht werden: Er enthält :count Dateien, die Sie nicht löschen dürfen."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Nadie está creando las descargas en zip",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Hay una descarga en zip esperando desde el :date y ningún proceso en segundo plano la ha recogido. Las descargas en zip ahora usan su propia cola, así que un proceso iniciado antes de esta versión está mirando la cola equivocada — todo lo demás, incluido el correo, sigue funcionando.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "El comando de tu proceso en segundo plano necesita --queue=default,zips. En INSTALL.md está el archivo de servicio completo.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Esta instalación está limitada a :count cuentas de sistema. Elimina una o pide un plan más grande.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Esta instalación está limitada a :count clientes. Elimina uno o pide un plan más grande."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Las cuentas de sistema de esta instalación están limitadas a :count. Elimina una o pide un plan más grande.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Los clientes de esta instalación están limitados a :count. Elimina uno o pide un plan más grande.",
|
||||
":used of :limit staff seats used": "Usadas :used de :limit cuentas de sistema",
|
||||
":used of :limit client accounts used": "Usadas :used de :limit cuentas de cliente",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Esta carpeta no se puede eliminar: contiene :count archivo que no puedes eliminar.|Esta carpeta no se puede eliminar: contiene :count archivos que no puedes eliminar."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Personne ne construit les téléchargements ZIP",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Un téléchargement ZIP attend depuis le :date et aucun processus d’arrière-plan ne l’a pris en charge. Les téléchargements ZIP passent désormais par leur propre file d’attente : un processus démarré avant cette version surveille donc la mauvaise — tout le reste, e-mail compris, continue de fonctionner.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "La commande de votre processus d’arrière-plan doit contenir --queue=default,zips. INSTALL.md donne le fichier de service complet.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Cette installation est limitée à :count comptes système. Supprimez-en un, ou demandez une formule plus large.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Cette installation est limitée à :count clients. Supprimez-en un, ou demandez une formule plus large."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Les comptes système de cette installation sont limités à :count. Supprimez-en un, ou demandez une formule plus large.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Les clients de cette installation sont limités à :count. Supprimez-en un, ou demandez une formule plus large.",
|
||||
":used of :limit staff seats used": "Utilisés : :used sur :limit comptes système",
|
||||
":used of :limit client accounts used": "Utilisés : :used sur :limit comptes client",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Ce dossier ne peut pas être supprimé : il contient :count fichier que vous n'avez pas le droit de supprimer.|Ce dossier ne peut pas être supprimé : il contient :count fichiers que vous n'avez pas le droit de supprimer."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Tidak ada yang membuat unduhan ZIP",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Sebuah unduhan ZIP sudah menunggu sejak :date dan tidak ada proses latar yang mengambilnya. Pembuatan ZIP kini berjalan di antrean sendiri, jadi proses yang dijalankan sebelum versi ini mengawasi antrean yang salah — selebihnya, termasuk email, tetap berjalan.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Perintah proses latarmu perlu --queue=default,zips. Berkas layanan lengkapnya ada di INSTALL.md.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Instalasi ini dibatasi :count akun sistem. Hapus satu, atau minta paket yang lebih besar.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Instalasi ini dibatasi :count klien. Hapus satu, atau minta paket yang lebih besar."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Akun sistem pada instalasi ini dibatasi hingga :count. Hapus satu, atau minta paket yang lebih besar.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Klien pada instalasi ini dibatasi hingga :count. Hapus satu, atau minta paket yang lebih besar.",
|
||||
":used of :limit staff seats used": ":used dari :limit akun sistem terpakai",
|
||||
":used of :limit client accounts used": ":used dari :limit akun klien terpakai",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Folder ini tidak dapat dihapus: berisi :count berkas yang tidak boleh kamu hapus.|Folder ini tidak dapat dihapus: berisi :count berkas yang tidak boleh kamu hapus."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Nessuno sta creando i download ZIP",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Un download ZIP è in attesa dal :date e nessun processo in background lo ha preso in carico. I download ZIP ora usano una coda propria, quindi un processo avviato prima di questa versione sta guardando la coda sbagliata — tutto il resto, email compresa, continua a funzionare.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Il comando del tuo processo in background ha bisogno di --queue=default,zips. In INSTALL.md c’è il file di servizio completo.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Questa installazione è limitata a :count account di sistema. Rimuovine uno o chiedi un piano più grande.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Questa installazione è limitata a :count clienti. Rimuovine uno o chiedi un piano più grande."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Gli account di sistema di questa installazione sono limitati a :count. Rimuovine uno o chiedi un piano più grande.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "I clienti di questa installazione sono limitati a :count. Rimuovine uno o chiedi un piano più grande.",
|
||||
":used of :limit staff seats used": "Usati :used di :limit account di sistema",
|
||||
":used of :limit client accounts used": "Usati :used di :limit account cliente",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Questa cartella non può essere eliminata: contiene :count file che non puoi eliminare.|Questa cartella non può essere eliminata: contiene :count file che non puoi eliminare."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "ZIP ダウンロードを作成しているものがありません",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": ":date から ZIP ダウンロードが待機したままで、どのバックグラウンド処理も取りかかっていません。ZIP の作成は専用のキューで実行されるようになったため、このバージョンより前に起動した処理は別のキューを見ています — メールを含め、ほかはすべて動いています。",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "バックグラウンド処理のコマンドに --queue=default,zips が必要です。完全なサービスファイルは INSTALL.md にあります。",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "このインストールはシステムアカウント :count 件までです。1 件削除するか、上位のプランをご依頼ください。",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "このインストールは取引先 :count 件までです。1 件削除するか、上位のプランをご依頼ください。"
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "このインストールのシステムアカウントの上限は :count です。1 件削除するか、上位のプランをご依頼ください。",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "このインストールの取引先の上限は :count です。1 件削除するか、上位のプランをご依頼ください。",
|
||||
":used of :limit staff seats used": "システムアカウント :limit 件中 :used 件を使用中",
|
||||
":used of :limit client accounts used": "クライアントアカウント :limit 件中 :used 件を使用中",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "このフォルダーは削除できません:削除権限のないファイルが :count 件含まれています。|このフォルダーは削除できません:削除権限のないファイルが :count 件含まれています。"
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Niemand bouwt de ZIP-downloads",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Een ZIP-download wacht al sinds :date en geen enkel achtergrondproces heeft hem opgepakt. ZIP-downloads gebruiken nu een eigen wachtrij, dus een proces dat vóór deze versie is gestart kijkt naar de verkeerde — al het andere, e-mail inbegrepen, werkt gewoon door.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Het commando van je achtergrondproces heeft --queue=default,zips nodig. In INSTALL.md staat het volledige servicebestand.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Deze installatie is beperkt tot :count systeemaccounts. Verwijder er een of vraag om een groter abonnement.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Deze installatie is beperkt tot :count klanten. Verwijder er een of vraag om een groter abonnement."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "De systeemaccounts van deze installatie zijn beperkt tot :count. Verwijder er een of vraag om een groter abonnement.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "De klanten van deze installatie zijn beperkt tot :count. Verwijder er een of vraag om een groter abonnement.",
|
||||
":used of :limit staff seats used": ":used van :limit systeemaccounts in gebruik",
|
||||
":used of :limit client accounts used": ":used van :limit klantaccounts in gebruik",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Deze map kan niet worden verwijderd: er staat :count bestand in dat je niet mag verwijderen.|Deze map kan niet worden verwijderd: er staan :count bestanden in die je niet mag verwijderen."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Nikt nie tworzy pobrań ZIP",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Pobranie ZIP czeka od :date i żaden proces w tle go nie podjął. Pobrania ZIP mają teraz własną kolejkę, więc proces uruchomiony przed tą wersją obserwuje niewłaściwą — wszystko inne, łącznie z pocztą, działa normalnie.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Polecenie twojego procesu w tle potrzebuje --queue=default,zips. Pełny plik usługi znajdziesz w INSTALL.md.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Ta instalacja jest ograniczona do :count kont systemowych. Usuń jedno lub poproś o większy plan.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Ta instalacja jest ograniczona do :count klientów. Usuń jednego lub poproś o większy plan."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Liczba kont systemowych w tej instalacji jest ograniczona do :count. Usuń jedno lub poproś o większy plan.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Liczba klientów w tej instalacji jest ograniczona do :count. Usuń jednego lub poproś o większy plan.",
|
||||
":used of :limit staff seats used": "Wykorzystano :used z :limit kont systemowych",
|
||||
":used of :limit client accounts used": "Wykorzystano :used z :limit kont klientów",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Tego folderu nie można usunąć: zawiera :count plik, którego nie możesz usunąć.|Tego folderu nie można usunąć: zawiera :count pliki, których nie możesz usunąć.|Tego folderu nie można usunąć: zawiera :count plików, których nie możesz usunąć."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Ninguém está montando os downloads em ZIP",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Um download em ZIP está esperando desde :date e nenhum processo em segundo plano pegou ele. Downloads em ZIP agora usam uma fila própria, então um processo iniciado antes desta versão está olhando para a fila errada — todo o resto, inclusive e-mail, continua funcionando.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "O comando do seu processo em segundo plano precisa de --queue=default,zips. O INSTALL.md tem o arquivo de serviço completo.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Esta instalação está limitada a :count contas de sistema. Remova uma ou peça um plano maior.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Esta instalação está limitada a :count clientes. Remova um ou peça um plano maior."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "As contas de sistema desta instalação estão limitadas a :count. Remova uma ou peça um plano maior.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Os clientes desta instalação estão limitados a :count. Remova um ou peça um plano maior.",
|
||||
":used of :limit staff seats used": "Usadas :used de :limit contas de sistema",
|
||||
":used of :limit client accounts used": "Usadas :used de :limit contas de cliente",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Esta pasta não pode ser excluída: ela contém :count arquivo que você não pode excluir.|Esta pasta não pode ser excluída: ela contém :count arquivos que você não pode excluir."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Никто не собирает ZIP-архивы",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "ZIP-архив ждёт с :date, и ни один фоновый обработчик его не взял. Сборка ZIP теперь идёт в отдельной очереди, поэтому обработчик, запущенный до этой версии, следит не за той — всё остальное, включая почту, работает как прежде.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "В команде вашего фонового обработчика нужен --queue=default,zips. Полный файл службы есть в INSTALL.md.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Эта установка ограничена :count системными учётными записями. Удалите одну или запросите более крупный тариф.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Эта установка ограничена :count клиентами. Удалите одного или запросите более крупный тариф."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Количество системных учётных записей в этой установке ограничено до :count. Удалите одну или запросите более крупный тариф.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Количество клиентов в этой установке ограничено до :count. Удалите одного или запросите более крупный тариф.",
|
||||
":used of :limit staff seats used": "Использовано :used из :limit системных учётных записей",
|
||||
":used of :limit client accounts used": "Использовано :used из :limit клиентских учётных записей",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Эту папку нельзя удалить: в ней :count файл, который вам нельзя удалять.|Эту папку нельзя удалить: в ней :count файла, которые вам нельзя удалять.|Эту папку нельзя удалить: в ней :count файлов, которые вам нельзя удалять."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Hakuna kinachotengeneza upakuaji wa ZIP",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Upakuaji wa ZIP umesubiri tangu :date na hakuna mchakato wa nyuma uliouchukua. Utengenezaji wa ZIP sasa unaendeshwa kwenye foleni yake yenyewe, hivyo mchakato ulioanzishwa kabla ya toleo hili unaangalia foleni isiyo sahihi — kila kitu kingine, pamoja na barua pepe, kinaendelea kufanya kazi.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Amri ya mchakato wako wa nyuma inahitaji --queue=default,zips. Faili kamili ya huduma ipo katika INSTALL.md.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Usakinishaji huu umewekewa kikomo cha akaunti za mfumo :count. Ondoa moja, au omba mpango mkubwa zaidi.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Usakinishaji huu umewekewa kikomo cha wateja :count. Ondoa mmoja, au omba mpango mkubwa zaidi."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Akaunti za mfumo katika usakinishaji huu zimewekewa kikomo cha :count. Ondoa moja, au omba mpango mkubwa zaidi.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Wateja katika usakinishaji huu wamewekewa kikomo cha :count. Ondoa mmoja, au omba mpango mkubwa zaidi.",
|
||||
":used of :limit staff seats used": "Zimetumika :used kati ya :limit akaunti za mfumo",
|
||||
":used of :limit client accounts used": "Zimetumika :used kati ya :limit akaunti za wateja",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Folda hii haiwezi kufutwa: ina faili :count ambalo huruhusiwi kulifuta.|Folda hii haiwezi kufutwa: ina faili :count ambazo huruhusiwi kuzifuta."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "ZIP indirmelerini kimse oluşturmuyor",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Bir ZIP indirmesi :date tarihinden beri bekliyor ve hiçbir arka plan işleyicisi onu almadı. ZIP indirmeleri artık kendi kuyruğunda çalışıyor, bu yüzden bu sürümden önce başlatılan bir işleyici yanlış kuyruğu izliyor — e-posta dahil diğer her şey çalışmaya devam ediyor.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Arka plan işleyicinin komutunda --queue=default,zips olmalı. Tam servis dosyası INSTALL.md içinde.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Bu kurulum :count sistem hesabıyla sınırlı. Birini kaldır ya da daha büyük bir plan iste.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Bu kurulum :count müşteriyle sınırlı. Birini kaldır ya da daha büyük bir plan iste."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Bu kurulumdaki sistem hesabı sayısı en fazla :count olabilir. Birini kaldır ya da daha büyük bir plan iste.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Bu kurulumdaki müşteri sayısı en fazla :count olabilir. Birini kaldır ya da daha büyük bir plan iste.",
|
||||
":used of :limit staff seats used": ":limit sistem hesabından :used tanesi kullanımda",
|
||||
":used of :limit client accounts used": ":limit müşteri hesabından :used tanesi kullanımda",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Bu klasör silinemez: silme yetkin olmayan :count dosya içeriyor.|Bu klasör silinemez: silme yetkin olmayan :count dosya içeriyor."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "Không có tiến trình nào đang tạo bản tải ZIP",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "Một bản tải ZIP đã chờ từ :date mà không tiến trình nền nào nhận. Việc tạo ZIP giờ chạy trên hàng đợi riêng, nên tiến trình khởi động trước phiên bản này đang theo dõi nhầm hàng đợi — mọi thứ khác, kể cả email, vẫn chạy bình thường.",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "Lệnh chạy tiến trình nền của bạn cần --queue=default,zips. Tệp dịch vụ đầy đủ có trong INSTALL.md.",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "Bản cài đặt này giới hạn :count tài khoản hệ thống. Hãy xoá bớt một tài khoản, hoặc yêu cầu gói lớn hơn.",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "Bản cài đặt này giới hạn :count khách hàng. Hãy xoá bớt một khách hàng, hoặc yêu cầu gói lớn hơn."
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "Số tài khoản hệ thống của bản cài đặt này giới hạn ở :count. Hãy xoá bớt một tài khoản, hoặc yêu cầu gói lớn hơn.",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Số khách hàng của bản cài đặt này giới hạn ở :count. Hãy xoá bớt một khách hàng, hoặc yêu cầu gói lớn hơn.",
|
||||
":used of :limit staff seats used": "Đã dùng :used trong :limit tài khoản hệ thống",
|
||||
":used of :limit client accounts used": "Đã dùng :used trong :limit tài khoản khách hàng",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Không thể xoá thư mục này: nó chứa :count tệp mà bạn không được phép xoá.|Không thể xoá thư mục này: nó chứa :count tệp mà bạn không được phép xoá."
|
||||
}
|
||||
|
||||
+5
-2
@@ -1901,6 +1901,9 @@
|
||||
"Nothing is building zip downloads": "没有任何进程在打包 ZIP 下载",
|
||||
"A zip download has been waiting since :date and no background worker has picked it up. Zip downloads run on their own queue now, so a worker started before this version watches the wrong one — everything else, including email, keeps working.": "有一个 ZIP 下载从 :date 起一直在等待,没有任何后台进程接手。ZIP 打包现在使用单独的队列,因此在此版本之前启动的进程盯着的是另一条队列 —— 其他一切,包括邮件,都照常工作。",
|
||||
"Your worker command needs --queue=default,zips. INSTALL.md has the full service file.": "你的后台进程命令需要加上 --queue=default,zips。完整的服务文件见 INSTALL.md。",
|
||||
"This installation is limited to :count staff accounts. Remove one, or ask for a larger plan.": "本安装最多允许 :count 个系统账户。请删除一个,或申请更大的套餐。",
|
||||
"This installation is limited to :count clients. Remove one, or ask for a larger plan.": "本安装最多允许 :count 个客户。请删除一个,或申请更大的套餐。"
|
||||
"Staff accounts on this installation are limited to :count. Remove one, or ask for a larger plan.": "本安装的系统账户上限为 :count。请删除一个,或申请更大的套餐。",
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "本安装的客户上限为 :count。请删除一个,或申请更大的套餐。",
|
||||
":used of :limit staff seats used": "系统账户已用 :used / :limit",
|
||||
":used of :limit client accounts used": "客户账户已用 :used / :limit",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "无法删除此文件夹:其中包含 :count 个你无权删除的文件。|无法删除此文件夹:其中包含 :count 个你无权删除的文件。"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* What SeatAllowance::staffState() / clientState() send. Null on a
|
||||
* self-hosted install, where there is no limit and nothing to say.
|
||||
*/
|
||||
export interface SeatState {
|
||||
limit: number;
|
||||
used: number;
|
||||
full: boolean;
|
||||
/** Set only when full, and worded by the server so the screen and the
|
||||
* refusal it prevents describe the limit the same way. */
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "New user" / "New client" button, told how many seats are left.
|
||||
*
|
||||
* A managed installation is sold a number of accounts, so filling it is an
|
||||
* ordinary state rather than an error. Offering a live button for a form
|
||||
* that cannot be submitted is what made it feel like a fault: you invent a
|
||||
* password, submit, and the plan limit arrives as a validation error under
|
||||
* the email field. So the button goes dead at the limit and the reason is
|
||||
* on screen next to it, before anything is typed.
|
||||
*/
|
||||
export function SeatLimitedAction({
|
||||
seats,
|
||||
href,
|
||||
label,
|
||||
usage,
|
||||
}: {
|
||||
seats: SeatState | null;
|
||||
href: string;
|
||||
label: string;
|
||||
/** The count line, worded by the caller: staff seats and client seats
|
||||
* are different things to a reader. */
|
||||
usage: (seats: SeatState) => string;
|
||||
}) {
|
||||
const action = seats?.full ? (
|
||||
<Button disabled title={seats.message ?? undefined}>
|
||||
{label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href={href}>{label}</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!seats) {
|
||||
return action;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{action}
|
||||
<p className="text-muted-foreground max-w-xs text-right text-xs">{seats.full ? seats.message : usage(seats)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import Heading from '@/components/heading';
|
||||
import { FilterField, ListToolbar } from '@/components/list-toolbar';
|
||||
import { Pagination, PaginationMeta } from '@/components/pagination';
|
||||
import { SeatLimitedAction, type SeatState } from '@/components/seat-limit';
|
||||
import { TableShell } from '@/components/table-shell';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -35,9 +36,10 @@ interface ClientsIndexProps {
|
||||
pagination: PaginationMeta;
|
||||
filters: Filters;
|
||||
reassign_candidates: ReassignCandidate[];
|
||||
seats: SeatState | null;
|
||||
}
|
||||
|
||||
export default function ClientsIndex({ clients, pagination, filters, reassign_candidates }: ClientsIndexProps) {
|
||||
export default function ClientsIndex({ clients, pagination, filters, reassign_candidates, seats }: ClientsIndexProps) {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
|
||||
@@ -66,16 +68,19 @@ export default function ClientsIndex({ clients, pagination, filters, reassign_ca
|
||||
<div className="px-4 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={t('Clients')} description={t('The people you share files with')} />
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{can('manage_custom_fields') && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={route('client-custom-fields.index')}>{t('Manage custom fields')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
{can('create_clients') && (
|
||||
<Button asChild>
|
||||
<Link href={route('clients.create')}>{t('New client')}</Link>
|
||||
</Button>
|
||||
<SeatLimitedAction
|
||||
seats={seats}
|
||||
href={route('clients.create')}
|
||||
label={t('New client')}
|
||||
usage={({ used, limit }) => t(':used of :limit client accounts used', { used, limit })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import Heading from '@/components/heading';
|
||||
import { FilterField, ListToolbar } from '@/components/list-toolbar';
|
||||
import { Pagination, PaginationMeta } from '@/components/pagination';
|
||||
import { SeatLimitedAction, type SeatState } from '@/components/seat-limit';
|
||||
import { TableShell } from '@/components/table-shell';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -43,9 +44,10 @@ interface UsersIndexProps {
|
||||
filters: Filters;
|
||||
roles: { id: number; name: string }[];
|
||||
reassign_candidates: ReassignCandidate[];
|
||||
seats: SeatState | null;
|
||||
}
|
||||
|
||||
export default function UsersIndex({ users, pagination, filters, roles, reassign_candidates }: UsersIndexProps) {
|
||||
export default function UsersIndex({ users, pagination, filters, roles, reassign_candidates, seats }: UsersIndexProps) {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = usePage<SharedData>().props;
|
||||
|
||||
@@ -66,9 +68,12 @@ export default function UsersIndex({ users, pagination, filters, roles, reassign
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={t('System users')} description={t('The people who administer this installation and upload files')} />
|
||||
{can('create_users') && (
|
||||
<Button asChild>
|
||||
<Link href={route('users.create')}>{t('New user')}</Link>
|
||||
</Button>
|
||||
<SeatLimitedAction
|
||||
seats={seats}
|
||||
href={route('users.create')}
|
||||
label={t('New user')}
|
||||
usage={({ used, limit }) => t(':used of :limit staff seats used', { used, limit })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{{-- The action URL, spelled out for somebody who cannot click the button.
|
||||
|
||||
Paired with text/action-url.blade.php. Laravel resolves `mail::`
|
||||
components against html/ or text/ depending on which half of the
|
||||
message it is building, which is the whole reason this is a component
|
||||
rather than a line in the view: the two halves need different things
|
||||
from the same URL, and only one of them understands markdown. --}}
|
||||
<span class="break-all">[{{ $url }}]({{ $url }})</span>
|
||||
@@ -0,0 +1,9 @@
|
||||
{{-- The plain-text half of html/action-url.blade.php.
|
||||
|
||||
Just the URL. Laravel's own view writes `[$url]($url)` here, which is
|
||||
correct for the HTML half and wrong for this one: nothing parses
|
||||
markdown in a text/plain body, so it arrives as literal brackets with
|
||||
the address duplicated inside them — the shape a phishing template
|
||||
has. Seen in a real password reset, which is often the first mail an
|
||||
installation ever sends somebody. --}}
|
||||
{{ $url }}
|
||||
@@ -0,0 +1,72 @@
|
||||
{{-- Laravel's notification email view, published so the subcopy can spell
|
||||
the action URL out differently in each half of the message.
|
||||
|
||||
Upstream writes `[$url]($url)` there. That is right for the HTML half
|
||||
and wrong for the text one, where nothing parses markdown: it arrives
|
||||
as literal brackets around a duplicated address, which is what a
|
||||
badly-built phishing mail looks like — on a password reset, often the
|
||||
first mail an installation ever sends anybody. The x-mail::action-url
|
||||
component resolves to a different file per half, which is how every
|
||||
other component in this message already handles the same problem.
|
||||
|
||||
This is a copy of a framework view, so it does not follow Laravel
|
||||
forward on its own. If an upgrade changes the notification layout,
|
||||
re-copy it and re-apply the one-line change below. --}}
|
||||
<x-mail::message>
|
||||
{{-- Greeting --}}
|
||||
@if (! empty($greeting))
|
||||
# {{ $greeting }}
|
||||
@else
|
||||
@if ($level === 'error')
|
||||
# @lang('Whoops!')
|
||||
@else
|
||||
# @lang('Hello!')
|
||||
@endif
|
||||
@endif
|
||||
|
||||
{{-- Intro Lines --}}
|
||||
@foreach ($introLines as $line)
|
||||
{{ $line }}
|
||||
|
||||
@endforeach
|
||||
|
||||
{{-- Action Button --}}
|
||||
@isset($actionText)
|
||||
<?php
|
||||
$color = match ($level) {
|
||||
'success', 'error' => $level,
|
||||
default => 'primary',
|
||||
};
|
||||
?>
|
||||
<x-mail::button :url="$actionUrl" :color="$color">
|
||||
{{ $actionText }}
|
||||
</x-mail::button>
|
||||
@endisset
|
||||
|
||||
{{-- Outro Lines --}}
|
||||
@foreach ($outroLines as $line)
|
||||
{{ $line }}
|
||||
|
||||
@endforeach
|
||||
|
||||
{{-- Salutation --}}
|
||||
@if (! empty($salutation))
|
||||
{{ $salutation }}
|
||||
@else
|
||||
@lang('Regards,')<br>
|
||||
{{ config('app.name') }}
|
||||
@endif
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($actionText)
|
||||
<x-slot:subcopy>
|
||||
@lang(
|
||||
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n".
|
||||
'into your web browser:',
|
||||
[
|
||||
'actionText' => $actionText,
|
||||
]
|
||||
) <x-mail::action-url :url="$actionUrl" />
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
</x-mail::message>
|
||||
+6
-1
@@ -95,7 +95,12 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
|
||||
->name('password.confirm');
|
||||
|
||||
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store']);
|
||||
// Named so EnforceTwoFactor can exempt it. Its exemption list matches
|
||||
// on route names, and an unnamed route matches nothing -- which left
|
||||
// the form reachable and its submission not, closing the enrolment
|
||||
// path enforcement depends on.
|
||||
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store'])
|
||||
->name('password.confirm.store');
|
||||
|
||||
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
|
||||
->name('logout');
|
||||
|
||||
+5
-3
@@ -295,9 +295,11 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::delete('account-requests/{client}', [AccountRequestsController::class, 'deny'])->name('account-requests.deny');
|
||||
});
|
||||
|
||||
// Staff user & role management is community-only (managed installations
|
||||
// handle it outside the application) — both layers gate it:
|
||||
// capability + permission.
|
||||
// Staff user & role management: both editions since 2.2.0, and still
|
||||
// gated by both layers — capability + permission. The capability is
|
||||
// the seam an edition difference would travel through, and cloud
|
||||
// holds it (see Capability::UsersManage); the seat cap, not a closed
|
||||
// screen, is what a managed plan limits.
|
||||
Route::middleware(['staff', 'capability:users.manage', 'can:manage_users'])->group(function () {
|
||||
Route::get('users', [UsersController::class, 'index'])->name('users.index');
|
||||
Route::get('users/create', [UsersController::class, 'create'])->middleware('can:create_users')->name('users.create');
|
||||
|
||||
@@ -351,6 +351,35 @@ test('a caller cannot deactivate or delete their own account', function () {
|
||||
->assertJsonPath('errors.user.0', 'You cannot delete your own account.');
|
||||
});
|
||||
|
||||
test('self-deactivation is refused however the boolean is written', function (mixed $active) {
|
||||
// A second administrator, so the last-administrator guard is not what
|
||||
// refuses this: with only one, that guard answers first and the refusal
|
||||
// under test here is never reached.
|
||||
User::factory()->create();
|
||||
|
||||
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['active' => $active])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.active.0', 'You cannot deactivate your own account.');
|
||||
|
||||
expect($this->admin->refresh()->active)->toBeTrue();
|
||||
})->with([
|
||||
'false' => [false],
|
||||
'zero' => [0],
|
||||
'the string zero' => ['0'],
|
||||
]);
|
||||
|
||||
test('deactivating somebody else still works in every one of those forms', function (mixed $active) {
|
||||
$user = User::factory()->role(SystemRole::Uploader)->create();
|
||||
|
||||
$this->withToken($this->token)->patchJson("/api/v1/users/{$user->id}", ['active' => $active])->assertOk();
|
||||
|
||||
expect($user->refresh()->active)->toBeFalse();
|
||||
})->with([
|
||||
'false' => [false],
|
||||
'zero' => [0],
|
||||
'the string zero' => ['0'],
|
||||
]);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deletion and its content
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||
use App\Modules\Comments\CommentVisibility;
|
||||
use App\Modules\Comments\Models\FileComment;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* `file_comments.client_context_id` and `author_id` are both
|
||||
* cascadeOnDelete, and neither cascade ever fires: users are
|
||||
* soft-deleted, so the row survives and the column goes on pointing at
|
||||
* it. Everything that asked the *relation* instead of the column read
|
||||
* that as "there is no client here" — and for client_context_id, "no
|
||||
* client" is the branch every client on the file reads.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
$this->file = File::factory()->create(['name' => 'Quote']);
|
||||
$this->alice = User::factory()->client()->create(['name' => 'Alice Ltd']);
|
||||
$this->bob = User::factory()->client()->create(['name' => 'Bob GmbH']);
|
||||
|
||||
shareFileWith($this->file, $this->alice);
|
||||
shareFileWith($this->file, $this->bob);
|
||||
|
||||
$this->fromAlice = FileComment::factory()->for($this->file)->inThreadOf($this->alice)->create([
|
||||
'author_id' => $this->alice->id,
|
||||
'body' => 'What is your best price?',
|
||||
]);
|
||||
});
|
||||
|
||||
function readableBy(User $viewer, File $file): array
|
||||
{
|
||||
return app(VisibleCommentScope::class)->for($viewer, $file)->pluck('id')->map(intval(...))->all();
|
||||
}
|
||||
|
||||
test('a reply into a deleted client\'s thread is refused, not broadcast', function () {
|
||||
$this->alice->delete();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->postJson("/files/{$this->file->id}/comments", [
|
||||
'body' => 'For you only: 40% off.',
|
||||
'visibility' => CommentVisibility::Clients->value,
|
||||
'reply_to' => $this->fromAlice->id,
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
expect(FileComment::query()->where('body', 'For you only: 40% off.')->exists())->toBeFalse()
|
||||
->and(readableBy($this->bob, $this->file))->toBe([]);
|
||||
});
|
||||
|
||||
test('a staff message to everybody still reaches everybody', function () {
|
||||
// The null context is a real branch, not only a failure mode: staff
|
||||
// writing fresh address every client on the file, and that must keep
|
||||
// working.
|
||||
$this->actingAs($this->admin)
|
||||
->postJson("/files/{$this->file->id}/comments", [
|
||||
'body' => 'The catalogue is attached.',
|
||||
'visibility' => CommentVisibility::Clients->value,
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$broadcast = FileComment::query()->where('body', 'The catalogue is attached.')->sole();
|
||||
|
||||
expect($broadcast->client_context_id)->toBeNull()
|
||||
->and(readableBy($this->bob, $this->file))->toContain($broadcast->id);
|
||||
});
|
||||
|
||||
test('a reply into a live client\'s thread still lands in that thread alone', function () {
|
||||
$this->actingAs($this->admin)
|
||||
->postJson("/files/{$this->file->id}/comments", [
|
||||
'body' => 'For you only: 40% off.',
|
||||
'visibility' => CommentVisibility::Clients->value,
|
||||
'reply_to' => $this->fromAlice->id,
|
||||
])
|
||||
->assertCreated();
|
||||
|
||||
$reply = FileComment::query()->where('body', 'For you only: 40% off.')->sole();
|
||||
|
||||
expect($reply->client_context_id)->toBe($this->alice->id)
|
||||
->and(readableBy($this->alice, $this->file))->toContain($reply->id)
|
||||
->and(readableBy($this->bob, $this->file))->not->toContain($reply->id);
|
||||
});
|
||||
|
||||
test('a deleted client\'s comment keeps their name instead of reading as a guest', function () {
|
||||
$this->alice->delete();
|
||||
|
||||
expect($this->fromAlice->fresh()->authorName())->toBe('Alice Ltd');
|
||||
});
|
||||
|
||||
test('a genuine guest comment is still anonymous', function () {
|
||||
$guest = FileComment::factory()->for($this->file)->fromGuest()->create();
|
||||
|
||||
expect($guest->authorName())->not->toBe('Alice Ltd')
|
||||
->and($guest->author_id)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
<?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 Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Deleting a folder cascades to every file in its subtree, and a File's
|
||||
* `deleted` hook removes the bytes from disk. So the folder route must
|
||||
* not destroy what the file route refuses to hand over.
|
||||
*
|
||||
* MyFoldersController::destroy already draws this line for clients. These
|
||||
* are the staff cases.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
});
|
||||
|
||||
function folderDeleteRole(array $permissions, bool $clientScoped = false): User
|
||||
{
|
||||
$role = Role::query()->create(['name' => 'Role '.Str::random(6), 'client_scoped' => $clientScoped]);
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
|
||||
}
|
||||
|
||||
return User::factory()->create(['role_id' => $role->id]);
|
||||
}
|
||||
|
||||
function folderDeleteUpload(User $as, string $name, ?int $folderId = null): File
|
||||
{
|
||||
test()->actingAs($as)->post('/files', [
|
||||
'file' => UploadedFile::fake()->create($name.'.pdf', 4, 'application/pdf'),
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
'folder_id' => $folderId,
|
||||
]);
|
||||
|
||||
return File::query()->latest('id')->firstOrFail();
|
||||
}
|
||||
|
||||
test('a folder is not a way around delete_others_files', function () {
|
||||
$staff = folderDeleteRole([
|
||||
Permission::CreateOwnFolders, Permission::DeleteFiles,
|
||||
Permission::Upload, Permission::EditFiles,
|
||||
]);
|
||||
|
||||
$folder = Folder::query()->create(['name' => 'Reports', 'created_by' => $staff->id]);
|
||||
$foreign = folderDeleteUpload($this->admin, 'someone-elses', $folder->id);
|
||||
|
||||
// The file route already refuses this one.
|
||||
$this->actingAs($staff)->delete("/files/{$foreign->id}")->assertForbidden();
|
||||
|
||||
$this->actingAs($staff)->delete("/folders/{$folder->id}")->assertRedirect();
|
||||
|
||||
expect(File::query()->whereKey($foreign->id)->exists())->toBeTrue()
|
||||
->and(Folder::query()->whereKey($folder->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the refusal says how many files are in the way', function () {
|
||||
$staff = folderDeleteRole([
|
||||
Permission::CreateOwnFolders, Permission::DeleteFiles,
|
||||
Permission::Upload, Permission::EditFiles,
|
||||
]);
|
||||
|
||||
$folder = Folder::query()->create(['name' => 'Reports', 'created_by' => $staff->id]);
|
||||
folderDeleteUpload($this->admin, 'one', $folder->id);
|
||||
folderDeleteUpload($this->admin, 'two', $folder->id);
|
||||
|
||||
$this->actingAs($staff)->delete("/folders/{$folder->id}")
|
||||
->assertSessionHas('error', fn (string $message): bool => str_contains($message, '2 files'));
|
||||
});
|
||||
|
||||
test('a nested file is reached too', function () {
|
||||
$staff = folderDeleteRole([
|
||||
Permission::CreateOwnFolders, Permission::DeleteFiles,
|
||||
Permission::Upload, Permission::EditFiles,
|
||||
]);
|
||||
|
||||
$parent = Folder::query()->create(['name' => 'Parent', 'created_by' => $staff->id]);
|
||||
$child = Folder::query()->create([
|
||||
'name' => 'Child', 'parent_id' => $parent->id,
|
||||
'path' => "/{$parent->id}/", 'created_by' => $staff->id,
|
||||
]);
|
||||
$foreign = folderDeleteUpload($this->admin, 'deep', $child->id);
|
||||
|
||||
$this->actingAs($staff)->delete("/folders/{$parent->id}");
|
||||
|
||||
expect(File::query()->whereKey($foreign->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the library boundary is asked as well, not only the permission', function () {
|
||||
$staff = folderDeleteRole([
|
||||
Permission::CreateOwnFolders, Permission::DeleteFiles,
|
||||
Permission::DeleteOthersFiles, Permission::Upload, Permission::EditFiles,
|
||||
], clientScoped: true);
|
||||
|
||||
$folder = Folder::query()->create(['name' => 'Scoped', 'created_by' => $staff->id]);
|
||||
$outside = folderDeleteUpload($this->admin, 'outside-the-library', $folder->id);
|
||||
|
||||
// Both delete permissions are held, so the permission half of
|
||||
// FilePolicy::delete passes and only StaffLibraryScope can refuse --
|
||||
// which is the half a per-permission check would have missed.
|
||||
$this->actingAs($staff)->delete("/files/{$outside->id}")->assertForbidden();
|
||||
|
||||
$this->actingAs($staff)->delete("/folders/{$folder->id}")->assertRedirect();
|
||||
|
||||
expect(File::query()->whereKey($outside->id)->exists())->toBeTrue()
|
||||
->and(Folder::query()->whereKey($folder->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a folder holding only the deleter own files still goes', function () {
|
||||
$staff = folderDeleteRole([
|
||||
Permission::CreateOwnFolders, Permission::DeleteFiles,
|
||||
Permission::Upload, Permission::EditFiles,
|
||||
]);
|
||||
|
||||
$folder = Folder::query()->create(['name' => 'Mine', 'created_by' => $staff->id]);
|
||||
$own = folderDeleteUpload($staff, 'my-own', $folder->id);
|
||||
|
||||
$this->actingAs($staff)->delete("/folders/{$folder->id}")->assertRedirect();
|
||||
|
||||
expect(File::query()->whereKey($own->id)->exists())->toBeFalse()
|
||||
->and(Folder::query()->whereKey($folder->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('an administrator holding both permissions is unaffected', function () {
|
||||
$folder = Folder::query()->create(['name' => 'Anything', 'created_by' => $this->admin->id]);
|
||||
$other = User::factory()->create();
|
||||
$theirs = folderDeleteUpload($other, 'theirs', $folder->id);
|
||||
|
||||
$this->actingAs($this->admin)->delete("/folders/{$folder->id}")->assertRedirect();
|
||||
|
||||
expect(File::query()->whereKey($theirs->id)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -596,3 +596,88 @@ test('a zip build is queued away from ordinary work', function () {
|
||||
|
||||
Queue::assertPushed(BuildZipDownloadJob::class, fn (BuildZipDownloadJob $job): bool => $job->queue === 'zips');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| A selection that reaches the same file more than once
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('a file reached by both a loose pick and a folder is added once', function () {
|
||||
$folder = Folder::query()->create(['name' => 'Reports']);
|
||||
$file = zipUploadFile($this->admin, 'report.pdf', $folder->id);
|
||||
|
||||
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
|
||||
'file_ids' => [$file->id],
|
||||
'folder_ids' => [$folder->id],
|
||||
])->assertOk();
|
||||
|
||||
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
||||
|
||||
// The loose pick reaches it first, so that is where the one copy sits.
|
||||
expect(zipEntryNames($zipDownload))->toBe(['report.pdf'])
|
||||
->and($zipDownload->file_count)->toBe(1)
|
||||
->and($zipDownload->total_size)->toBe($file->size)
|
||||
->and($zipDownload->contained_file_ids)->toBe([$file->id]);
|
||||
});
|
||||
|
||||
test('a folder inside another selected folder does not duplicate its contents', function () {
|
||||
$parent = Folder::query()->create(['name' => 'Reports']);
|
||||
$child = Folder::query()->create(['name' => 'Q1', 'parent_id' => $parent->id, 'path' => "/{$parent->id}/"]);
|
||||
$file = zipUploadFile($this->admin, 'report.pdf', $child->id);
|
||||
|
||||
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
|
||||
'folder_ids' => [$parent->id, $child->id],
|
||||
])->assertOk();
|
||||
|
||||
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
||||
|
||||
// The outer folder wins, so the entry keeps the fuller path.
|
||||
expect(zipEntryNames($zipDownload))->toBe(['Reports/Q1/report.pdf'])
|
||||
->and($zipDownload->file_count)->toBe(1)
|
||||
->and($zipDownload->total_size)->toBe($file->size);
|
||||
});
|
||||
|
||||
test('the same file selected three ways is handed over once and charged once', function () {
|
||||
$parent = Folder::query()->create(['name' => 'Reports']);
|
||||
$child = Folder::query()->create(['name' => 'Q1', 'parent_id' => $parent->id, 'path' => "/{$parent->id}/"]);
|
||||
$file = zipUploadFile($this->admin, 'report.pdf', $child->id);
|
||||
|
||||
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
|
||||
'file_ids' => [$file->id],
|
||||
'folder_ids' => [$parent->id, $child->id],
|
||||
])->assertOk();
|
||||
|
||||
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
||||
$this->actingAs($this->admin)->get("/zip-downloads/{$zipDownload->id}/download")->assertOk();
|
||||
|
||||
// Delivery logs one FileDownloaded per contained file, so as many
|
||||
// copies as the archive holds must be as many as the log records —
|
||||
// otherwise a file limited to one download leaves in several.
|
||||
$logged = ActivityLog::query()
|
||||
->where('action', Action::FileDownloaded)
|
||||
->where('subject_id', $file->id)
|
||||
->count();
|
||||
|
||||
expect(count(zipEntryNames($zipDownload)))->toBe(1)
|
||||
->and($logged)->toBe(1);
|
||||
});
|
||||
|
||||
test('two selected folders that merely share a name are both zipped', function () {
|
||||
// The pruning above is about containment, not about names: neither of
|
||||
// these is inside the other, so both belong in the archive, and the
|
||||
// usual collision suffix keeps them apart.
|
||||
$first = Folder::query()->create(['name' => 'Reports']);
|
||||
$second = Folder::query()->create(['name' => 'Reports']);
|
||||
zipUploadFile($this->admin, 'a.pdf', $first->id);
|
||||
zipUploadFile($this->admin, 'b.pdf', $second->id);
|
||||
|
||||
$response = $this->actingAs($this->admin)->postJson('/zip-downloads', [
|
||||
'folder_ids' => [$first->id, $second->id],
|
||||
])->assertOk();
|
||||
|
||||
$zipDownload = ZipDownload::query()->findOrFail($response->json('id'));
|
||||
|
||||
expect($zipDownload->file_count)->toBe(2)
|
||||
->and(zipEntryNames($zipDownload))->toContain('Reports/a.pdf');
|
||||
});
|
||||
|
||||
@@ -420,3 +420,60 @@ test('an unscoped administrator manages every group exactly as before', function
|
||||
|
||||
expect(Group::query()->whereKey($this->strangerGroup->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The screen that edits a group, and its API twin
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('a group reaching past the library cannot even be opened', function () {
|
||||
// update() and destroy() already refuse this group. Reading it was the
|
||||
// one group route that did not.
|
||||
$this->actingAs($this->rep)->get("/groups/{$this->strangerGroup->id}")->assertNotFound();
|
||||
|
||||
$token = $this->rep->createToken('t', [Permission::EditGroups->value])->plainTextToken;
|
||||
$this->withToken($token)->getJson("/api/v1/groups/{$this->strangerGroup->id}")->assertNotFound();
|
||||
});
|
||||
|
||||
test('the edit screen stops naming clients this viewer has no business hearing about', function () {
|
||||
// Nothing is shared with this group, so it reaches nowhere and stays
|
||||
// open to the rep — which is exactly the case a reach guard alone
|
||||
// would leave holding a stranger's address.
|
||||
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
|
||||
$ours->members()->syncWithoutDetaching([$this->mine->id, $this->stranger->id]);
|
||||
|
||||
$props = $this->actingAs($this->rep)->get("/groups/{$ours->id}")->assertOk()->viewData('page')['props'];
|
||||
|
||||
expect(array_column($props['members'], 'name'))->toBe(['Mine'])
|
||||
->and(array_column($props['members'], 'email'))->toBe([$this->mine->email])
|
||||
// Every client the rep may reach is already in the group, so there
|
||||
// is nobody left to add — rather than the stranger, whom they could
|
||||
// not have added anyway.
|
||||
->and($props['available_clients'])->toBe([]);
|
||||
});
|
||||
|
||||
test('the API twin narrows the membership it hands back', function () {
|
||||
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
|
||||
$ours->members()->syncWithoutDetaching([$this->mine->id, $this->stranger->id]);
|
||||
|
||||
$token = $this->rep->createToken('t', [Permission::EditGroups->value])->plainTextToken;
|
||||
$data = $this->withToken($token)->getJson("/api/v1/groups/{$ours->id}")->assertOk()->json('data');
|
||||
|
||||
expect(array_column($data['members'], 'name'))->toBe(['Mine'])
|
||||
// members_count is left whole on purpose: a size is not an
|
||||
// identity, and it is the same number the group listing reports.
|
||||
->and($data['members_count'])->toBe(2);
|
||||
});
|
||||
|
||||
test('an unscoped viewer keeps the whole roster and every member', function () {
|
||||
$ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
|
||||
$ours->members()->syncWithoutDetaching([$this->mine->id]);
|
||||
|
||||
$props = $this->actingAs($this->admin)->get("/groups/{$ours->id}")->assertOk()->viewData('page')['props'];
|
||||
|
||||
expect(array_column($props['members'], 'name'))->toBe(['Mine'])
|
||||
->and(array_column($props['available_clients'], 'name'))->toBe(['Not Mine']);
|
||||
|
||||
$this->actingAs($this->admin)->get("/groups/{$this->strangerGroup->id}")->assertOk();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\UserType;
|
||||
|
||||
/**
|
||||
* Deleting your own account goes through ProfileController, which asked
|
||||
* for the current password and nothing else. Every other door that can
|
||||
* remove an administrator asks StaffAccounts::guardLastAdministrator
|
||||
* first; this one did not, and it is the one door where the account being
|
||||
* removed is certainly signed in.
|
||||
*
|
||||
* The account is soft-deleted, so the row survives — but every "is this
|
||||
* installation set up" check asked `exists()`, which does not see trashed
|
||||
* rows. Zero live staff sends every request to the first-run setup form,
|
||||
* and that form is registered without `guest`, without auth and without a
|
||||
* throttle.
|
||||
*/
|
||||
function liveStaffCount(): int
|
||||
{
|
||||
return User::query()->where('type', UserType::Staff)->count();
|
||||
}
|
||||
|
||||
function onlyStaffAccount(): User
|
||||
{
|
||||
User::query()->where('type', UserType::Staff)->forceDelete();
|
||||
|
||||
return User::factory()->create();
|
||||
}
|
||||
|
||||
test('the last administrator cannot delete their own account', function () {
|
||||
$admin = onlyStaffAccount();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->from('/settings/delete-account')
|
||||
->delete('/settings/profile', ['password' => 'password'])
|
||||
->assertSessionHasErrors('role_id');
|
||||
|
||||
expect(liveStaffCount())->toBe(1)
|
||||
->and($admin->fresh())->not->toBeNull();
|
||||
});
|
||||
|
||||
test('an administrator with a colleague still may', function () {
|
||||
$admin = onlyStaffAccount();
|
||||
$second = User::factory()->create();
|
||||
|
||||
$this->actingAs($admin)
|
||||
->delete('/settings/profile', ['password' => 'password'])
|
||||
->assertRedirect('/');
|
||||
|
||||
expect(liveStaffCount())->toBe(1)
|
||||
->and(User::query()->whereKey($second->id)->exists())->toBeTrue()
|
||||
->and(User::query()->whereKey($admin->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a staff member who is not an administrator still may', function () {
|
||||
onlyStaffAccount();
|
||||
$uploader = User::factory()->role(SystemRole::Uploader)->create();
|
||||
|
||||
$this->actingAs($uploader)
|
||||
->delete('/settings/profile', ['password' => 'password'])
|
||||
->assertRedirect('/');
|
||||
|
||||
expect(User::query()->whereKey($uploader->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a client can still close their own account', function () {
|
||||
onlyStaffAccount();
|
||||
$client = User::factory()->client()->create();
|
||||
|
||||
$this->actingAs($client)
|
||||
->delete('/settings/profile', ['password' => 'password'])
|
||||
->assertRedirect('/');
|
||||
|
||||
expect(User::query()->whereKey($client->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The second lock: a trashed staff row still means "already set up"
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('setup stays shut once a staff account has existed, even trashed', function () {
|
||||
$admin = onlyStaffAccount();
|
||||
|
||||
// Reached past the guard on purpose — the point of this half is that
|
||||
// the window stays closed even if some future door forgets to ask.
|
||||
$admin->delete();
|
||||
|
||||
expect(liveStaffCount())->toBe(0)
|
||||
->and(User::query()->withTrashed()->where('type', UserType::Staff)->count())->toBe(1);
|
||||
|
||||
$this->get('/')->assertRedirect('/login');
|
||||
|
||||
$this->post('/setup', [
|
||||
'site_name' => 'Taken Over',
|
||||
'name' => 'Stranger',
|
||||
'email' => 'stranger@example.com',
|
||||
'password' => 'Str0ng-Passw0rd!x',
|
||||
'password_confirmation' => 'Str0ng-Passw0rd!x',
|
||||
])->assertRedirect(route('home'));
|
||||
|
||||
expect(User::query()->where('email', 'stranger@example.com')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a genuinely fresh installation still reaches setup', function () {
|
||||
User::query()->withTrashed()->forceDelete();
|
||||
|
||||
expect(User::query()->withTrashed()->count())->toBe(0);
|
||||
|
||||
$this->get('/')->assertRedirect(route('setup'));
|
||||
|
||||
$this->post('/setup', [
|
||||
'site_name' => 'Fresh',
|
||||
'name' => 'First Administrator',
|
||||
'email' => 'first@example.com',
|
||||
'password' => 'Str0ng-Passw0rd!x',
|
||||
'password_confirmation' => 'Str0ng-Passw0rd!x',
|
||||
])->assertRedirect(route('setup.success'));
|
||||
|
||||
$created = User::query()->where('email', 'first@example.com')->sole();
|
||||
$administrator = Role::query()->where('name', SystemRole::SystemAdministrator->value)->sole();
|
||||
|
||||
expect($created->type)->toBe(UserType::Staff)
|
||||
->and($created->role_id)->toBe($administrator->id);
|
||||
});
|
||||
@@ -55,7 +55,12 @@ test('the 2fa setup screen itself and logout stay reachable under enforcement',
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->get('/settings/two-factor')->assertOk();
|
||||
$this->post('/settings/two-factor')->assertRedirect();
|
||||
// Named rather than bare: enabling is behind password.confirm, so the
|
||||
// redirect it answers with is the confirm-password screen. A bare
|
||||
// assertRedirect() passes on any target, including this middleware
|
||||
// bouncing the request back to two-factor.show, which is the shape
|
||||
// this file exists to refuse.
|
||||
$this->post('/settings/two-factor')->assertRedirect(route('password.confirm'));
|
||||
$this->post('/logout')->assertRedirect('/');
|
||||
});
|
||||
|
||||
@@ -102,3 +107,46 @@ test('clients cannot access security settings', function () {
|
||||
$this->get('/system/settings/security')->assertRedirect(route('dashboard'));
|
||||
$this->patch('/system/settings/security', ['two_factor_enforcement' => 'all'])->assertForbidden();
|
||||
});
|
||||
|
||||
/**
|
||||
* The exemption list matches on route names, and only the GET half of the
|
||||
* confirm-password screen had one. So the form rendered and its submission
|
||||
* did not: enforcement sent the POST back to two-factor.show, the password
|
||||
* was never confirmed, and enrolment -- the one exit enforcement leaves
|
||||
* open -- could not be started by anybody.
|
||||
*/
|
||||
test('an enforced user can confirm their password, which is what enrolling needs', function () {
|
||||
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->post('/settings/two-factor')->assertRedirect(route('password.confirm'));
|
||||
$this->get('/confirm-password')->assertOk();
|
||||
|
||||
$this->post('/confirm-password', ['password' => 'password'])
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
expect(session()->has('auth.password_confirmed_at'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('enrolment can actually be started under enforcement', function () {
|
||||
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$this->post('/confirm-password', ['password' => 'password']);
|
||||
|
||||
// store() answers back(), so the target is the referer rather than a
|
||||
// fixed route -- what matters is that it ran at all instead of being
|
||||
// bounced to the confirm-password screen it can no longer get past.
|
||||
$this->post('/settings/two-factor')->assertSessionHasNoErrors();
|
||||
|
||||
// The secret is what enabling writes; without it the enrolment screen
|
||||
// has no QR code to show and there is nothing to confirm against.
|
||||
expect($user->refresh()->two_factor_secret)->not->toBeNull();
|
||||
|
||||
$this->get('/settings/two-factor')->assertOk()->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('pending', true)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Notifications\ResetPasswordNotification;
|
||||
use Illuminate\Mail\Markdown;
|
||||
|
||||
/**
|
||||
* Every notification carrying an action button repeats its URL in the
|
||||
* subcopy, for somebody whose mail client will not let them click it.
|
||||
*
|
||||
* Laravel's own view writes that as `[$url]($url)`, which is right for
|
||||
* the HTML half and wrong for the text one: nothing parses markdown in a
|
||||
* text/plain body, so it arrives as literal brackets around a duplicated
|
||||
* address — the shape a badly-built phishing mail has, on what is often
|
||||
* the first message an installation ever sends anybody. Seen in the wild
|
||||
* on a real password reset before it was fixed.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
User::factory()->create();
|
||||
});
|
||||
|
||||
/** The two halves Laravel builds for one markdown notification. */
|
||||
function renderResetMail(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$mail = (new ResetPasswordNotification(str_repeat('a', 64)))->toMail($user);
|
||||
$mail->viewData['actionText'] = $mail->actionText;
|
||||
|
||||
$markdown = app(Markdown::class);
|
||||
$view = $mail->markdown ?: 'notifications::email';
|
||||
$data = array_merge($mail->toArray(), $mail->viewData);
|
||||
|
||||
return [
|
||||
'text' => (string) $markdown->renderText($view, $data),
|
||||
'html' => (string) $markdown->render($view, $data),
|
||||
'url' => $mail->actionUrl,
|
||||
];
|
||||
}
|
||||
|
||||
it('spells the action URL out plainly in the text half', function () {
|
||||
['text' => $text, 'url' => $url] = renderResetMail();
|
||||
|
||||
expect($text)->toContain($url)
|
||||
->and($text)->not->toContain('](')
|
||||
->and($text)->not->toContain('['.$url);
|
||||
});
|
||||
|
||||
it('still links the action URL in the html half', function () {
|
||||
['html' => $html, 'url' => $url] = renderResetMail();
|
||||
|
||||
// Twice: the button itself, and the subcopy that repeats it.
|
||||
expect(substr_count($html, 'href="'.e($url).'"'))->toBe(2)
|
||||
// The markdown must have been parsed, not passed through.
|
||||
->and($html)->not->toContain('['.e($url).']');
|
||||
});
|
||||
|
||||
it('does not repeat the URL more than the two places that need it', function () {
|
||||
['text' => $text, 'url' => $url] = renderResetMail();
|
||||
|
||||
// Once after the button label, once in the subcopy. A third meant the
|
||||
// markdown link had been left in place.
|
||||
expect(substr_count($text, $url))->toBe(2);
|
||||
});
|
||||
@@ -111,7 +111,11 @@ test('installing wins over updating', function () {
|
||||
});
|
||||
|
||||
test('completing setup raises the greeting', function () {
|
||||
User::query()->delete();
|
||||
// forceDelete, not delete: "a fresh installation" means no staff row
|
||||
// at all. A soft-deleted one is evidence that setup already happened,
|
||||
// and EnsureSetupIsComplete counts it as such so that losing the last
|
||||
// administrator cannot reopen the first-run form to a stranger.
|
||||
User::query()->forceDelete();
|
||||
|
||||
$this->post('/setup', [
|
||||
'site_name' => 'Acme Files',
|
||||
@@ -127,7 +131,7 @@ test('completing setup raises the greeting', function () {
|
||||
// Unattended provisioning skips the setup screen entirely, and is how
|
||||
// every container that came up from environment variables was installed.
|
||||
test('provisioning from the command line raises it too', function () {
|
||||
User::query()->delete();
|
||||
User::query()->forceDelete();
|
||||
|
||||
$this->artisan('projectsend:admin', [
|
||||
'--name' => 'Ada',
|
||||
|
||||
@@ -8,6 +8,9 @@ use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
/**
|
||||
* A cap is only a cap if every door asks.
|
||||
@@ -190,6 +193,53 @@ test('door: approving an account request', function () {
|
||||
expect($pending->refresh()->account_requested)->toBeTrue();
|
||||
});
|
||||
|
||||
test('door: approving a pending client through the edit screen', function () {
|
||||
seatLimits(clients: 0);
|
||||
|
||||
$pending = User::factory()->client()->create(['account_requested' => true, 'active' => false]);
|
||||
|
||||
$this->actingAs($this->admin)->patch("/clients/{$pending->id}", [
|
||||
'name' => $pending->name,
|
||||
'email' => $pending->email,
|
||||
'active' => true,
|
||||
])->assertSessionHasErrors('active');
|
||||
|
||||
// Nothing is written: the guard throws before save(), so a refused
|
||||
// approval does not leave the name or the flag half-applied.
|
||||
expect($pending->refresh()->account_requested)->toBeTrue()
|
||||
->and($pending->active)->toBeFalse();
|
||||
});
|
||||
|
||||
test('door: approving a pending client through the API', function () {
|
||||
seatLimits(clients: 0);
|
||||
|
||||
$pending = User::factory()->client()->create(['account_requested' => true, 'active' => false]);
|
||||
|
||||
Sanctum::actingAs($this->admin, ['*']);
|
||||
|
||||
$this->patchJson("/api/v1/clients/{$pending->id}", ['active' => true])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors('active');
|
||||
|
||||
expect($pending->refresh()->account_requested)->toBeTrue();
|
||||
});
|
||||
|
||||
test('the cap does not block editing a client the installation already holds', function () {
|
||||
// The guard sits inside the approval branch. Above it, an installation
|
||||
// sitting at its cap could not rename anybody.
|
||||
seatLimits(clients: 0);
|
||||
|
||||
$client = User::factory()->client()->create(['account_requested' => false, 'active' => true]);
|
||||
|
||||
$this->actingAs($this->admin)->patch("/clients/{$client->id}", [
|
||||
'name' => 'Renamed Ltd',
|
||||
'email' => $client->email,
|
||||
'active' => true,
|
||||
])->assertSessionHasNoErrors();
|
||||
|
||||
expect($client->refresh()->name)->toBe('Renamed Ltd');
|
||||
});
|
||||
|
||||
test('door: demoting a staff account to client', function () {
|
||||
seatLimits(clients: 0);
|
||||
|
||||
@@ -202,6 +252,77 @@ test('door: demoting a staff account to client', function () {
|
||||
expect($staffer->refresh()->isStaff())->toBeTrue();
|
||||
});
|
||||
|
||||
// ------------------------------------------- saying so before anything is typed
|
||||
|
||||
/**
|
||||
* A full installation is an ordinary state on a managed plan, not a fault.
|
||||
*
|
||||
* It used to read as one: the create screen opened, you invented a
|
||||
* password, submitted, and the plan limit came back as a validation error
|
||||
* under the email field — which looks like a complaint about the address.
|
||||
* The guard stays where it is; these cases are about the screen in front
|
||||
* of it.
|
||||
*/
|
||||
test('the create screen turns you away instead of taking a form it cannot accept', function () {
|
||||
seatLimits(staff: 1); // the admin already fills it
|
||||
|
||||
$this->actingAs($this->admin)->get('/users/create')
|
||||
->assertRedirect('/users')
|
||||
->assertSessionHas('error', fn (string $message): bool => str_contains($message, 'limited to 1.'));
|
||||
});
|
||||
|
||||
test('the client create screen does the same', function () {
|
||||
seatLimits(clients: 0);
|
||||
|
||||
$this->actingAs($this->admin)->get('/clients/create')
|
||||
->assertRedirect('/clients')
|
||||
->assertSessionHas('error', fn (string $message): bool => str_contains($message, 'limited to 0.'));
|
||||
});
|
||||
|
||||
test('room under the cap still opens the create screen', function () {
|
||||
seatLimits(staff: 2, clients: 2);
|
||||
|
||||
$this->actingAs($this->admin)->get('/users/create')->assertOk();
|
||||
$this->actingAs($this->admin)->get('/clients/create')->assertOk();
|
||||
});
|
||||
|
||||
test('the list says where the installation stands, so the button can go dead with a reason', function () {
|
||||
seatLimits(staff: 2);
|
||||
|
||||
$this->actingAs($this->admin)->get('/users')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('seats.limit', 2)
|
||||
->where('seats.used', 1)
|
||||
->where('seats.full', false)
|
||||
// Nothing to explain while there is room.
|
||||
->where('seats.message', null));
|
||||
});
|
||||
|
||||
test('the list carries the refusal in the guard\'s own words once it is full', function () {
|
||||
// One wording for one limit: two is how somebody ends up believing
|
||||
// there are two limits.
|
||||
seatLimits(clients: 1);
|
||||
|
||||
User::factory()->client()->create();
|
||||
|
||||
$this->actingAs($this->admin)->get('/clients')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('seats.full', true)
|
||||
->where('seats.message', fn (string $message): bool => str_contains($message, 'limited to 1.')));
|
||||
});
|
||||
|
||||
test('a self-hosted install is told nothing about seats at all', function () {
|
||||
// No limit, so no counter, no dead button, and no invitation to
|
||||
// wonder which plan it is on.
|
||||
seatLimits();
|
||||
|
||||
$this->actingAs($this->admin)->get('/users')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('seats', null));
|
||||
|
||||
$this->actingAs($this->admin)->get('/clients')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('seats', null));
|
||||
});
|
||||
|
||||
// --------------------------------------------------------- what must not change
|
||||
|
||||
test('the console command is deliberately not capped', function () {
|
||||
@@ -234,3 +355,32 @@ test('room under the cap still lets an account through', function () {
|
||||
|
||||
expect(User::query()->where('email', 'second@example.test')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('names the limit without putting a noun after the number', function () {
|
||||
// The refusal used to read "limited to 1 staff accounts" — the number
|
||||
// sat directly in front of a countable noun, so no single wording could
|
||||
// be right for every value. English needs two forms; Polish, Czech and
|
||||
// Russian need three, and inflect the noun by the number in front of
|
||||
// it. Putting the number last means no language has to agree with it.
|
||||
config()->set('projectsend.platform.max_staff_users', 1);
|
||||
config()->set('projectsend.platform.max_clients', 1);
|
||||
|
||||
// Both seats have to be full for either guard to say anything at all.
|
||||
User::factory()->client()->create();
|
||||
|
||||
$allowance = app(SeatAllowance::class);
|
||||
|
||||
foreach (['guardStaff', 'guardClient'] as $guard) {
|
||||
try {
|
||||
$allowance->{$guard}();
|
||||
$this->fail($guard.'() should have refused at a limit of 1.');
|
||||
} catch (ValidationException $e) {
|
||||
$message = $e->validator->errors()->first();
|
||||
|
||||
expect($message)->toContain('limited to 1.')
|
||||
// A trailing noun is exactly what this is here to stop.
|
||||
->and($message)->not->toContain('1 staff accounts')
|
||||
->and($message)->not->toContain('1 clients');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -127,8 +127,13 @@ test('it names the command this kind of installation can actually run', function
|
||||
]);
|
||||
|
||||
// The rollback story, asserted end to end: whatever the marker said, the
|
||||
// command rewrites it to whatever is actually running.
|
||||
// command rewrites it to whatever is actually running. Through the artisan
|
||||
// seam, as everything that runs this command has to be — see
|
||||
// Tests\Support\RecordingUpdate. The settings write this asserts is the
|
||||
// real one.
|
||||
test('running the update clears the notice', function () {
|
||||
recordingUpdate();
|
||||
|
||||
applied('2.2.0');
|
||||
expect(noticeFor($this->admin))->not->toBeNull();
|
||||
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Platform\Capabilities\Edition;
|
||||
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
/**
|
||||
* The probe a reconciler reads instead of being given a shell one-liner
|
||||
@@ -14,13 +20,16 @@ beforeEach(function () {
|
||||
$this->admin = User::factory()->create();
|
||||
});
|
||||
|
||||
function statusJson(): array
|
||||
function statusJson(bool $assoc = true): array
|
||||
{
|
||||
// Capturing the command's own output rather than asserting on lines,
|
||||
// because the contract here is the document and not the wording.
|
||||
Artisan::call('projectsend:status', ['--json' => true]);
|
||||
|
||||
return json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
// Decoded as objects where the shape itself is under test: {} and []
|
||||
// are the same array once an associative decode has flattened them,
|
||||
// and telling them apart is the point of those cases.
|
||||
return (array) json_decode(Artisan::output(), $assoc, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
test('it reports the version, the edition and the capabilities that edition grants', function () {
|
||||
@@ -73,3 +82,153 @@ test('the human form says unlimited in words', function () {
|
||||
->expectsOutputToContain('of unlimited')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------- is anybody there
|
||||
|
||||
/**
|
||||
* The one question a platform cannot answer from outside the container.
|
||||
*
|
||||
* Written against the real sign-in path rather than by inserting log rows:
|
||||
* what makes this trustworthy is that a login writes the entry, and a test
|
||||
* that writes its own entry would keep passing after the listener stopped.
|
||||
*/
|
||||
test('it reports when a staff account last signed in', function () {
|
||||
$this->travelTo('2026-08-24 21:13:32');
|
||||
Auth::login($this->admin);
|
||||
|
||||
expect(statusJson()['activity']['last_staff_login_at'])->toBe('2026-08-24T21:13:32+00:00');
|
||||
});
|
||||
|
||||
test('it reports the most recent sign-in, not the first', function () {
|
||||
$this->travelTo('2026-08-01 09:00:00');
|
||||
Auth::login($this->admin);
|
||||
|
||||
$this->travelTo('2026-08-24 21:13:32');
|
||||
Auth::login(User::factory()->create());
|
||||
|
||||
expect(statusJson()['activity']['last_staff_login_at'])->toBe('2026-08-24T21:13:32+00:00');
|
||||
});
|
||||
|
||||
test('a client signing in is not a staff sign-in', function () {
|
||||
// Clients using an installation says nothing about whether anybody is
|
||||
// still administering it, which is the question being asked.
|
||||
Auth::login(User::factory()->client()->create());
|
||||
|
||||
expect(statusJson()['activity']['last_staff_login_at'])->toBeNull();
|
||||
});
|
||||
|
||||
test('never is null, and the key is there to say so', function () {
|
||||
// "Nobody has ever signed in" and "we got no answer from the probe"
|
||||
// have to stay distinguishable, and a missing key collapses them.
|
||||
$status = statusJson();
|
||||
|
||||
expect($status['activity'])->toHaveKey('last_staff_login_at')
|
||||
->and($status['activity']['last_staff_login_at'])->toBeNull();
|
||||
});
|
||||
|
||||
test('an API token is not somebody signing in', function () {
|
||||
// An hourly integration must not make a dormant installation look
|
||||
// busy. Only Laravel's Login event writes the entry this reads, and
|
||||
// token authentication does not fire it.
|
||||
Laravel\Sanctum\Sanctum::actingAs($this->admin, ['manage_users']);
|
||||
$this->getJson('/api/v1/users')->assertOk();
|
||||
|
||||
expect(statusJson()['activity']['last_staff_login_at'])->toBeNull();
|
||||
});
|
||||
|
||||
test('the human form says never rather than nothing', function () {
|
||||
$this->artisan('projectsend:status')
|
||||
->expectsOutputToContain('Last staff login: never')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
// ------------------------------------------------- what the disk cannot say
|
||||
|
||||
/**
|
||||
* Storage is summed from the rows, not measured on the volume.
|
||||
*
|
||||
* Measuring the directory was right until external storage went live and
|
||||
* silently stopped being: an upload that resolves to a bucket leaves
|
||||
* nothing on the volume, so a figure taken from the filesystem freezes
|
||||
* while the account keeps filling.
|
||||
*/
|
||||
test('storage is what the installation holds, wherever the bytes went', function () {
|
||||
File::factory()->create(['size' => 100, 'disk' => 'files']);
|
||||
File::factory()->create(['size' => 250, 'disk' => 'files']);
|
||||
File::factory()->create(['size' => 1000, 'disk' => 'files_external']);
|
||||
|
||||
$storage = statusJson()['storage'];
|
||||
|
||||
expect($storage['bytes'])->toBe(1350)
|
||||
->and($storage['files'])->toBe(3)
|
||||
// Split by disk, which is the only way to see what is still
|
||||
// sitting locally from before a cutover.
|
||||
->and($storage['by_disk'])->toBe([
|
||||
'files' => ['bytes' => 350, 'files' => 2],
|
||||
'files_external' => ['bytes' => 1000, 'files' => 1],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a trashed file is not still costing anything', function () {
|
||||
// File's `deleted` hook takes the bytes off disk, so a soft-deleted
|
||||
// row records something that is gone rather than something held.
|
||||
$file = File::factory()->create(['size' => 500, 'disk' => 'files']);
|
||||
File::factory()->create(['size' => 100, 'disk' => 'files']);
|
||||
|
||||
$file->delete();
|
||||
|
||||
expect(statusJson()['storage']['bytes'])->toBe(100);
|
||||
});
|
||||
|
||||
test('an installation holding nothing still answers with a map', function () {
|
||||
// An empty PHP array encodes as [], and a reader unmarshalling a map
|
||||
// breaks on the day it happens to be empty rather than the day it is
|
||||
// written.
|
||||
expect(json_encode(statusJson(false)['storage']->by_disk))->toBe('{}');
|
||||
});
|
||||
|
||||
test('it reports the health a container cannot show from outside', function () {
|
||||
// A queue worker dying is invisible to anything watching the
|
||||
// container: it is still up, and zips quietly stop building.
|
||||
$health = statusJson()['health'];
|
||||
|
||||
expect($health)->toHaveKeys(['pending_migrations', 'failed_jobs', 'queues'])
|
||||
->and($health['pending_migrations'])->toBe(0)
|
||||
->and($health['failed_jobs'])->toBe(0)
|
||||
->and($health['queues'])->toHaveKeys(['default', 'zips']);
|
||||
});
|
||||
|
||||
test('the enforcement setting is echoed back as applied', function () {
|
||||
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'all');
|
||||
|
||||
expect(statusJson()['settings']['two_factor_enforcement'])->toBe('all');
|
||||
});
|
||||
|
||||
test('an unreadable enforcement value reports none, not something stricter', function () {
|
||||
// Read the way EnforceTwoFactor reads it: reporting a stricter answer
|
||||
// than the middleware actually enforces is worse than reporting none.
|
||||
app(Settings::class)->set(Setting::TwoFactorEnforcement, 'everybody-ish');
|
||||
|
||||
expect(statusJson()['settings']['two_factor_enforcement'])->toBe('none');
|
||||
});
|
||||
|
||||
// --------------------------------------------- what core cannot answer alone
|
||||
|
||||
test('a package can report what core has no way to know', function () {
|
||||
// The managed storage backend and the version of the package that
|
||||
// provides it live outside this repository. A platform knows what it
|
||||
// asked for; only the installation knows what loaded.
|
||||
Event::listen(ResolvingInstallationStatus::class, function (ResolvingInstallationStatus $event): void {
|
||||
$event->report('cloud_modules', '1.1.0');
|
||||
$event->report('managed_storage', 's3 bucket "psc-rebels"');
|
||||
});
|
||||
|
||||
expect(statusJson()['modules'])->toBe([
|
||||
'cloud_modules' => '1.1.0',
|
||||
'managed_storage' => 's3 bucket "psc-rebels"',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an installation running no packages answers with a map, not a list', function () {
|
||||
expect(json_encode(statusJson(false)['modules']))->toBe('{}');
|
||||
});
|
||||
|
||||
@@ -136,7 +136,21 @@ test('nothing is reported when uploads go to external storage instead', function
|
||||
test('the dashboard carries the verdict to the system widget', function () {
|
||||
$admin = User::factory()->create();
|
||||
|
||||
// Substituted rather than read live, for the same reason the rest of
|
||||
// this file substitutes it: the real answer depends on whatever machine
|
||||
// the suite runs on. What is under test here is that the verdict this
|
||||
// class produced reaches the widget whole — the level, and the volume
|
||||
// name the card prints alongside it.
|
||||
app()->instance(
|
||||
StorageDurability::class,
|
||||
durability(mounts('/docker/volumes/projectsend_files/_data', storage_path('app/files'))),
|
||||
);
|
||||
|
||||
$this->actingAs($admin)
|
||||
->get('/dashboard')
|
||||
->assertInertia(fn ($page) => $page->has('system'));
|
||||
->assertInertia(fn ($page) => $page->where('system.storage_durability', [
|
||||
'level' => StorageDurabilityLevel::DockerVolume->value,
|
||||
'volume' => 'projectsend_files',
|
||||
'source' => null,
|
||||
]));
|
||||
});
|
||||
|
||||
@@ -8,68 +8,18 @@ use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Updates\UpdateInstallation;
|
||||
use Illuminate\Console\OutputStyle;
|
||||
|
||||
/**
|
||||
* The ordering constraints inside UpdateInstallation are invisible in its
|
||||
* result and expensive when wrong — a queue:restart before a cache clear
|
||||
* leaves a worker on old code indefinitely, and config:cache breaks
|
||||
* TRUSTED_PROXIES silently. An ordered list of the commands it ran is the
|
||||
* only thing that can assert them, so the artisan call is a seam.
|
||||
* The command runs through Tests\Support\RecordingUpdate in every test
|
||||
* here, including the ones below that assert the real wiring rather than
|
||||
* the sequence — nothing in this file asserts that an artisan command
|
||||
* actually ran, and running them for real reaches outside this test into
|
||||
* a directory the whole suite shares. See the class, and the test at the
|
||||
* end that pins it.
|
||||
*/
|
||||
class RecordingUpdate extends UpdateInstallation
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $calls = [];
|
||||
|
||||
/** @var array<string, int> */
|
||||
public array $exitCodes = [];
|
||||
|
||||
/** @var array{route: bool, event: bool, config: bool} */
|
||||
public array $warm = ['route' => false, 'event' => false, 'config' => false];
|
||||
|
||||
/** The test database is always migrated, so this cannot be observed for real. */
|
||||
public bool $existingInstall = true;
|
||||
|
||||
protected function artisan(string $command, array $parameters = [], ?OutputStyle $output = null): int
|
||||
{
|
||||
$this->calls[] = $command;
|
||||
|
||||
return $this->exitCodes[$command] ?? 0;
|
||||
}
|
||||
|
||||
protected function warmCaches(): array
|
||||
{
|
||||
return $this->warm;
|
||||
}
|
||||
|
||||
protected function hasRunMigrationsBefore(): bool
|
||||
{
|
||||
return $this->existingInstall;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{route?: bool, event?: bool, config?: bool} $warm
|
||||
* @param array<string, int> $exitCodes
|
||||
*/
|
||||
function recordingUpdate(array $warm = [], array $exitCodes = []): RecordingUpdate
|
||||
{
|
||||
$fake = new RecordingUpdate(
|
||||
app(Illuminate\Contracts\Foundation\Application::class),
|
||||
app(App\Modules\Identity\Permissions\EnsureSystemRoles::class),
|
||||
app(Settings::class),
|
||||
app(App\Modules\Audit\ActivityLogger::class),
|
||||
);
|
||||
|
||||
$fake->warm = [...$fake->warm, ...$warm];
|
||||
$fake->exitCodes = $exitCodes;
|
||||
|
||||
app()->instance(UpdateInstallation::class, $fake);
|
||||
|
||||
return $fake;
|
||||
}
|
||||
beforeEach(function () {
|
||||
recordingUpdate();
|
||||
});
|
||||
|
||||
test('it migrates, ensures roles, links storage and restarts the queue', function () {
|
||||
$fake = recordingUpdate();
|
||||
@@ -154,8 +104,9 @@ test('it records the version it applied', function () {
|
||||
->and(app(Settings::class)->get(Setting::AppliedVersionAt))->not->toBe('');
|
||||
});
|
||||
|
||||
// The real thing, not the seam: both entrypoints run this on every boot,
|
||||
// so a second run has to be as uneventful as the first.
|
||||
// Both entrypoints run this on every boot, so a second run has to be as
|
||||
// uneventful as the first. The settings writes it asserts are the real
|
||||
// ones; only the artisan calls are recorded.
|
||||
test('running it twice is uneventful', function () {
|
||||
$this->artisan('projectsend:update')->assertSuccessful();
|
||||
$this->artisan('projectsend:update')->assertSuccessful();
|
||||
@@ -163,10 +114,11 @@ test('running it twice is uneventful', function () {
|
||||
expect(app(Settings::class)->get(Setting::AppliedVersion))->toBe(config('projectsend.version'));
|
||||
});
|
||||
|
||||
// Not through the seam: this is the one assertion that the real wiring
|
||||
// runs, and EnsureSystemRoles is the part of an update that a migration
|
||||
// cannot do for itself. AccountManager rather than Uploader — the latter
|
||||
// is legacy and deliberately never seeded.
|
||||
// The one assertion that the real wiring runs: EnsureSystemRoles is the
|
||||
// part of an update that a migration cannot do for itself, and the double
|
||||
// does not touch it — only the artisan calls are recorded. AccountManager
|
||||
// rather than Uploader — the latter is legacy and deliberately never
|
||||
// seeded.
|
||||
test('it puts back a system role somebody deleted', function () {
|
||||
Role::query()->where('name', SystemRole::AccountManager->value)->delete();
|
||||
|
||||
@@ -286,3 +238,29 @@ test('a rollback raises no welcome', function () {
|
||||
expect(app(Settings::class)->get(Setting::UpdateWelcomeTo))->toBe('')
|
||||
->and(ActivityLog::query()->where('action', Action::ApplicationUpdated)->count())->toBe(1);
|
||||
});
|
||||
|
||||
// The seam is not a convenience. bootstrap/cache holds one packages.php and
|
||||
// one services.php for the whole checkout, and `pest --parallel` gives eight
|
||||
// worker processes the same one: `clear-compiled` deletes both for all of
|
||||
// them at once. A worker that boots its application in the window between
|
||||
// that delete and its own rebuild reads an empty package manifest —
|
||||
// PackageManifest::getManifest() falls back to [] when the file it just
|
||||
// wrote is gone again — registers no package service providers, and dies on
|
||||
// the next page it renders with "Target [Inertia\Ssr\Gateway] is not
|
||||
// instantiable". It surfaced as UpdateWelcomeTest failing roughly one run in
|
||||
// six, in a file that has nothing to do with updates.
|
||||
//
|
||||
// Asserted on the files rather than on the recorded calls: what matters is
|
||||
// that nothing left this test, and a future double that forgot to intercept
|
||||
// one command would still pass a call-list assertion.
|
||||
test('it leaves the compiled caches the rest of the suite is reading alone', function () {
|
||||
$manifests = [app()->getCachedPackagesPath(), app()->getCachedServicesPath()];
|
||||
|
||||
// Both are written during the first application boot of any run, so by
|
||||
// now they are there to be deleted.
|
||||
expect(array_filter($manifests, 'file_exists'))->toBe($manifests);
|
||||
|
||||
$this->artisan('projectsend:update')->assertSuccessful();
|
||||
|
||||
expect(array_filter($manifests, 'file_exists'))->toBe($manifests);
|
||||
});
|
||||
|
||||
@@ -67,6 +67,12 @@ class ProfileUpdateTest extends TestCase
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// A second administrator, so what is under test is self-deletion
|
||||
// and not the last-administrator refusal: the factory makes an
|
||||
// administrator, and one on their own may no longer remove
|
||||
// themselves — see SoleAdministratorSelfDeletionTest.
|
||||
User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete('/settings/profile', [
|
||||
@@ -107,6 +113,10 @@ class ProfileUpdateTest extends TestCase
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Same reason as above: this is about the grace period, not about
|
||||
// who is allowed to go.
|
||||
User::factory()->create();
|
||||
|
||||
// Deleting your account has its own screen; the profile form no
|
||||
// longer carries the block or the grace period behind it.
|
||||
$this->actingAs($user)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Files\Folders\FolderService;
|
||||
use App\Modules\Files\Models\File;
|
||||
@@ -11,14 +12,19 @@ use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Models\RolePermission;
|
||||
use App\Modules\Identity\Permissions\EnsureSystemRoles;
|
||||
use App\Modules\Identity\Permissions\PermissionChecker;
|
||||
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
|
||||
use App\Modules\Platform\Settings\ExternalStorageSettings;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Updates\UpdateInstallation;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\Support\RecordingUpdate;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -282,3 +288,31 @@ function failAccountContentDisposal(): void
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the update in for one that records its artisan calls instead of
|
||||
* running them, and return it.
|
||||
*
|
||||
* Used by every test that runs `projectsend:update`, in more than one file
|
||||
* — see the class for why running the real commands is not an option in a
|
||||
* parallel suite.
|
||||
*
|
||||
* @param array{route?: bool, event?: bool, config?: bool} $warm
|
||||
* @param array<string, int> $exitCodes
|
||||
*/
|
||||
function recordingUpdate(array $warm = [], array $exitCodes = []): RecordingUpdate
|
||||
{
|
||||
$fake = new RecordingUpdate(
|
||||
app(Application::class),
|
||||
app(EnsureSystemRoles::class),
|
||||
app(Settings::class),
|
||||
app(ActivityLogger::class),
|
||||
);
|
||||
|
||||
$fake->warm = [...$fake->warm, ...$warm];
|
||||
$fake->exitCodes = $exitCodes;
|
||||
|
||||
app()->instance(UpdateInstallation::class, $fake);
|
||||
|
||||
return $fake;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use App\Modules\Platform\Updates\UpdateInstallation;
|
||||
use Illuminate\Console\OutputStyle;
|
||||
|
||||
/**
|
||||
* The real update with its artisan calls written down instead of run.
|
||||
*
|
||||
* Two reasons, and the second one is why every test that runs the command
|
||||
* uses this and not the genuine article.
|
||||
*
|
||||
* The ordering constraints inside UpdateInstallation are invisible in its
|
||||
* result and expensive when wrong — a queue:restart before a cache clear
|
||||
* leaves a worker on old code indefinitely, and config:cache breaks
|
||||
* TRUSTED_PROXIES silently. An ordered list of the commands it ran is the
|
||||
* only thing that can assert them, so the artisan call is a seam.
|
||||
*
|
||||
* And those commands are not local. `clear-compiled` deletes
|
||||
* bootstrap/cache/packages.php and bootstrap/cache/services.php, `view:clear`
|
||||
* empties storage/framework/views, `storage:link` rewrites public/storage —
|
||||
* one copy of each, shared by all eight workers of a parallel run. A worker
|
||||
* that boots its application in the window between the delete and the
|
||||
* rebuild reads an empty package manifest, registers no package service
|
||||
* providers at all, and dies on the next page it renders with
|
||||
* "Target [Inertia\Ssr\Gateway] is not instantiable". See the test in
|
||||
* UpdateCommandTest that pins this.
|
||||
*
|
||||
* Everything above the artisan call stays real: EnsureSystemRoles, the
|
||||
* settings writes, the activity log and the welcome marker all run, which
|
||||
* is what the tests in both files actually assert.
|
||||
*/
|
||||
class RecordingUpdate extends UpdateInstallation
|
||||
{
|
||||
/** @var list<string> */
|
||||
public array $calls = [];
|
||||
|
||||
/** @var array<string, int> */
|
||||
public array $exitCodes = [];
|
||||
|
||||
/** @var array{route: bool, event: bool, config: bool} */
|
||||
public array $warm = ['route' => false, 'event' => false, 'config' => false];
|
||||
|
||||
/** The test database is always migrated, so this cannot be observed for real. */
|
||||
public bool $existingInstall = true;
|
||||
|
||||
protected function artisan(string $command, array $parameters = [], ?OutputStyle $output = null): int
|
||||
{
|
||||
$this->calls[] = $command;
|
||||
|
||||
return $this->exitCodes[$command] ?? 0;
|
||||
}
|
||||
|
||||
protected function warmCaches(): array
|
||||
{
|
||||
return $this->warm;
|
||||
}
|
||||
|
||||
protected function hasRunMigrationsBefore(): bool
|
||||
{
|
||||
return $this->existingInstall;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user