37 Commits

Author SHA1 Message Date
ignacionelson 7fcfbb5c41 Merge branch feat/api-folders
Folders in API v1: list, read, create, rename, move, delete and share
2026-10-03 02:46:22 -03:00
ignacionelson e9b71993f5 Document the folder endpoints
The OpenAPI document gains the seven folder operations; `ancestors` gets
an explicit type so the schema says what it holds rather than Scramble's
guess. The guide gets a Folders section, the folder abilities, the
idempotent create under "Retries", and public folders under "Not in v1".

The abilities table was split in two by a blank line, with the groups row
left under the paragraph after it; both are back in the table.
2026-10-02 23:46:09 -03:00
ignacionelson 33bc90c9ef Test the folder API: scope, trails, placement, the delete guard and sharing 2026-10-02 23:46:09 -03:00
ignacionelson 70dc725858 Folders in the API: list, read, create, rename, move, delete and share
An integration could put a file into a folder by id but could not see,
make or arrange the folders themselves, so mirroring a directory tree
into ProjectSend was impossible over the API. The hosted AI connector
already creates, lists and shares folders.

GET /folders polls like every list (updated_since, cursor) and filters
on parent_id, top_level and search. Each folder carries its ancestors
and a display path, trimmed for a client-scoped token to the folders it
may see (BreadcrumbBuilder::visible's rule), worked out for a whole page
in two queries by FolderTrails.

POST /folders returns an existing folder of the same name in the same
place with a 200 rather than making a second one, so a retried request
is safe. PATCH renames and moves. DELETE refuses a non-empty folder with
409 unless content_action=cascade_delete is sent, and then asks
UndeletableFiles exactly as the web does. Sharing goes through
FolderSharing.

Every write uses the web's policy, scope and FolderService, and asks
Folder::uploadableBy for every parent it writes, creation included.

Public state stays web-only: the resource reports `public`, nothing here
changes it. A file's `folder` now carries `parent_id` as well.
2026-10-02 23:46:09 -03:00
ignacionelson a5b6538b31 Give folder sharing and the folder-delete guard one definition each
Sharing a folder was four steps written in the web controller: the
assignment row, the activity entry, the in-app notification and the
digest email. The hosted edition's AI connector repeated them, because
there was nothing in the core to call, and the two copies had already
drifted (one re-notifies on a repeated share, the other does not). The
folder API about to land would have been a third copy.

FolderSharing is the folder twin of FileSharing, and the web controller
now calls it. Behaviour on the web is unchanged.

The count of files a staff member may not delete inside a folder's
subtree moves out of FoldersController into UndeletableFiles, for the
same reason: deleting a folder over the API has to ask exactly the
question the web screen asks before the cascade takes files with it.
2026-10-02 23:46:09 -03:00
ignacionelson 48a1c9f227 Trim the staff breadcrumb to the library's reach, and ask before nesting into a public folder
Two edges of the staff folder screens, found while the folder API was
built to answer the same questions.

A client-scoped staff member can hold one of their clients' folders that
sits inside somebody else's tree. The breadcrumb above it named every
folder on the way up, including ones their library does not show them.
It now starts at the first folder they can reach, as the client portal's
already does (BreadcrumbBuilder::visible). Unscoped staff see the whole
trail as before.

Creating a folder did not ask Folder::uploadableBy for its parent, though
every other write of a parent_id does: a folder inside a public one is
public. Files were already refused there by the upload check, so what
this closes is an empty folder's name appearing on a public page without
upload_public. Staff holding upload_public, or creating inside a private
folder, are unaffected.
2026-10-02 23:45:51 -03:00
Ignacio Nelson 185c46fff1 Merge pull request #1807 from projectsend/feat/package-styling-hooks
Let an installed package restyle the staff area and supply its own browser icons
2026-10-02 15:44:52 -03:00
ignacionelson a8adf6f614 Show a custom logo larger again on the sign-in pages
The sign-in, password reset, setup and share-link pages drew a custom logo
in a box 80 pixels tall, up from 48 in 2.6.0. Tested on 2.6.0, a square
logo still read as small on both phone and desktop. The box is now 128
pixels tall and up to 320 wide. The card is 384 wide, so a wide logo still
fits a phone. ProjectSend's own logo is unchanged.

The Branding → Logo hint states the new size. Its existing translations
are carried over with only the numbers changed, rather than left to fall
back to English.

Reported by @jiits (#1798)
2026-10-01 16:48:53 -03:00
ignacionelson f9e08412f2 Still log a failing health check in the production image
#1804 dropped every /up request from the nginx access log, so the
container's health checks stopped flooding `docker logs`. That also hid
the failing ones: when the container goes unhealthy, the 5xx from /up is
the line someone looks for, and Docker's health status alone does not say
why.

Key the map on the status as well as the path, so only a 2xx /up is
dropped. Verified against the 2.6.0 image: a 503 /up logs, a 200 /up does
not, and a 200 /upload still logs.
2026-10-01 15:24:08 -03:00
ignacionelson 9c26d46374 Merge pull request #1804 from 01110111000001/feat/quieter-logs
Quieter logs in docker container
2026-10-01 15:23:37 -03:00
ignacionelson 60c82afe5a Let an installed package restyle the staff area and supply its own browser icons
Core imports any stylesheet a package ships under resources/css after its
own app.css, and marks the pieces worth restyling with data attributes:
the staff shell (data-surface="staff"), the header, cards, buttons with
their variant, list toolbars, table frames and the default logo marks.
The layout takes its icons from projectsend.icons when a package names
some, replacing the defaults as a set.

Core names no package and no style. With nothing installed that ships a
stylesheet or icons, nothing renders differently.
2026-09-29 17:51:47 -03:00
01110111000001 f24a8587b9 feat: disable php-fpm access logs 2026-09-27 03:19:19 +02:00
01110111000001 a640bf81ed feat: ignore nginx logs on /up parh 2026-09-27 03:18:59 +02:00
ignacionelson a9b17ddc1e Release 2.6.0 2026-09-25 15:00:17 -03:00
ignacionelson 24a94d3beb Translate the strings added in the 2026-09-25 issue run
Eight strings, in all sixteen locales: bulk delete's confirmation and its
error, the upload page's "Uploading into", and the Branding page's site-name
switch and logo size hint.
2026-09-25 02:50:18 -03:00
ignacionelson 27f994263f Label the bulk delete confirmation "Delete", not the permission name
"Delete files" is already the label of the delete_files permission, and
several locales translate it as a noun ("deletion of files"), which reads
wrongly on a button. "Delete" is translated as a verb everywhere.
2026-09-25 02:50:18 -03:00
ignacionelson 8180a66243 Drop two nullsafe operators PHPStan flags: ?? already covers a missing branding row 2026-09-25 02:49:03 -03:00
ignacionelson 1d483f6a81 Delete several files at once from the staff selection bar
The selection bar could zip and bulk-edit the ticked files, including
moving them to a folder, but deleting was one file at a time.

A Delete button now appears when at least one ticked file is one this
person may delete. It asks for confirmation and sends only those files.
The server asks each file the same question a single delete asks, through
FilePolicy, and gives each the same soft delete and the same FileDeleted
activity entry. A file the person may not delete is dropped from the
batch rather than failing it, as bulk edit already does. A batch with
nothing left to delete is a 422. The route sits before files/{file},
which would otherwise read "bulk-delete" as a file id.

Reported by @lolgufdHD (#1800)
2026-09-25 02:47:34 -03:00
ignacionelson bd26740390 Upload into the folder you are in, on the staff Files page
Inside a folder, Upload opened the upload page with no folder, so every
file landed at the top of the library and had to be moved. The client
portal already carried the folder; the staff page now does the same.

The upload page takes ?folder=, says "Uploading into <folder>", passes
it to the upload, and sends a multi-file upload back to that folder. The
same two checks as the portal's upload page: a folder outside the staff
member's library is a 404, so the page never confirms it exists, and one
they may not upload into is a 403. ChunkedUploadsController still checks
the destination again when the upload starts. While searching, the
button keeps uploading to the top, since results span folders.

Reported by @lolgufdHD (#1801)
2026-09-25 02:44:54 -03:00
ignacionelson 37c9cb839f Never remember a JSON request as the page to go back to
Saving a settings form could open Inertia's error dialog showing
{"count":0}. back() prefers the Referer and falls back to the URL the
session recorded last. Laravel records every GET not marked as Ajax, and
the notification bell's plain fetch() of its unread count is not marked,
so the poll became "the previous page". Where the Referer did not arrive,
because a proxy or a browser stripped it, the save redirected to
/notifications/unread-count, and Inertia rendered the JSON as an error.

The session middleware is swapped for a subclass that skips recording
when the request asked for JSON. The rule is about the request, not about
that one route: nothing that asked for JSON is a page anybody goes back
to. Inertia visits ask for HTML and are recorded as before, and the
middleware order is unchanged (the sorter matches the subclass by its
parent).

A test reproduces it: open the privacy form, poll the count the way the
bell does, and save with no Referer. It failed with a redirect to
/notifications/unread-count before this change.

Reported by @0xVavaldi (#1799)
2026-09-25 02:40:29 -03:00
ignacionelson 1b3f014f5f Show the logo larger on the sign-in pages, and let the site name appear under it
The sign-in, password reset, setup and share-link pages drew a custom logo
48 pixels tall, so a square logo was a 48x48 stamp. It now gets a box 80
pixels tall and up to 240 wide, so a square logo reads and a wide one still
fits a phone. ProjectSend's own logo is unchanged.

The site name appeared nowhere on those pages except as the image's alt
text. A new switch on Branding → Logo prints it under the logo. It is off
by default, because many logos already say the name and would then say it
twice. It is stored on the branding row, gated like the rest of the
screen (staff, edit_settings, branding.customize), and a withheld
capability takes it off the pages along with the logo. The branding API's
logo endpoint reports it as show_site_name.

The Logo tab now says what size to use and which formats are accepted.
SVG stays refused: it can carry script, and the logo is served from the
site's own origin. The crop tool the issue also asks for is not part of
this.

Reported by @jiits (#1798)
2026-09-25 02:38:47 -03:00
ignacionelson c795c58963 Use Pdo\Mysql::ATTR_SSL_CA, which PHP 8.5 no longer warns about
PHP 8.5 deprecated PDO::MYSQL_ATTR_SSL_CA, so loading config/database.php
printed two "Deprecated" warnings, one for each of the MySQL and MariaDB
connections. On a server that displays warnings, they appeared on every
page.

Pdo\Mysql::ATTR_SSL_CA exists since PHP 8.4, which is our minimum, so no
version check is needed. Checked by loading the real config file under
PHP 8.5 with pdo_mysql: the old file prints both warnings, and the new one
prints none and sets the same option.

Reported by @jiits (#1796)
2026-09-25 01:59:18 -03:00
ignacionelson acab833b72 Put the Docker quick start first, and fill the gaps a first install falls into
The README now opens its instructions before the screenshots, and says
what the quick start assumed: Docker Engine with Compose installed, your
own user in the docker group (so nobody reaches for sudo), and which
directory the commands run from.

The quick start also saved the file as compose.example.yaml and started
it with -f. Every later command in DOCKER.md, UPDATE.md and the migration
guide is a plain `docker compose ...`, which only finds a file called
compose.yaml, so each of them failed with "no configuration file
provided". It is now saved as compose.yaml, as the Docker Hub page
already said, with one line for people who kept the old name.

The migration guide covered "Legacy on this machine, ProjectSend in
Docker" in one sentence. It now has a worked route through a bundle. The
exporter runs with the host's own PHP, where Legacy's `localhost`
database really is local, so no container networking is needed. The
guide also says why Direct is harder there: the database, and hardlinks
that cannot cross a mount. Step 2 was run against a real v1 install on
the host (60 files, 63 MB).

Reported by @lukatong (#1635), with the install and migration gaps
pointed out by @jjoelc.
2026-09-25 01:34:38 -03:00
ignacionelson a150dc4955 Let a disk sign its links with a separate key
Downloads, previews and public links from managed storage have failed in
every browser since late August with the bucket's AccessDenied XML. The
platform pins each instance's R2 key to our servers' IP (portal 0125be7,
2026-08-26). Core started redirecting to signed URLs at about the same
time (57540164, d6fd5a91). A signed URL carries every restriction of the
key that signed it, so it worked from the server and nowhere else.

A disk can now name another disk in `signing_disk`, and links are signed
with that one. The platform gives it a read-only key without the IP pin.
The read-write key stays pinned. A name that points at no configured disk
is ignored, and the file's own disk signs as before. Uploads are
unaffected: they go through the server, and nothing else signs.
2026-09-24 22:56:00 -03:00
ignacionelson bccf3d1f29 Stop serving a self-deleted account's files, and let them go at once
Reported from the shared free instance. A client deleted their own account
on 2026-09-18. Their five files kept serving through share links with no
expiry for the whole 30-day grace period. Somebody who asked to leave
stayed published.

Rule 1: once an account is soft-deleted, its uploads are served to nobody
but staff. That covers the client scope (assignment, group, shared
folder), share links, the public listing, public comments and zips.
Staff keep them, because the grace period exists to undo a mistake.
Nothing is deleted and share links are kept, so a restored account is
served again. A withdrawn share link answers like a token that never
existed. In practice this only meets self-deleted accounts: an
administrator deleting an account that owns anything must already choose
to delete or reassign it.

Rule 2, new in Privacy settings: when someone deletes their own account,
their files are deleted "when the grace period ends" (the default, and
today's behaviour) or "right away". "Right away" uses
DeletedAccountContent's cascade: their own uploads, and their folders only
if nothing else is left inside. It runs in the same transaction as the
account delete. A platform can force "right away" through the new
ResolvingSelfDeletion hook. The screen then shows the choice as set by
the hosting plan instead of offering a switch.

A second setting decides whose deletion both rules apply to: any account
(the default) or clients only. A staff member's uploads are often the
organization's work for its clients.

The delete-account screen now says what happens to the files before the
person confirms. New strings are in all sixteen locales.
2026-09-24 16:58:48 -03:00
ignacionelson 4524b75c9d Let a hosted plan switch zip downloads off with downloads.zip
A new capability, granted by both editions. Self-hosted installs keep zip
downloads as they are. A platform removes it through
PROJECTSEND_CAPABILITIES_DISABLED. The free shared instances do this,
because building an archive holds the zips worker, the disk and a CPU on
a server that thousands of accounts share.

- The three zip routes sit behind capability:downloads.zip, so a
  hand-made request gets a 404, not just a missing button.
- BuildZipDownloadJob refuses a build that was queued before the key went
  away. The row ends failed and is never stamped as started.
  StalledZipBuilds stays quiet when the key is off, so leftover rows
  raise no worker banner.
- The zip buttons are hidden. In the portal, the checkboxes and the
  selection bar are hidden too, since they exist only to pick files for a
  zip. Staff /files keeps its checkboxes, which also drive bulk edit.
- Archives already built are not touched. They expire on the normal
  purge schedule.
- A guard test walks the router. It fails if any route that reaches
  ZipDownloadsController, or dispatches the build job, lacks the
  middleware. No API route builds zips today.

The case goes last in the enum, because the control plane reads keys in
enum order.
2026-09-24 15:39:44 -03:00
ignacionelson ca85c8a7e3 Translate the storage rows and the password dialog's rate-limit message
Eight strings, in all sixteen locales: the seven the System card gained
in #1794 (where files are stored, what is free, the temporary upload
space and why it exists), and the password dialog's "Too many attempts",
which until now was only in Spanish.
2026-09-21 22:47:50 -03:00
ignacionelson 42721b1bab Merge pull request #1794 from JensS/fix/storage-capacity-display
Show object storage and temporary upload capacity separately
2026-09-21 21:49:24 -03:00
ignacionelson ac5803b773 Merge pull request #1792 from JensS/fix/concurrent-upload-reservations
Fix premature 413 errors for concurrent upload chunks
2026-09-21 18:37:45 -03:00
ignacionelson 86edbc640d Remove the duplicate custom-pr-sign-comment that broke the CLA workflow
c9a4b552 added custom-pr-sign-comment to close a hole that was not
there: the key was already set further down the same block, with the
same value, and has been since 2.0.0. The action was already comparing
the whole comment, so a comment wrapping the phrase in other text was
never recorded as a signature, and loosening the job filter in
0a7330d5 opened nothing. The second copy made the file invalid, and
GitHub stopped running the CLA check at all.

This removes the copy and says at the original why it is set, since it
repeats the action's default phrase and looks removable.
2026-09-21 18:26:43 -03:00
ignacionelson c9a4b5520d Only record a CLA signature when the comment is the phrase
The action, left to its default, searches a comment for the signing
phrase, so a single line such as "I LIE, I have read the CLA Document
and I hereby sign the CLA. I do not sign it" was recorded as a
signature. The exact match in the job filter used to block that, but
only by accident, and it also blocked @JensS's real signature, which
had line breaks after it. Loosening that filter in 0a7330d5 let both
through.

custom-pr-sign-comment makes the action compare the whole comment,
trimmed and lowercased, against the phrase. Trailing line breaks still
sign; anything else around the phrase does not. The job filter stays
loose, since it only decides whether a runner starts.
2026-09-21 18:25:14 -03:00
ignacionelson 0a7330d5cd Accept a CLA signature that has line breaks after it
The CLA job only ran for a comment exactly equal to the signing phrase.
@JensS signed on #1792 with the phrase followed by line breaks
("...sign the CLA\r\n\r\n\n"), the comparison failed, the job was
skipped, and the signature was never recorded, so all three of his pull
requests still show the CLA as unsigned.

The action itself matches the phrase loosely. The filter in front of it
now does too: contains() for the signature, startsWith() for recheck,
both case-insensitive. It still keeps the job off ordinary comments,
which is what it is there for.
2026-09-21 18:21:04 -03:00
ignacionelson 3a3fd5358d Let directory accounts confirm their password
The confirm-password screen hid its field from every account that was
not Local, and told it to set a password instead. That is right for an
account a provider created, which has no password anybody has seen. It
is wrong for a directory account: its password is the directory's,
PasswordVerification accepts it, and /settings/password refuses to let
it set another. So an LDAP account could not get past the confirmation
at all, and everything behind it, turning on two-factor included, was
out of reach. The new dialog copied the same question.

Both now ask whether the account came from a provider, and the prop is
called has_password, which is what it means. The dialog also clears
the typed password when it closes or once it has been used, instead of
keeping it in component state.
2026-09-21 18:13:24 -03:00
ignacionelson a45eae315c Ask for the password over the page instead of throwing the form away
password.confirm redirected every write to the confirm-password screen.
A redirect cannot carry a POST body, and Redirector::guest() only
remembers the exact URL of a GET, so after confirming, the user landed
back on an empty form and the action never ran. On the API token forms
that meant typing the name, the scopes and the expiry again.

An Inertia request now gets a 423 marked X-Password-Confirmation. A
dialog mounted around every page catches it, asks for the password over
the current page, and sends the refused request again with the same data
and callbacks, so the form finishes as if nothing happened. The check
itself is still the framework's. Plain form posts and JSON clients are
answered as before, and accounts with no local password are offered a
way to set one, as the confirm screen does.
2026-09-21 18:05:54 -03:00
Jens 5902e7ab32 Report actual file storage and temporary upload capacity separately 2026-09-20 17:58:08 +02:00
Jens d3f213da16 Fix premature 413 responses for concurrent upload chunks 2026-09-20 16:46:00 +02:00
ignacionelson 60171799e7 Keep "No folder" inside the client's own folder, not the library root
Reported by binghuo. With per-client folders switched on, a client editing
one of their own files could choose "No folder" and the file left their
home for the root of the library — beside the staff folders, where the
administrator's own things are. The feature exists precisely to stop that
mess, and the editor was the one door still open to it.

Uploading resolves an absent folder to the client's home, and so does
creating a folder without naming a parent. The portal's file editor did
not, so the rule held on two paths out of three.

Now it holds on all three: update() resolves a null folder to the home
where the installation gives them one, and the editor stops offering "No
folder" at all in that case — there is no such place for this client — and
preselects their home for a file that has none.

An installation with the setting off is unchanged: no folder still means
no folder, because there every client's file sits at the root.
2026-09-20 10:44:25 -03:00
114 changed files with 4938 additions and 297 deletions
+15 -2
View File
@@ -41,11 +41,18 @@ jobs:
# `issue.pull_request` is present only when the comment is on a pull
# request; comments on ordinary issues have nothing for this action to
# check.
#
# Loose on purpose, never `==`. This only decides whether a runner
# starts; whether a comment is a signature is decided by the action,
# strictly, against `custom-pr-sign-comment` below. An exact match here
# threw away a real signature that arrived with trailing line breaks
# ("...sign the CLA\r\n\r\n"), which the action -- it trims first --
# would have accepted. `contains` and `startsWith` ignore case.
if: >-
github.event_name == 'pull_request_target'
|| (github.event.issue.pull_request
&& (github.event.comment.body == 'recheck'
|| github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'))
&& (startsWith(github.event.comment.body, 'recheck')
|| contains(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA')))
runs-on: ubuntu-latest
steps:
- name: CLA check
@@ -74,6 +81,12 @@ jobs:
Please read the **[CLA]($pathToCLADocument)**, then post exactly this as a comment
on this pull request:
# Not decoration, although it repeats the action's default phrase.
# Set, it makes the action compare the whole comment, trimmed and
# lowercased, against it. Unset, the action searches the comment
# for the phrase instead, and "I LIE, I have read the CLA Document
# and I hereby sign the CLA. I do not sign it" on one line would
# be recorded as a signature.
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
custom-allsigned-prcomment: 'CLA signed — thanks. A maintainer will review this shortly.'
lock-pullrequest-aftermerge: false
+53
View File
@@ -10,6 +10,59 @@ Anything under **⚠️ Important — do these yourself** is something you have
we did. It sits at the top of a release for that reason. Older entries call the same section
**Upgrade notes**.
## 2.6.0 — 25 September 2026
Mostly fixes: files and folders are easier to tidy, the sign-in pages carry your brand better, and
someone who deletes their own account stops being published straight away.
**Added**
- **Delete several files at once** from the selection bar on the Files page, with one confirmation.
- **Upload inside a folder puts the files in that folder**, and brings you back to it afterwards.
- **Show your site name under the logo** on the sign-in and download pages, from Branding → Logo.
- **Choose what happens to someone's files when they delete their own account**: removed right away,
or at the end of the grace period — and whether that applies to everyone or only to clients.
Found under Settings → Privacy.
**Changed**
- **A deleted account's files stop being shared at once.** From the moment someone deletes their own
account, their files are visible only to staff — not to the clients and groups they were shared
with, not through share links, not on the public pages — until the account is erased. Restoring
the account brings them back.
- **Your logo is shown larger on the sign-in and download pages**, so a square logo is clearly
visible. The Branding screen now says what size to use.
**Fixed**
- Saving a settings form could show an error dialog containing `{"count":0}` instead of saving.
- A file upload running several parts at once could be refused near the end with "too large".
- The System card reported the server's free disk space as file storage on installations that keep
files in S3; it now shows the two separately.
- Confirming your password no longer throws away the form you were filling in.
- LDAP accounts can now get past the password confirmation, which kept them from turning on
two-factor authentication.
- With per-client folders on, a client choosing "No folder" moved the file out of their own folder.
- PHP 8.5 no longer prints deprecation warnings from the database configuration.
- The Docker quick start now saves `compose.yaml`, so the `docker compose` commands in the other
guides work as written. If you saved `compose.example.yaml`, rename it to `compose.yaml`.
- The Docker and migration guides now cover installing Docker, and migrating from a Legacy install
on the same machine.
Thanks to [@JensS](https://github.com/JensS), binghuo, [@lukatong](https://github.com/lukatong),
[@jjoelc](https://github.com/jjoelc), [@jiits](https://github.com/jiits),
[@0xVavaldi](https://github.com/0xVavaldi) and [@lolgufdHD](https://github.com/lolgufdHD) for
reporting and fixing.
### Issues closed since 2.5.0
- [#1635](https://github.com/projectsend/projectsend/issues/1635) — Docs: consider moving the Docker quick start above screenshots
- [#1795](https://github.com/projectsend/projectsend/issues/1795) — Slightly confused regarding the reviews
- [#1796](https://github.com/projectsend/projectsend/issues/1796) — PHP 8.5.10: PDO::MYSQL_ATTR_SSL_CA Deprecated Warning
- [#1799](https://github.com/projectsend/projectsend/issues/1799) — Inertia error from notifications/unread-count because of JSON response
- [#1800](https://github.com/projectsend/projectsend/issues/1800) — Add Bulk-Edit for Moving and Deleting files
- [#1801](https://github.com/projectsend/projectsend/issues/1801) — Add Upload into Folder
## 2.5.0 — 18 September 2026
**Added**
+1 -1
View File
@@ -218,7 +218,7 @@ its own directory the first time it starts.
### 2. Point the compose file at them
`compose.example.yaml` is yours — you downloaded and edited it — so change the volumes in place
Your `compose.yaml` is yours — you downloaded and edited it — so change the volumes in place
rather than layering an override on top:
```yaml
+58 -2
View File
@@ -180,6 +180,7 @@ are listed at each step.
|---|---|
| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) |
| Legacy is on **another server**, or on hosting you cannot reach from the new box | [**Bundle**](#step-3b--bundle-different-machines) |
| Legacy runs on this machine's own web server, and ProjectSend runs **in Docker** | **Bundle** — see [below](#legacy-on-this-machine-projectsend-in-docker) |
Direct is faster and simpler. It copies your files by default, and it can also *hardlink* them
instead when you ask it to — on a single filesystem that writes no bytes at all, so 400 GB migrates
@@ -208,8 +209,63 @@ file bytes:
| `move` | Takes the bytes out of Legacy. Fast and frees disk — and **cannot be undone** |
| `defer` | Writes no bytes at all. For importing the database now and moving half a terabyte overnight |
If ProjectSend runs in Docker, the Legacy directory has to be visible **inside the app container**
— bind-mount it there, and use the container's path, not the host's.
If ProjectSend runs in Docker, Direct needs two things the container does not have by default: the
Legacy directory mounted inside it, and a way to reach Legacy's database. That database is usually
at `localhost` in Legacy's config, and inside a container `localhost` is the container itself.
Hardlinks also cannot cross into a mount, so `--files=hardlink` quietly becomes a copy. When
Legacy runs on the same machine's own web server, the bundle route below is simpler and needs
none of that.
### Legacy on this machine, ProjectSend in Docker
The usual shape of an upgrade: Legacy was copied into a LAMP server, and the new install follows
[Getting started](README.md#getting-started). Use a bundle. The exporter runs with the PHP your
Legacy site already uses, on the host, where `localhost` really is Legacy's database. Nothing has
to reach across into the container except one directory at the end.
Every command below runs from the directory that holds your `compose.yaml`, after the tool is
installed as described in [Step 1](#if-you-are-running-the-official-docker-image).
**1. Take the exporter out of the container:**
```sh
docker compose cp \
app:/var/www/html/vendor/projectsend/v1-migration-tool/bin/projectsend-v1-export.php .
```
**2. Export, on the host.** Point `--install` at the directory Legacy runs from, the one that holds
`includes/sys.config.php`. `--files=copy` puts the files in the bundle too, so this needs free
disk space about the size of Legacy's `upload/files` directory:
```sh
php projectsend-v1-export.php --install=/var/www/projectsend-legacy --preflight
php projectsend-v1-export.php --install=/var/www/projectsend-legacy --out=/srv/ps-export --files=copy
```
If `php` says it cannot connect to the database, run it as a user who can read Legacy's config and
use the same `php` your web server uses.
**3. Put the bundle inside the container**, and give it to the user the application runs as:
```sh
docker compose cp /srv/ps-export app:/tmp/ps-export
docker compose exec app chown -R www-data:www-data /tmp/ps-export
```
For a very large install, mount it instead of copying it: add `- /srv/ps-export:/tmp/ps-export:ro`
under the app service's `volumes:` in `compose.yaml`, and run `docker compose up -d`. Take the line
out again when you are done.
**4. Carry on from [Step 4](#step-4--read-the-preflight)** with the bundle's path inside the
container:
```sh
docker compose exec -u www-data app php artisan projectsend:migrate:preflight --bundle=/tmp/ps-export
docker compose exec -u www-data app php artisan projectsend:migrate:import --bundle=/tmp/ps-export
```
When the import is verified, delete `/srv/ps-export` on the host and the copy in the container
(`docker compose exec app rm -rf /tmp/ps-export`). Your Legacy install is never written to.
---
+32 -20
View File
@@ -54,34 +54,29 @@ out in [LICENSING.md](LICENSING.md).
- Privacy controls, including GDPR-grade account erasure with a grace period
- Local disk, S3-compatible storage, or Google Cloud Storage
## Screenshots
<p align="center">
<img src=".github/screenshots/dashboard.png" alt="The dashboard: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity" width="900">
</p>
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
<p align="center">
<img src=".github/screenshots/files.png" alt="The file library, showing folders and files with thumbnails, sharing status and download counts" width="900">
</p>
<p align="center"><em>Your library — folders, categories, and who each file is shared with.</em></p>
<p align="center">
<img src=".github/screenshots/portal.png" alt="A client's own page, listing the files shared with them with download buttons" width="900">
</p>
<p align="center"><em>What your client sees — only their files, nothing else.</em></p>
## Getting started
**With Docker** — the quickest path, and the one we recommend. Nothing to build: the published
image ships with its dependencies and its frontend already compiled.
You need Docker Engine with the Compose plugin. If the machine does not have it yet,
[install it](https://docs.docker.com/engine/install/) and then
[let your own user run it](https://docs.docker.com/engine/install/linux-postinstall/): add yourself
to the `docker` group, then log out and back in. Without that step every command below fails with
"permission denied", and putting `sudo` in front of it is not the fix.
Then, in an empty directory of your choosing — `/srv/projectsend` is a good one — run:
```sh
curl -O https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
# edit the passwords and APP_URL in it, then:
docker compose -f compose.example.yaml up -d
curl -o compose.yaml https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
# edit the passwords and APP_URL in compose.yaml, then:
docker compose up -d
```
Every `docker compose` command in these guides runs from that same directory, and finds the file
because it is called `compose.yaml`. If you saved it under its original name, `compose.example.yaml`,
rename it: `mv compose.example.yaml compose.yaml`.
Open `APP_URL` and the first thing you see is a setup screen that creates your administrator
account — or uncomment `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first, with a password of
your own, and it is created for you.
@@ -103,6 +98,23 @@ installation: the dependencies and the compiled frontend are deliberately not in
needs Composer and npm before it runs. **[CONTRIBUTING.md](CONTRIBUTING.md)** has the sequence, and
it is short.
## Screenshots
<p align="center">
<img src=".github/screenshots/dashboard.png" alt="The dashboard: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity" width="900">
</p>
<p align="center"><em>The dashboard — what is in the installation, and what has been happening in it.</em></p>
<p align="center">
<img src=".github/screenshots/files.png" alt="The file library, showing folders and files with thumbnails, sharing status and download counts" width="900">
</p>
<p align="center"><em>Your library — folders, categories, and who each file is shared with.</em></p>
<p align="center">
<img src=".github/screenshots/portal.png" alt="A client's own page, listing the files shared with them with download buttons" width="900">
</p>
<p align="center"><em>What your client sees — only their files, nothing else.</em></p>
## Coming from ProjectSend Legacy?
The previous generation of ProjectSend lives on at
@@ -7,6 +7,7 @@ use App\Modules\Identity\AuthSource;
use App\Modules\Identity\PasswordVerification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response as HttpResponse;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -27,7 +28,13 @@ class ConfirmablePasswordController extends Controller
// has seen. The screen offers to set one instead of asking for
// it, which is the only way past this for those accounts, and
// this screen stands in front of two-factor enrolment.
'has_local_password' => $user->auth_source === AuthSource::Local,
//
// Social, not "anything but Local": a directory account has a
// password -- the directory's -- and store() accepts it. Asking
// whether the account was Local told those accounts to set one
// here instead, which /settings/password refuses them, and left
// them no way past this screen at all.
'has_password' => $user->auth_source !== AuthSource::Social,
]);
}
@@ -41,8 +48,12 @@ class ConfirmablePasswordController extends Controller
* their local hash is a Str::password(64) nobody has ever seen -- and
* this screen stands in front of enrolling in two-factor, so those
* accounts could not enrol at all.
*
* Asked for JSON, it answers with a bare 204: that is the password
* dialog (RequirePasswordConfirmation), which stays on the page and
* sends the refused request again itself, so there is nowhere to go.
*/
public function store(Request $request, PasswordVerification $passwords): RedirectResponse
public function store(Request $request, PasswordVerification $passwords): RedirectResponse|HttpResponse
{
$user = $request->user();
assert($user !== null);
@@ -55,6 +66,10 @@ class ConfirmablePasswordController extends Controller
$request->session()->put('auth.password_confirmed_at', time());
if ($request->expectsJson()) {
return response()->noContent();
}
return redirect()->intended(route('dashboard', absolute: false));
}
}
@@ -8,7 +8,9 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Clients\ClientFieldContext;
use App\Modules\Clients\ClientPortalCustomFields;
use App\Modules\Files\DeletedAccountContent;
use App\Modules\Identity\Erasure\ErasureSchedule;
use App\Modules\Identity\Erasure\SelfDeletion;
use App\Modules\Identity\StaffAccounts;
use App\Modules\Identity\StartPage;
use App\Modules\Identity\StartPages;
@@ -19,6 +21,7 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
@@ -67,10 +70,20 @@ class ProfileController extends Controller
* you scroll past on the way to saving your email address. The delete
* itself still goes to destroy() below.
*/
public function deleteAccount(): Response
public function deleteAccount(Request $request): Response
{
$user = $request->user();
$selfDeletion = app(SelfDeletion::class);
$applies = $user !== null && $selfDeletion->appliesTo($user);
return Inertia::render('settings/delete-account', [
'erasureGraceDays' => (int) app(Settings::class)->get(Setting::AccountErasureGraceDays),
// What happens to their files, said before they confirm. Both
// follow the account's own type (SelfDeletion::appliesTo), so a
// staff member on a "clients only" installation is told
// neither, because neither happens to them.
'filesWithdrawn' => $applies,
'filesDeletedImmediately' => $applies && $selfDeletion->deletesFilesImmediately(),
]);
}
@@ -132,10 +145,27 @@ class ProfileController extends Controller
// Self-deletion: soft delete now, permanent GDPR erasure after
// the disclosed grace period (Setting::AccountErasureGraceDays).
app(ErasureSchedule::class)->apply($user);
$user->delete();
//
// One transaction with the files, for the reason
// ClientsController::destroy gives: a deletion whose second half
// failed must not leave the account gone and the files it
// promised to delete still there.
DB::transaction(function () use ($user): void {
app(ErasureSchedule::class)->apply($user);
$user->delete();
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
app(ActivityLogger::class)->log(Action::UserDeleted, $user, context: ['name' => $user->name]);
// Only what they own, by the rule an administrator's delete
// uses: their uploads, and their folders only if nothing else
// is left inside them. See SelfDeletion.
$selfDeletion = app(SelfDeletion::class);
if ($selfDeletion->appliesTo($user) && $selfDeletion->deletesFilesImmediately()) {
$result = app(DeletedAccountContent::class)->cascadeDelete($user);
app(ActivityLogger::class)->log(Action::AccountContentCascadeDeleted, context: ['name' => $user->name, ...$result]);
}
});
$request->session()->invalidate();
$request->session()->regenerateToken();
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Session\Middleware\StartSession as FrameworkStartSession;
use Illuminate\Contracts\Session\Session;
/**
* The framework's session middleware, except that a request asking for
* JSON is never remembered as "the previous page".
*
* `back()` prefers the Referer header and falls back to the URL the
* session recorded last. Laravel records every GET not marked as Ajax,
* and a plain fetch() is not marked. So the notification bell's poll for
* its unread count became the previous page. Wherever the Referer did not
* arrive, because a proxy or a browser stripped it, saving any settings
* form redirected to /notifications/unread-count, and Inertia showed the
* raw {"count":0} in an error dialog (#1799).
*
* The rule is about the request, not about that one route: nothing that
* asked for JSON is a page anybody goes back to. Inertia visits ask for
* HTML, so they are recorded exactly as before.
*/
class StartSession extends FrameworkStartSession
{
protected function storeCurrentUrl(Request $request, $session): void
{
if ($request->wantsJson()) {
return;
}
/** @var Session $session */
parent::storeCurrentUrl($request, $session);
}
}
@@ -30,6 +30,7 @@ use App\Modules\Platform\News\NewsItems;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Storage\StorageDurability;
use App\Modules\Platform\Storage\StorageCapacity;
use App\Modules\Platform\System\SystemEnvironment;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use Illuminate\Database\Eloquent\Builder;
@@ -56,6 +57,7 @@ class DashboardController extends Controller
private readonly Settings $settings,
private readonly ApiUsage $apiUsage,
private readonly StorageDurability $storageDurability,
private readonly StorageCapacity $storageCapacity,
private readonly FileDelivery $fileDelivery,
private readonly Installation $installation,
private readonly TimezoneRegistry $timezones,
@@ -98,7 +100,7 @@ class DashboardController extends Controller
: null,
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo($user) : null,
// Both editions — informational content, not an update action,
// so no Capability check alongside the permission (unlike
// 'system' above).
@@ -485,10 +487,8 @@ class DashboardController extends Controller
/**
* @return array<string, array<string, bool|int|string|null>|bool|int|string|null>
*/
private function systemInfo(): array
private function systemInfo(User $viewer): array
{
$freeBytes = @disk_free_space(storage_path('app/files'));
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
// call from the request path. null means either no successful
// check yet, or the current version is already the latest.
@@ -497,7 +497,7 @@ class DashboardController extends Controller
return [
...$this->environment->toArray(),
'storage_used_bytes' => (int) File::query()->sum('size'),
'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes,
...$this->storageCapacity->inspect($viewer),
'update_available' => $release !== null,
'latest_version' => $release['version'] ?? null,
'release_url' => $release['url'] ?? null,
@@ -125,7 +125,7 @@ class PublicFileCommentsController extends Controller
private function guard(string $publicSlug, File $file): void
{
abort_unless($this->settings->get(Setting::PublicListingSlug) === $publicSlug, 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
// Same answer as the file's own public page, which 404s a file
// that is not available: otherwise a pending or quarantined file
// could be discussed, and found to exist, by anybody.
@@ -87,7 +87,7 @@ class StoredFileResponse
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$url = Storage::disk($this->signingDisk($file->disk))->temporaryUrl(
$file->path,
now()->addSeconds($linkSeconds),
['ResponseContentDisposition' => $disposition],
@@ -98,4 +98,30 @@ class StoredFileResponse
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
}
/**
* The disk whose credentials sign the link: the file's own, unless
* that disk names another in `signing_disk`.
*
* A signed URL carries every restriction of the key that signed it.
* A hosted instance's read-write key only works from our own servers,
* which is right for the key and wrong for a link a browser follows:
* every download, preview and public link got AccessDenied from the
* bucket. So a platform can give the disk a second, read-only key,
* free of that restriction and used for nothing but signing. The
* signing disk must point at the same bucket and prefix. The platform
* that configures one is also responsible for that.
*
* A name that points at no configured disk is ignored rather than
* obeyed. Failing every download over a typo would be worse than
* signing with the key the file was stored with.
*/
private function signingDisk(string $disk): string
{
$signing = config("filesystems.disks.{$disk}.signing_disk");
return is_string($signing) && $signing !== '' && is_array(config("filesystems.disks.{$signing}"))
? $signing
: $disk;
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Folders;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\Folder;
/**
* The ancestors of a whole page of folders, in two queries however long the
* page is, trimmed to what the viewer may see.
*
* BreadcrumbBuilder answers this for one folder on a screen. A list
* endpoint needs it for every row, and a query per row is the cost a
* listing must not have.
*
* Trimmed the way BreadcrumbBuilder::visible() trims the client portal's
* trail: the list starts at the first ancestor the viewer can reach, since
* a client-scoped staff member holding a client's folder deep in somebody
* else's tree has no business reading the names of the folders above it.
* An unscoped staff member reaches every folder, so for them nothing is
* ever trimmed.
*/
class FolderTrails
{
public function __construct(
private readonly StaffLibraryScope $scope,
) {}
/**
* @param iterable<Folder> $folders
* @return array<int, list<array{id: int, name: string}>> folder id => its visible ancestors, root first, itself excluded
*/
public function ancestors(iterable $folders, User $viewer): array
{
$chains = [];
$allIds = [];
foreach ($folders as $folder) {
$ids = $folder->ancestorIds();
$chains[$folder->id] = $ids;
array_push($allIds, ...$ids);
}
$allIds = array_values(array_unique($allIds));
if ($allIds === []) {
return array_map(fn (): array => [], $chains);
}
$names = Folder::query()->whereIn('id', $allIds)->pluck('name', 'id')->all();
$visible = $viewer->isClientScoped()
? array_flip($this->scope->folders($viewer)->whereIn('folders.id', $allIds)->pluck('folders.id')->all())
: array_flip($allIds);
$out = [];
foreach ($chains as $folderId => $ids) {
$trail = [];
$reached = false;
foreach ($ids as $id) {
$reached = $reached || isset($visible[$id]);
if ($reached && isset($names[$id])) {
$trail[] = ['id' => $id, 'name' => (string) $names[$id]];
}
}
$out[$folderId] = $trail;
}
return $out;
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Folders;
use App\Models\User;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use Illuminate\Database\Eloquent\Builder;
/**
* How many files in a folder's subtree a staff member may not delete.
*
* 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 by
* FolderPolicy. Every staff path that deletes a folder asks this first, so
* the web screen and the API cannot disagree about what a cascade may take.
*
* 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.
*
* The client half of the same rule is MyFoldersController::destroy.
*/
class UndeletableFiles
{
public function __construct(
private readonly StaffLibraryScope $scope,
) {}
public function count(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();
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Folders\FolderTrails;
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
use App\Modules\Files\Http\Resources\Api\FolderResource;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Sharing\FolderSharing;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Sharing a folder with a client or a group: `{type: client|group, id}`.
*
* A client a folder is shared with sees everything inside it, including
* folders and files added later.
*
* Both the target resolution (ResolvesShareTargets) and the effects
* (FolderSharing — the row, the activity entry, the in-app notification,
* the digest email) are shared with the web controller, so the two surfaces
* cannot drift. "May share" is "may edit", as on the web.
*/
class FolderAssignmentsController extends Controller
{
use ResolvesShareTargets;
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly FolderSharing $sharing,
private readonly FolderTrails $trails,
) {}
/**
* Share a folder.
*
* Sharing it again with the same client or group leaves one share.
*/
public function store(Request $request, Folder $folder): FolderResource
{
Gate::authorize('update', $folder);
[$assignable, $targetName] = $this->resolveRequestedTarget(
$request,
__('Folders can only be shared with clients or groups.'),
);
$this->sharing->assign($folder, $assignable, $targetName);
return $this->resource($request, $folder);
}
/**
* Stop sharing a folder.
*/
public function destroy(Request $request, Folder $folder): FolderResource
{
Gate::authorize('update', $folder);
[$assignable, $targetName] = $this->resolveRequestedTarget(
$request,
__('Folders can only be shared with clients or groups.'),
);
$this->sharing->unassign($folder, $assignable, $targetName);
return $this->resource($request, $folder);
}
private function resource(Request $request, Folder $folder): FolderResource
{
$folder = $folder->fresh() ?? $folder;
$folder->load('assignments.assignable');
$user = $request->user();
if ($user !== null) {
$folder->setRelation('trail', collect($this->trails->ancestors([$folder], $user)[$folder->id] ?? []));
}
return new FolderResource($folder);
}
}
@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\Support\PollingQuery;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Folders\FolderTrails;
use App\Modules\Files\Folders\UndeletableFiles;
use App\Modules\Files\Http\Resources\Api\FolderResource;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Support\Rules;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\Rule;
/**
* The staff library's folders.
*
* Which folders a token sees is the same question the library screen
* answers, so a staff member limited to their assigned clients gets exactly
* the folders they see on the web. Every write goes through the same
* service, policy and placement rule as the web screen.
*/
class FoldersController extends Controller
{
public function __construct(
private readonly StaffLibraryScope $scope,
private readonly PollingQuery $polling,
private readonly FolderService $folders,
private readonly FolderTrails $trails,
private readonly UndeletableFiles $undeletable,
private readonly ActivityLogger $activity,
) {}
/**
* List folders.
*
* Cursor paginated, like every list. Pass `updated_since` to poll for
* folders created, renamed or moved since a point in time. `parent_id`
* lists the folders directly inside one folder, and `top_level=1` the
* folders at the top of the library.
*
* Moving a folder updates the folder itself and every folder under it,
* so a poll sees the whole moved subtree.
*/
public function index(Request $request): AnonymousResourceCollection
{
$user = $request->user();
assert($user !== null);
$filters = $request->validate($this->polling->rules() + [
'parent_id' => ['nullable', 'integer'],
'top_level' => ['nullable', 'boolean'],
'search' => ['nullable', 'string', 'max:255'],
]);
$query = $this->scope->folders($user);
if (($filters['parent_id'] ?? null) !== null) {
$query->where('folders.parent_id', (int) $filters['parent_id']);
}
if ($request->boolean('top_level')) {
$query->whereNull('folders.parent_id');
}
if (($filters['search'] ?? null) !== null) {
$query->where('folders.name', 'like', '%'.$filters['search'].'%');
}
$page = $this->polling->paginate($request, $query, 'folders');
/** @var Collection<int, Folder> $items */
$items = collect($page->items());
$this->attachTrails($items, $user);
return FolderResource::collection($page);
}
/**
* Show a folder, with the clients and groups it is shared with.
*/
public function show(Request $request, Folder $folder): FolderResource
{
Gate::authorize('view', $folder);
return $this->resource($folder, $request->user());
}
/**
* Create a folder.
*
* At the top of the library, or inside `parent_id`. Requires the
* `create_own_folders` ability, and `upload` with it.
*
* If a folder with the same name already exists in the same place, that
* folder is returned with a 200 instead of a second one being made, so
* retrying a request is safe. A new folder answers 201.
*/
public function store(Request $request): JsonResponse
{
$user = $request->user();
assert($user !== null);
// The same pair FoldersController::store asks on the web: a folder
// nobody can put anything into is no use.
abort_unless($user->can('create_own_folders') && $user->can('upload'), 403);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'parent_id' => Rules::folderId(),
]);
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
// A folder inside a public one is public, so creating one there is
// placing content into it (Folder::uploadableBy).
abort_unless(Folder::uploadableBy($user, $parent), 403);
$existing = $this->scope->folders($user)
->where('folders.parent_id', $parent?->id)
->where('folders.name', $validated['name'])
->orderBy('folders.id')
->first();
if ($existing instanceof Folder) {
return $this->resource($existing, $user)->response()->setStatusCode(200);
}
$folder = $this->folders->create($validated['name'], $parent);
$this->activity->log(Action::FolderCreated, subject: $folder);
return $this->resource($folder, $user)->response()->setStatusCode(201);
}
/**
* Rename or move a folder.
*
* Only the fields you send change. `parent_id: null` moves the folder to
* the top of the library. A folder moves with everything inside it, and
* cannot be moved into itself or one of its own subfolders.
*/
public function update(Request $request, Folder $folder): FolderResource
{
$user = $request->user();
assert($user !== null);
Gate::authorize('update', $folder);
$validated = $request->validate([
'name' => ['sometimes', 'required', 'string', 'max:255'],
'parent_id' => ['sometimes', ...Rules::folderId()],
]);
if (array_key_exists('name', $validated) && $validated['name'] !== $folder->name) {
$folder->update(['name' => $validated['name']]);
$this->activity->log(Action::FolderRenamed, subject: $folder);
}
if (array_key_exists('parent_id', $validated)) {
$newParentId = $validated['parent_id'] === null ? null : (int) $validated['parent_id'];
if ($newParentId !== $folder->parent_id) {
$newParent = $this->resolveParent($user, $newParentId);
// Dropping a folder into a public parent publishes its whole
// subtree, the act FoldersController::move refuses without
// `upload_public` (GHSA-rxf8-wh8v-jm9j).
abort_unless(Folder::uploadableBy($user, $newParent), 403);
$this->folders->move($folder, $newParent);
$this->activity->log(Action::FolderMoved, subject: $folder);
}
}
return $this->resource($folder->fresh() ?? $folder, $user);
}
/**
* Delete a folder.
*
* An empty folder is deleted straight away. A folder holding files or
* other folders answers 409 unless you send
* `content_action=cascade_delete`, which deletes the folder, every folder
* under it and every file inside them, as the web screen does. There is
* no restore.
*
* A cascade is refused with 403 if the folder holds any file this token
* may not delete itself.
*/
public function destroy(Request $request, Folder $folder): JsonResponse
{
$user = $request->user();
assert($user !== null);
Gate::authorize('delete', $folder);
$validated = $request->validate([
'content_action' => ['nullable', Rule::in(['cascade_delete'])],
]);
$subtree = $folder->subtreeFolderIds();
$hasContent = count($subtree) > 1
|| File::query()->whereIn('folder_id', $subtree)->exists();
// A sync job with a bug in it must not be one request away from
// emptying a client's folder: the cascade has to be asked for.
abort_if(
$hasContent && ($validated['content_action'] ?? null) !== 'cascade_delete',
409,
__('This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.'),
);
$blocked = $this->undeletable->count($user, $folder);
abort_if($blocked > 0, 403, 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;
$this->folders->delete($folder);
$this->activity->log(Action::FolderDeleted, context: ['name' => $name]);
return response()->json(status: 204);
}
private function resource(Folder $folder, ?User $user): FolderResource
{
$folder->load('assignments.assignable');
if ($user !== null) {
$this->attachTrails(collect([$folder]), $user);
}
return new FolderResource($folder);
}
/**
* @param Collection<int, Folder> $folders
*/
private function attachTrails(Collection $folders, User $user): void
{
$trails = $this->trails->ancestors($folders, $user);
foreach ($folders as $folder) {
$folder->setRelation('trail', collect($trails[$folder->id] ?? []));
}
}
/**
* The parent must be a folder this caller's library shows them — the
* same lookup the web screen makes, answering 404 otherwise.
*/
private function resolveParent(User $user, ?int $parentId): ?Folder
{
if ($parentId === null) {
return null;
}
/** @var Builder<Folder> $folders */
$folders = $this->scope->folders($user);
return $folders->findOrFail($parentId);
}
}
@@ -235,7 +235,10 @@ class ChunkedUploadsController extends Controller
// chooses its own chunking and only the last part is short.
$limit = $maxPartBytes * 2;
if ($request->header('Content-Length') !== null && (int) $request->header('Content-Length') > $limit) {
$contentLength = $request->header('Content-Length');
$reservationLimit = $contentLength !== null ? (int) $contentLength : $limit;
if ($reservationLimit < 1 || $reservationLimit > $limit) {
abort(413);
}
@@ -254,7 +257,12 @@ class ChunkedUploadsController extends Controller
// what a part gets is whatever the session has left, and the write
// is then capped at exactly that — an over-long body is cut off
// mid-stream as it always was, just against a smaller number.
$reserve = $this->reservePartRoom($session, $part, $limit);
// Reserve the declared request length when available. Reserving the
// full per-part ceiling (40 MiB for a normal 20 MiB chunk) makes
// concurrent final parts exhaust the session allowance prematurely.
// Unknown-length requests retain the conservative ceiling, and the
// streamed byte count is still enforced against the reservation.
$reserve = $this->reservePartRoom($session, $part, $reservationLimit);
if ($reserve < 1) {
// 413 rather than 422: this is about the size of what is being
@@ -264,9 +272,10 @@ class ChunkedUploadsController extends Controller
abort(413);
}
$stream = $request->getContent(true);
$stream = null;
try {
$stream = $request->getContent(true);
$etag = $this->parts->storePart($session, $part, $stream, $reserve);
} catch (PartTooLargeException) {
abort(413);
@@ -62,7 +62,22 @@ class FilesController extends Controller
$user = $request->user();
assert($user !== null);
// Opened from inside a folder, the upload goes into it (#1801).
// The same two checks the portal's upload page makes, with the
// staff library in place of the client's: a folder this person
// cannot see is a 404, one they may not upload into is a 403.
// ChunkedUploadsController checks the destination again when the
// upload starts, so this decides what the page offers, not what
// is allowed.
$folder = null;
if ($request->integer('folder') > 0) {
$folder = Folder::query()->find($request->integer('folder'));
abort_if($folder === null || ! app(StaffLibraryScope::class)->allowsFolder($user, $folder), 404);
abort_unless(Folder::uploadableBy($user, $folder), 403);
}
return Inertia::render('files/create', [
'folder' => $folder === null ? null : ['id' => $folder->id, 'name' => $folder->name],
'max_file_size_mb' => app(Settings::class)->get(Setting::MaxFileSizeMb),
'part_size_mb' => (int) config('projectsend.upload_part_size_mb'),
'allowed_extensions' => app(UploadExtensionPolicy::class)->hintFor($user),
@@ -555,4 +570,41 @@ class FilesController extends Controller
return redirect()->route('files.index')->with('success', __('File deleted.'));
}
/**
* Delete several files at once, from the staff selection bar (#1800).
*
* Each file is asked exactly what destroy() asks, through the same
* policy, and gets the same soft delete and the same activity entry: a
* batch is a shorthand for single deletes, never a way around one. A
* file the person may not delete is dropped from the batch rather than
* failing it, the convention bulkUpdate() follows. Nothing left to
* delete is a 422, so the page does not report a success.
*/
public function bulkDestroy(Request $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
$validated = $request->validate([
'file_ids' => ['required', 'array', 'min:1'],
'file_ids.*' => ['integer', 'distinct'],
]);
$files = File::query()->whereIn('id', $validated['file_ids'])->get()
->filter(fn (File $file): bool => Gate::forUser($user)->allows('delete', $file));
abort_if($files->isEmpty(), 422, __('None of the selected files could be deleted.'));
DB::transaction(function () use ($files): void {
foreach ($files as $file) {
$name = $file->name;
$file->delete();
$this->activity->log(Action::FileDeleted, context: ['name' => $name]);
}
});
return back()->with('success', trans_choice(':count file deleted.|:count files deleted.', $files->count(), ['count' => $files->count()]));
}
}
@@ -5,31 +5,26 @@ declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Http\Controllers\Concerns\ResolvesShareTargets;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Notifications\NotificationDigester;
use App\Modules\Notifications\Notifier;
use App\Modules\Files\Sharing\FolderSharing;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Sharing a folder with a client or group grants live access to its
* whole subtree. Mirrors FileAssignmentsController.
* whole subtree. Mirrors FileAssignmentsController; the effects live in
* FolderSharing, shared with the API.
*/
class FolderAssignmentsController extends Controller
{
use ResolvesShareTargets;
public function __construct(
private readonly ActivityLogger $activity,
private readonly StaffLibraryScope $scope,
private readonly NotificationDigester $digester,
private readonly Notifier $notifier,
private readonly FolderSharing $sharing,
) {}
public function store(Request $request, Folder $folder): RedirectResponse
@@ -41,20 +36,7 @@ class FolderAssignmentsController extends Controller
__('Folders can only be shared with clients or groups.'),
);
FolderAssignment::query()->firstOrCreate([
'folder_id' => $folder->id,
'assignable_type' => $this->assignableType($assignable),
'assignable_id' => $assignable->getKey(),
]);
$this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]);
$recipients = $this->shareRecipients($assignable);
$this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]);
// The master switch and each recipient's own preference are the
// digester's job now — every caller was repeating them.
$this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]);
$this->sharing->assign($folder, $assignable, $targetName);
return back();
}
@@ -68,15 +50,7 @@ class FolderAssignmentsController extends Controller
__('Folders can only be shared with clients or groups.'),
);
$deleted = FolderAssignment::query()
->where('folder_id', $folder->id)
->where('assignable_type', $this->assignableType($assignable))
->where('assignable_id', $assignable->getKey())
->delete();
if ($deleted > 0) {
$this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]);
}
$this->sharing->unassign($folder, $assignable, $targetName);
return back();
}
@@ -16,6 +16,7 @@ use App\Modules\Files\Access\ShareTargets;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Folders\BreadcrumbBuilder;
use App\Modules\Files\Folders\FolderService;
use App\Modules\Files\Folders\UndeletableFiles;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
@@ -65,6 +66,7 @@ class FoldersController extends Controller
private readonly VisibleCommentScope $comments,
private readonly FileVersionLinks $versionLinks,
private readonly DownloadAllowance $allowance,
private readonly UndeletableFiles $undeletable,
) {}
/**
@@ -252,7 +254,7 @@ class FoldersController extends Controller
return Inertia::render('files/index', [
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
'breadcrumb' => $flat ? [] : $this->breadcrumbs->for($current),
'breadcrumb' => $flat ? [] : $this->breadcrumb($user, $current),
'folders' => $folderRows->map(fn (Folder $folder): array => $this->folderRow($user, $folder))->all(),
'files' => $fileRows->map(fn (File $file): array => $this->fileRow($user, $file, $commentCounts, $pendingCounts, $versions))->all(),
'pagination' => Pagination::meta($sliced['paginator']),
@@ -426,7 +428,7 @@ class FoldersController extends Controller
'public_url' => $folder->public
? $this->publicUrl->for($folder)
: null,
'breadcrumb' => $this->breadcrumbs->for($folder),
'breadcrumb' => $this->breadcrumb($user, $folder),
'can_update' => Gate::forUser($user)->allows('update', $folder),
'can_manage_public' => $user->can('upload_public'),
...$this->shareTargets->forSubject($folder, $user),
@@ -450,6 +452,11 @@ class FoldersController extends Controller
$parent = $this->resolveParent($user, $validated['parent_id'] ?? null);
// A folder inside a public one is public, so creating it there is
// placing content into a public folder: the question every other
// write of a parent_id already asks (Folder::uploadableBy).
abort_unless(Folder::uploadableBy($user, $parent), 403);
$folder = $this->folders->create($validated['name'], $parent);
// Only a user who can manage public state may set it on create —
@@ -558,16 +565,9 @@ class FoldersController extends Controller
$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);
// Authorizing the folder is not authorizing the files the cascade
// takes with it — see UndeletableFiles, which the API asks too.
$blocked = $this->undeletable->count($viewer, $folder);
if ($blocked > 0) {
return back()->with('error', trans_choice(
@@ -588,47 +588,28 @@ class FoldersController extends Controller
}
/**
* How many files in this folder's subtree the viewer may not delete.
* The trail to $folder, trimmed for a client-scoped staff member to
* start at the first folder their library shows them: one of their
* clients' folders can sit inside somebody else's tree, and the names
* above it are not theirs to read. The client portal trims the same way.
*
* 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.
* @return list<array{id: int, name: string}>
*/
private function undeletableFileCount(User $viewer, Folder $folder): int
private function breadcrumb(User $user, ?Folder $folder): array
{
$mayDeleteOwn = $viewer->can('delete_files');
$mayDeleteOthers = $viewer->can('delete_others_files');
$scoped = $viewer->isClientScoped();
if ($mayDeleteOwn && $mayDeleteOthers && ! $scoped) {
return 0;
if ($folder === null || ! $user->isClientScoped()) {
return $this->breadcrumbs->for($folder);
}
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);
}
$visibleIds = array_values(array_map(
'intval',
$this->scope->folders($user)
->whereIn('folders.id', [...$folder->ancestorIds(), $folder->id])
->pluck('folders.id')
->all(),
));
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();
return $this->breadcrumbs->visible($folder, $visibleIds);
}
private function resolveParent(?User $user, ?int $parentId): ?Folder
@@ -446,6 +446,10 @@ class MyFilesController extends Controller
'categories' => $file->categories->pluck('id')->all(),
],
'can_delete' => Gate::forUser($client)->allows('delete', $file),
// Their own root, where the installation gives them one. The
// form offers no "No folder" beside it: there is no such place
// for this client, and update() resolves it here anyway.
'home_folder_id' => $this->homeFolders->for($client)?->id,
'can_publish' => $client->can('upload_public'),
// The public links on this file, and where to make and revoke
// one — the same shape the staff screen uses. A file marked
@@ -544,6 +548,15 @@ class MyFilesController extends Controller
$folderId = isset($validated['folder_id']) ? (int) $validated['folder_id'] : null;
// "No folder" means the top of what this client sees, which on an
// installation that gives them a home folder is inside it — not the
// root of the library, beside the staff folders. Uploading and
// creating a folder already resolve it this way; the editor did
// not, so a client could move their own file out of their home and
// into the administrator's root by choosing "No folder" (reported
// by binghuo).
$folderId ??= $this->homeFolders->for($client)?->id;
// The client rule, not the staff one: somewhere they could have
// uploaded it in the first place. Same check the upload path makes,
// so moving a file cannot reach a folder that uploading it could
@@ -41,7 +41,12 @@ class PublicShareController extends Controller
$shareLink = ShareLink::query()->where('token', $token)->first();
$file = $shareLink?->shareable;
if ($shareLink === null || ! $file instanceof File) {
// A withdrawn file answers exactly as a link that never existed.
// Its uploader deleted their account, and "this was here once" is
// itself something they asked to stop saying. The link row stays,
// so an account that is restored is served again. See
// SelfDeletion.
if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn()) {
return Inertia::render('share/show', ['status' => 'not_found']);
}
@@ -99,7 +104,7 @@ class PublicShareController extends Controller
$shareLink = ShareLink::query()->where('token', $token)->first();
$file = $shareLink?->shareable;
if ($shareLink === null || ! $file instanceof File || $shareLink->isExpired() || $file->isExpired()) {
if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn() || $shareLink->isExpired() || $file->isExpired()) {
return redirect()->route('share.show', $token);
}
@@ -129,9 +129,11 @@ class FileResource extends JsonResource
'name' => $this->nextVersion->name,
]),
// GET /folders/{id} has the rest, its place in the tree included.
'folder' => $this->whenLoaded('folder', fn (): ?array => $this->folder === null ? null : [
'id' => $this->folder->id,
'name' => $this->folder->name,
'parent_id' => $this->folder->parent_id,
]),
// Name only. The uploader is a user record; their email address
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Resources\Api;
use App\Modules\Files\Access\ClientIdentityScope;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Groups\Models\Group;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Folder
*
* Every field is listed explicitly, never $folder->toArray(), for the same
* reason as FileResource: the next migration must not publish itself.
*
* `ancestors` and `path` come from FolderTrails, loaded by the controller
* for a whole page at once, and are trimmed to the folders the caller may
* see. The assignment list is narrowed per entry by ClientIdentityScope,
* exactly as FileResource narrows a file's.
*/
class FolderResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$viewer = $request->user();
$identity = app(ClientIdentityScope::class);
$groupMorph = (new Group)->getMorphClass();
$ancestors = $this->ancestors();
return [
'id' => $this->id,
'name' => $this->name,
'parent_id' => $this->parent_id,
// The folders above this one, root first, as far up as the
// caller may see. Empty for a folder at the top of the library.
'ancestors' => $ancestors,
// The same trail as one string, this folder included:
// "Clients / Acme / 2026". For display; match on ids, since a
// folder name may itself contain " / ".
'path' => implode(' / ', [...array_column($ancestors, 'name'), $this->name]),
// Read-only here. Making a folder public publishes everything
// inside it, and is done on the web.
'public' => (bool) $this->public,
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
'assignments' => $this->whenLoaded('assignments', fn (): array => $this->assignments
->filter(fn (FolderAssignment $assignment): bool => $assignment->assignable_type === $groupMorph
? $identity->permitsGroupId($viewer, (int) $assignment->assignable_id)
: $identity->permitsClientId($viewer, (int) $assignment->assignable_id))
->map(fn (FolderAssignment $assignment): array => [
'type' => $assignment->assignable_type === $groupMorph ? 'group' : 'client',
'id' => $assignment->assignable_id,
'name' => $assignment->assignable?->getAttribute('name'),
])
->values()
->all()),
];
}
/**
* @return list<array{id: int, name: string}>
*/
private function ancestors(): array
{
if (! $this->resource->relationLoaded('trail')) {
return [];
}
/** @var list<array{id: int, name: string}> $trail */
$trail = $this->resource->getRelation('trail')->all();
return $trail;
}
}
@@ -11,6 +11,8 @@ use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\ZipDownload;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Bus\Queueable;
@@ -87,6 +89,23 @@ class BuildZipDownloadJob implements ShouldQueue
return;
}
// A build queued before this installation was told to stop
// offering zips. The route refuses new ones; this refuses the ones
// already waiting, so the work the key exists to save is not done
// anyway. Checked before started_at is stamped, so the row goes
// straight from waiting to failed and never looks like a build in
// hand. Failed, not left pending: pending is polled by the page
// and counted by StalledZipBuilds, and neither should wait on a
// build that will never run.
if (! app(CapabilityRegistry::class)->has(Capability::ZipDownloads)) {
$zipDownload->update([
'status' => ZipDownload::STATUS_FAILED,
'error' => 'Zip downloads are not available on this site.',
]);
return;
}
// Stamped before any of the work, because the only thing this is
// for is telling "a worker has this in hand" apart from "nobody
// is listening to the zips queue". A build that waits and never
+37 -3
View File
@@ -14,6 +14,7 @@ use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Erasure\SelfDeletion;
use App\Support\Concerns\HasUniqueSlug;
use Database\Factories\FileFactory;
use Illuminate\Database\Eloquent\Builder;
@@ -391,6 +392,36 @@ class File extends Model
$query->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
}
/**
* Files whose uploader has not deleted their own account — the first
* rule in SelfDeletion. Every surface that serves somebody other than
* staff narrows by this: the client scope, and the three public
* listing scopes. Single files ask isWithdrawn().
*
* The null branch is not tidiness. `uploaded_by NOT IN (...)` is never
* true for a NULL uploader, so a file whose uploader was erased long
* ago would vanish from every client along with the withdrawn ones.
*
* @param Builder<File> $query
*/
public function scopeNotWithdrawn(Builder $query): void
{
$query->where(fn (Builder $q) => $q
->whereNull('uploaded_by')
->orWhereNotIn('uploaded_by', app(SelfDeletion::class)->withdrawnAccounts()));
}
/**
* The single-file twin of scopeNotWithdrawn(). Asked by the routes
* that reach one file without an account behind them — share links
* and the public listing — which have no client scope to lean on.
*/
public function isWithdrawn(): bool
{
return $this->uploaded_by !== null
&& app(SelfDeletion::class)->withdrawnAccounts()->whereKey($this->uploaded_by)->exists();
}
/**
* Whether a cap has been set on how many times this may be
* downloaded. Unlike expiry, reaching it does not hide the file:
@@ -499,7 +530,9 @@ class File extends Model
$outer->orWhere('uploaded_by', $client->id);
});
$query->notExpired()->available($client);
// Withdrawn last, beside expiry, because it is the same kind of
// rule: not "who may see this" but "may anybody besides staff".
$query->notExpired()->notWithdrawn()->available($client);
}
/**
@@ -540,7 +573,7 @@ class File extends Model
$outer->orWhereIn('folder_id', $subtreeFolderIds);
});
$query->notExpired()->available();
$query->notExpired()->notWithdrawn()->available();
}
/**
@@ -554,7 +587,7 @@ class File extends Model
*/
public function scopePubliclyVisibleForFolder(Builder $query, Folder $folder): void
{
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired()->available();
$query->whereIn('folder_id', $folder->subtreeFolderIds())->notExpired()->notWithdrawn()->available();
}
/**
@@ -606,6 +639,7 @@ class File extends Model
$folder->whereNull('folder_id')->orWhereNotIn('folder_id', $publicFolderSubtreeIds);
})
->notExpired()
->notWithdrawn()
->available();
}
}
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Modules\Files\Queue;
use App\Modules\Files\Models\ZipDownload;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use Illuminate\Support\Carbon;
/**
@@ -56,6 +58,15 @@ class StalledZipBuilds
*/
public function oldestUnstarted(): ?Carbon
{
// An installation that does not offer zips has no reason to be
// serving their queue, and one that stopped offering them may
// still hold rows queued before it did. BuildZipDownloadJob fails
// those when a worker reaches them; until one does, they are not
// a worker problem worth a banner.
if (! app(CapabilityRegistry::class)->has(Capability::ZipDownloads)) {
return null;
}
if ($this->buildInHand()) {
return null;
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Sharing;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Groups\Models\Group;
use App\Modules\Notifications\NotificationDigester;
use App\Modules\Notifications\Notifier;
/**
* What actually happens when a folder is shared with a client or a group —
* the assignment row, the activity entry, the in-app notification and the
* debounced digest email, in that order. The folder twin of FileSharing.
*
* Extracted for the same reason FileSharing was: the web controller and the
* API controller must not be able to answer the question differently. The
* AI connector in the hosted edition repeated these four steps too, because
* there was nothing here to call.
*
* Unlike a file, a folder has no scan to wait for: the files inside it are
* held back individually until they can be had, and sharing the folder
* does not change that. So the telling is never deferred here.
*
* Authorization is the caller's job — both callers reach this after
* Gate::authorize('update', $folder), and the target has already been
* resolved and scope-checked by ResolvesShareTargets.
*/
class FolderSharing
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly NotificationDigester $digester,
private readonly Notifier $notifier,
) {}
/**
* Idempotent for the row: sharing the same folder with the same target
* twice leaves one assignment, which matters for an API caller retrying
* a request.
*/
public function assign(Folder $folder, User|Group $assignable, string $targetName): void
{
FolderAssignment::query()->firstOrCreate([
'folder_id' => $folder->id,
'assignable_type' => $assignable->getMorphClass(),
'assignable_id' => $assignable->getKey(),
]);
$this->activity->log(Action::FolderShared, subject: $folder, context: ['target' => $targetName]);
$recipients = $this->recipients($assignable);
$this->notifier->send('file_shared', $recipients, subject: $folder, data: ['itemName' => $folder->name]);
// The master switch and each recipient's own preference are the
// digester's job now — every caller was repeating them.
$this->digester->queue('file_shared', $recipients, $folder->name, ['is_folder' => true]);
}
/**
* @return bool whether an assignment was actually removed
*/
public function unassign(Folder $folder, User|Group $assignable, string $targetName): bool
{
$deleted = FolderAssignment::query()
->where('folder_id', $folder->id)
->where('assignable_type', $assignable->getMorphClass())
->where('assignable_id', $assignable->getKey())
->delete();
if ($deleted > 0) {
$this->activity->log(Action::FolderUnshared, subject: $folder, context: ['target' => $targetName]);
}
return $deleted > 0;
}
/**
* Notifier performs no authorization of its own — see its SECURITY
* CONTRACT docblock — so the recipient list is resolved here, from the
* assignment itself.
*
* @return iterable<User>
*/
private function recipients(User|Group $assignable): iterable
{
return $assignable instanceof Group ? $assignable->members : [$assignable];
}
}
@@ -353,6 +353,11 @@ class LocalPartStore
* deletes the whole tree, for everybody. Unset, which is every
* installation, the path is what it has always been.
*/
public function temporaryDirectory(): string
{
return $this->root();
}
private function root(): string
{
$configured = config('projectsend.uploads.parts_path');
@@ -194,7 +194,7 @@ class PublicGroupsController extends Controller
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
abort_unless($this->availability->isAvailable($file), 404);
$file->loadMissing('categories');
@@ -249,7 +249,7 @@ class PublicGroupsController extends Controller
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
abort_unless($this->availability->isAvailable($file), 404);
abort_unless(ThumbnailGenerator::supports($file->mime_type), 404);
@@ -307,7 +307,7 @@ class PublicGroupsController extends Controller
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
abort_unless($this->availability->isAvailable($file), 404);
abort_unless($this->settings->get(Setting::PublicListingPreviewEnabled) === true, 404);
abort_if(PreviewKind::forMime($file->mime_type) === null, 404);
@@ -354,7 +354,7 @@ class PublicGroupsController extends Controller
{
$this->guardSlug($publicSlug);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired() && ! $file->isWithdrawn(), 404);
abort_unless($this->availability->isAvailable($file), 404);
// 403 rather than 404, unlike the checks above it: the file is
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Erasure\Events;
/**
* "When somebody deletes their own account, do their files go at once?"
* Asked by SelfDeletion, with the installation's own setting already in
* $filesImmediately.
*
* A hosted platform answers it for some installations. On the free
* shared instance the files are only ever the customer's own, and the
* staff who would keep seeing them through the grace period are us, so
* cloud-modules makes this true there. The settings screen shows the
* choice as made by the platform rather than offering a switch that
* would do nothing.
*
* Listened to by *string* class name from a package, same as every other
* hook here — see docs/extension-points-architecture.md.
*/
final class ResolvingSelfDeletion
{
/**
* Whether a listener made the choice rather than the setting.
*/
public bool $managed = false;
public function __construct(
public bool $filesImmediately,
) {}
/**
* One direction only, the way ResolvingAttribution moves: a listener
* can make deletion sooner, never later. A package that could turn
* "delete my files now" into "keep them for a month" would be
* overruling a promise the person was shown when they confirmed.
*/
public function deleteFilesImmediately(): void
{
$this->filesImmediately = true;
$this->managed = true;
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Erasure;
use App\Models\User;
use App\Modules\Identity\Erasure\Events\ResolvingSelfDeletion;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Event;
/**
* What deleting your own account does to your files.
*
* Two rules, and they share one question — whose deletion counts, set by
* Setting::AccountSelfDeleteScope:
*
* 1. **The files stop being served at once.** From the moment the account
* is soft-deleted, nobody but staff gets them: not the clients and
* groups they were shared with, not a share link, not the public
* listing. Somebody who asked to leave should not stay published for
* the length of a grace period. Staff keep seeing them, because the
* grace period exists so that a mistake can still be undone. Nothing
* is deleted by this rule, and share links are kept, so an account
* that is restored is served again exactly as before.
*
* 2. **Optionally, the files are deleted at once** rather than when the
* account is erased (Setting::AccountSelfDeleteFiles, which a platform
* can overrule through ResolvingSelfDeletion).
*
* In practice rule 1 only ever meets a *self*-deleted account. An
* administrator deleting an account that owns anything must choose there
* and then to delete or reassign it (AccountContentDeletion), so no file
* is left pointing at an account an administrator removed.
*/
class SelfDeletion
{
public function __construct(
private readonly Settings $settings,
) {}
/**
* Whether the two rules apply to this account's own deletion.
*/
public function appliesTo(User $user): bool
{
return $this->clientsOnly() ? $user->isClient() : true;
}
public function deletesFilesImmediately(): bool
{
return $this->resolve()->filesImmediately;
}
/**
* Whether the platform made the choice, so the settings screen shows
* it rather than a switch that would change nothing.
*/
public function isManaged(): bool
{
return $this->resolve()->managed;
}
/**
* The ids of every deleted account whose files are withdrawn — the
* subquery File::scopeNotWithdrawn() and File::isWithdrawn() ask.
*
* @return Builder<User>
*/
public function withdrawnAccounts(): Builder
{
return User::onlyTrashed()
->select('id')
->when($this->clientsOnly(), fn (Builder $query) => $query->where('type', UserType::Client));
}
private function clientsOnly(): bool
{
return $this->settings->get(Setting::AccountSelfDeleteScope) === 'clients';
}
private function resolve(): ResolvingSelfDeletion
{
$event = new ResolvingSelfDeletion(
$this->settings->get(Setting::AccountSelfDeleteFiles) === 'immediately',
);
Event::dispatch($event);
return $event;
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Modules\Identity\Http\Middleware;
use App\Modules\Identity\AuthSource;
use Closure;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* `password.confirm`, answered in place for the app's own screens.
*
* The framework's version redirects to the confirm-password screen and
* relies on the "intended" URL to come back. For a GET that works. For
* the writes this guards it cannot: Redirector::guest() only remembers
* the exact URL of a GET, so a POST comes back to the page it was sent
* from, freshly rendered, and whatever was typed into the form -- a
* token's name and scopes, a release reason -- is gone, along with the
* action itself.
*
* So an Inertia request gets a 423 instead, which the browser turns into
* a password dialog over the page it is on (password-confirmation-dialog.tsx).
* Nothing navigates, the form keeps its state, and once the password is
* proved the same request is sent again. The check itself is the
* framework's, unchanged: this only decides what the refusal looks like.
* Anything else -- a plain form post, a JSON client -- is answered
* exactly as before.
*/
class RequirePasswordConfirmation extends RequirePassword
{
/**
* Marks the 423 as this refusal and not any other, so the browser does
* not open a password dialog in answer to something else.
*/
public const HEADER = 'X-Password-Confirmation';
public function handle($request, Closure $next, $redirectToRoute = null, $passwordTimeoutSeconds = null)
{
// Middleware parameters arrive as strings ("password.confirm:,300").
$timeout = $passwordTimeoutSeconds === null || $passwordTimeoutSeconds === '' ? null : (int) $passwordTimeoutSeconds;
if ($request->header('X-Inertia') && $this->shouldConfirmPassword($request, $timeout)) {
return $this->inertiaRefusal($request);
}
return parent::handle($request, $next, $redirectToRoute, $passwordTimeoutSeconds);
}
private function inertiaRefusal(Request $request): Response
{
$user = $request->user();
return $this->responseFactory->json([
'message' => 'Password confirmation required.',
// The same question the confirm-password screen asks, and see
// there for why it is Social and not "anything but Local": an
// account provisioned by a provider has no password to type,
// and the dialog has to offer it a way to set one instead.
'has_password' => $user !== null && $user->auth_source !== AuthSource::Social,
], 423, [self::HEADER => 'required']);
}
}
@@ -68,11 +68,14 @@ class BrandingServiceProvider extends ServiceProvider
// somebody's logo on every page of an installation offering no way
// to see it, change it or take it off. Evaluated per request, so
// uploading a logo or changing plan takes effect on the next one.
Inertia::share('branding', fn (): array => [
'logo_url' => $this->available()
? BrandingSetting::query()->first()?->logoUrl()
: null,
]);
Inertia::share('branding', function (): array {
$setting = $this->available() ? BrandingSetting::query()->first() : null;
return [
'logo_url' => $setting?->logoUrl(),
'show_site_name' => $setting->show_site_name ?? false,
];
});
}
private function available(): bool
@@ -31,7 +31,8 @@ class BrandingController extends Controller
* Get this installation's logo.
*
* Returns a null `logo_url` when no logo has been uploaded, which is
* the normal state rather than an error.
* the normal state rather than an error. `show_site_name` says whether
* the sign-in and download pages print the site name under the logo.
*/
public function show(): JsonResponse
{
@@ -40,6 +41,7 @@ class BrandingController extends Controller
return response()->json([
'data' => [
'logo_url' => $setting?->logoUrl(),
'show_site_name' => $setting->show_site_name ?? false,
'updated_at' => $setting?->updated_at?->toIso8601String(),
],
]);
@@ -48,6 +48,7 @@ class BrandingController extends Controller
// carries the column because it owns the table, and carries no
// way to set it.
'hide_attribution' => $setting->hide_attribution,
'show_site_name' => $setting->show_site_name,
'watermark' => [
'enabled' => $setting->watermark_enabled,
'image_url' => $setting->watermarkUrl(),
@@ -79,6 +80,23 @@ class BrandingController extends Controller
return back()->with('success', __('Logo updated.'));
}
/**
* Whether the sign-in and download pages print the site name under the
* logo. Its own action rather than a field on the logo upload, because
* it applies with or without a custom logo: an installation with a new
* name and ProjectSend's own logo is the case that needs it most.
*/
public function updateSiteName(Request $request): RedirectResponse
{
$validated = $request->validate([
'show_site_name' => ['required', 'boolean'],
]);
BrandingSetting::current()->update(['show_site_name' => (bool) $validated['show_site_name']]);
return back();
}
public function destroy(): RedirectResponse
{
$setting = BrandingSetting::query()->first();
@@ -20,6 +20,7 @@ use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
* @property int $watermark_size
* @property int $watermark_opacity
* @property bool $hide_attribution
* @property bool $show_site_name
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
@@ -31,6 +32,7 @@ class BrandingSetting extends Model
protected $casts = [
'watermark_enabled' => 'boolean',
'show_site_name' => 'boolean',
'watermark_position' => WatermarkPosition::class,
'watermark_size' => 'integer',
'watermark_opacity' => 'integer',
@@ -46,6 +48,7 @@ class BrandingSetting extends Model
*/
protected $attributes = [
'watermark_enabled' => false,
'show_site_name' => false,
'watermark_position' => 'bottom-right',
'watermark_size' => 30,
'watermark_opacity' => 60,
@@ -169,6 +169,23 @@ enum Capability: string
case AiConnector = 'ai.connector';
// Both editions, and present by default: a self-hosted installation
// keeps zip downloads exactly as it has them. The key exists so a
// hosted plan can subtract it, and the reason is cost rather than
// trust. Building an archive holds the `zips` worker, the disk and a
// CPU for as long as it takes, and on an instance shared by thousands
// of free accounts one person's folder is everybody's wait.
//
// Closed at the route, all three verbs, so a hand-made POST is a 404
// and not just a missing button. A build already queued when the key
// went away is refused by BuildZipDownloadJob and ends failed rather
// than pending. Archives already built are left alone and expire on
// their own schedule: taking a feature away never deletes anything.
//
// Last on purpose: keys are listed in enum order, and the control
// plane reads them in that order, so a new key goes at the end.
case ZipDownloads = 'downloads.zip';
/**
* @return list<Edition>
*/
@@ -185,7 +202,8 @@ enum Capability: string
self::UsersManage,
self::CaptchaConfigure,
self::Branding => [Edition::Community, Edition::Cloud],
self::Branding,
self::ZipDownloads => [Edition::Community, Edition::Cloud],
self::AttributionHide,
self::StorageManaged,
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Identity\AccountContentDeletion;
use App\Modules\Identity\Erasure\SelfDeletion;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Http\RedirectResponse;
@@ -18,7 +19,8 @@ use Inertia\Response;
/**
* System-wide privacy settings (staff-only): download IP logging
* granularity, the account-erasure retention window, how long API request
* granularity, the account-erasure retention window, what deleting your
* own account does to your files (see SelfDeletion), how long API request
* telemetry is kept, and whether to discourage search engines from
* indexing this installation.
*/
@@ -28,6 +30,7 @@ class PrivacySettingsController extends Controller
private readonly Settings $settings,
private readonly ActivityLogger $activity,
private readonly AccountContentDeletion $accountDeletion,
private readonly SelfDeletion $selfDeletion,
) {}
public function edit(Request $request): Response
@@ -41,6 +44,12 @@ class PrivacySettingsController extends Controller
// erasure will use, stored once for everybody, and the page is
// already behind edit_settings.
'reassign_candidates' => $this->accountDeletion->candidates(null),
// The effective answer, not the stored one: where a platform
// has made the choice, the screen shows what will happen and
// says who decided, instead of a switch that does nothing.
'account_self_delete_files' => $this->selfDeletion->deletesFilesImmediately() ? 'immediately' : 'after_grace_period',
'account_self_delete_files_managed' => $this->selfDeletion->isManaged(),
'account_self_delete_scope' => $this->settings->get(Setting::AccountSelfDeleteScope),
'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays),
'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing),
]);
@@ -58,6 +67,8 @@ class PrivacySettingsController extends Controller
'required_if:account_erasure_content_action,reassign',
Rule::exists('users', 'id')->where('active', true),
],
'account_self_delete_files' => ['required', Rule::in(['after_grace_period', 'immediately'])],
'account_self_delete_scope' => ['required', Rule::in(['any', 'clients'])],
'api_request_log_retention_days' => ['required', 'integer', 'min:0', 'max:3650'],
'discourage_search_indexing' => ['required', 'boolean'],
]);
@@ -71,6 +82,14 @@ class PrivacySettingsController extends Controller
Setting::AccountErasureReassignTo,
$validated['account_erasure_content_action'] === 'reassign' ? (int) $validated['account_erasure_reassign_to'] : 0,
);
// Not written while a platform decides it. The screen shows that
// choice with the control disabled, so what comes back is only the
// platform's answer echoed; storing it would record a decision
// this installation never made.
if (! $this->selfDeletion->isManaged()) {
$this->settings->set(Setting::AccountSelfDeleteFiles, $validated['account_self_delete_files']);
}
$this->settings->set(Setting::AccountSelfDeleteScope, $validated['account_self_delete_scope']);
$this->settings->set(Setting::ApiRequestLogRetentionDays, $validated['api_request_log_retention_days']);
$this->settings->set(Setting::DiscourageSearchIndexing, $validated['discourage_search_indexing']);
+23 -1
View File
@@ -177,6 +177,22 @@ enum Setting: string
// instead, so content is never left orphaned.
case AccountErasureReassignTo = 'account_erasure_reassign_to';
// When somebody deletes their own account, whether the files they
// uploaded go at once ('immediately') or wait for the grace period
// like the account does ('after_grace_period'). Only the self-service
// delete asks it: an administrator deleting an account already
// chooses per account. Read through SelfDeletion, which a hosted
// platform can overrule — never read it directly.
case AccountSelfDeleteFiles = 'account_self_delete_files';
// Whose self-deletion the two rules apply to: 'any' account, or
// 'clients' only. The rules are this setting's neighbour above, and
// that a self-deleted account's files stop being served to anybody
// but staff while the grace period runs. A staff member's uploads are
// often the organization's work for its clients, which is the case
// for narrowing it. Consumed by SelfDeletion.
case AccountSelfDeleteScope = 'account_self_delete_scope';
// Whether PurgeExpiredFilesCommand's daily run actually deletes
// anything (off by default — deletion is destructive, so an admin
// must opt in) and how many days after a file's own expires_at it
@@ -414,7 +430,9 @@ enum Setting: string
self::NewsLastFetchedAt,
self::CaptchaProvider,
self::CaptchaKeySource,
self::AccountErasureContentAction => SettingType::String,
self::AccountErasureContentAction,
self::AccountSelfDeleteFiles,
self::AccountSelfDeleteScope => SettingType::String,
self::ClientsCanRegister,
self::ClientsAutoApprove,
@@ -563,6 +581,10 @@ enum Setting: string
// uploaded is the sensible zero-config default; reassign is opt-in
// and needs a fallback account chosen.
self::AccountErasureContentAction => 'cascade_delete',
// What self-deletion did before the choice existed, so an
// upgrade changes nothing about when files are deleted.
self::AccountSelfDeleteFiles => 'after_grace_period',
self::AccountSelfDeleteScope => 'any',
// Commenting is on for every file out of the box, but only
// between people who are logged in: reaching the public
// requires PublicCommentsEnabled, which is off by default.
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Storage;
use App\Models\User;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Files\Uploads\LocalPartStore;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
class StorageCapacity
{
public function __construct(private readonly LocalPartStore $parts) {}
/** @return array{storage_driver: string, storage_free_bytes: int, upload_temp_free_bytes: int} */
public function inspect(User $uploader): array
{
$event = new ResolvingUploadDisk($uploader);
Event::dispatch($event);
$driver = (string) config('filesystems.disks.'.$event->disk.'.driver');
return [
'storage_driver' => $driver,
// Object stores do not expose filesystem free space. Never report
// the VPS disk as the capacity of a remote storage provider.
'storage_free_bytes' => $driver === 'local'
? $this->freeBytes(Storage::disk($event->disk)->path(''))
: -1,
'upload_temp_free_bytes' => $this->freeBytes($this->parts->temporaryDirectory()),
];
}
protected function freeBytes(string $path): int
{
// Before the first upload, the directory may not exist yet. Its
// nearest existing ancestor is on the filesystem that will hold it.
while (! is_dir($path)) {
$parent = dirname($path);
if ($parent === $path) {
return -1;
}
$path = $parent;
}
$bytes = @disk_free_space($path);
return $bytes === false ? -1 : (int) $bytes;
}
}
+8
View File
@@ -12,6 +12,7 @@ use App\Modules\Identity\Http\Middleware\EnforceTwoFactor;
use App\Modules\Identity\Http\Middleware\EnsureAccountIsActive;
use App\Modules\Identity\Http\Middleware\EnsureSetupIsComplete;
use App\Modules\Identity\Http\Middleware\EnsureStaff;
use App\Modules\Identity\Http\Middleware\RequirePasswordConfirmation;
use App\Modules\Platform\Http\Middleware\EnsureCapability;
use App\Modules\Platform\Http\Middleware\SetLocale;
use App\Support\WriteSafeRedirect;
@@ -93,6 +94,9 @@ return Application::configure(basePath: dirname(__DIR__))
// silently does nothing here.
$middleware->web(replace: [
Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class => ValidateCsrfToken::class,
// So a JSON poll never becomes the page back() returns to —
// see that class, and #1799.
Illuminate\Session\Middleware\StartSession::class => App\Http\Middleware\StartSession::class,
]);
$middleware->alias([
@@ -101,6 +105,10 @@ return Application::configure(basePath: dirname(__DIR__))
'staff-token' => EnsureStaffToken::class,
'token-can' => EnsureTokenCan::class,
'api-active' => EnsureApiAccountIsActive::class,
// Replaces the framework's own: a write that needs the password
// re-proved gets a dialog over the page instead of a redirect
// that throws the submitted form away. See the class.
'password.confirm' => RequirePasswordConfirmation::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
+2 -2
View File
@@ -58,7 +58,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@@ -78,7 +78,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
+1 -1
View File
@@ -234,7 +234,7 @@ return [
// A slow disk or a large source wants longer than the default 15.
'rendition_lock_wait_seconds' => env('PROJECTSEND_RENDITION_LOCK_WAIT_SECONDS'),
'version' => '2.5.0',
'version' => '2.6.0',
/*
|--------------------------------------------------------------------------
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// Whether the sign-in and download pages print the site name under
// the logo. A choice rather than always-on, because plenty of logos
// already say the name and would then say it twice (#1798). Off by
// default, so every existing installation looks as it did.
Schema::table('branding_settings', function (Blueprint $table) {
$table->boolean('show_site_name')->default(false);
});
}
public function down(): void
{
Schema::table('branding_settings', function (Blueprint $table) {
$table->dropColumn('show_site_name');
});
}
};
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* GET /api/v1/folders walks folders ordered by (updated_at, id), like every
* list endpoint — see App\Modules\Api\Support\PollingQuery. Same reasoning
* as the files index: without it, every poll is a filesort over the table.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('folders', function (Blueprint $table) {
$table->index(['updated_at', 'id'], 'folders_updated_at_id_index');
});
}
public function down(): void
{
Schema::table('folders', function (Blueprint $table) {
$table->dropIndex('folders_updated_at_id_index');
});
}
};
+3 -2
View File
@@ -1,7 +1,8 @@
# A complete ProjectSend install using the official image.
#
# 1. Edit the passwords and APP_URL below.
# 2. docker compose -f compose.example.yaml up -d
# 1. Save this file as compose.yaml, in a directory of its own, and edit
# the passwords and APP_URL below.
# 2. docker compose up -d
# 3. Open APP_URL — the setup screen creates your administrator account.
#
# This is the file the Docker Hub description points at, so it is written
+12
View File
@@ -13,12 +13,24 @@
# as the alpine package's `nginx` (uid 100) and cannot read what
# php-fpm just wrote — see the comment on that line.
# The image's HEALTHCHECK hits /up every 30 seconds, which buried
# `docker logs` under two lines per check. Drop a passing check from the
# access log; a failing one (anything but a 2xx) still logs, because that
# is the line someone reads when the container goes unhealthy.
map "$request_uri:$status" $loggable {
~^/up:2 0;
default 1;
}
server {
listen 80 default_server;
server_name _;
root /var/www/html/public;
index index.php;
# Overrides the http-level access_log only to apply $loggable above.
access_log /dev/stdout main if=$loggable;
# Uploads arrive in chunks (Uppy resumable), so this caps a single
# chunk, not a file. Raising it does not raise the maximum file size.
client_max_body_size 100m;
+2
View File
@@ -24,3 +24,5 @@ group = www-data
catch_workers_output = yes
decorate_workers_output = no
access.log = /dev/null
+50 -4
View File
@@ -80,6 +80,10 @@ list for your account.
| `upload` | list files, upload |
| `edit_files` / `edit_others_files` | read and edit file metadata, share files |
| `delete_files` / `delete_others_files` | delete files |
| `upload` / `edit_files` / `edit_others_files` | list and read folders |
| `create_own_folders` | create folders (with `upload`, as on the web) |
| `edit_files` / `edit_others_files` | rename, move and share folders |
| `delete_files` / `delete_others_files` | delete folders |
| `set_file_expiration_date` | set `expires_at` when editing |
| `set_file_categories` | set `categories` when editing |
| `limit_downloads` | set `download_limit` and `download_limit_scope` when editing |
@@ -88,7 +92,7 @@ list for your account.
| `manage_clients` | list clients |
| `create_clients` / `edit_clients` / `delete_clients` | create, read and edit, delete clients; `edit_clients` also removes a client's two-factor authentication |
| `manage_groups` | list groups |
| `create_groups` / `edit_groups` / `delete_groups` | create, read and edit (including membership), delete groups |
| `moderate_comments` | list what is awaiting approval, and approve it |
| `manage_users` | list staff accounts and the roles you may assign |
| `create_users` / `edit_users` / `delete_users` | create, read and edit, delete staff accounts; `edit_users` also removes an account's two-factor authentication |
@@ -99,10 +103,10 @@ a per-role permission, so the file abilities are the gate — the same question
endpoint also lets an author remove their own within the editing window and that is not moderation;
it additionally requires the token's owner to hold `moderate_comments`, checked live against the
account rather than carried by the token.
| `create_groups` / `edit_groups` / `delete_groups` | create, read and edit (including membership), delete groups |
Where an endpoint accepts several — `edit_files` *or* `edit_others_files` — holding either is enough,
and which one applies to a given file depends on whether you uploaded it.
and which one applies to a given file depends on whether you uploaded it. For a folder, it depends
on whether you created it.
Every operation in the OpenAPI document names its own requirement.
@@ -359,6 +363,46 @@ two are narrowed to what your token may see: a counterpart outside your reach re
---
## Folders
`GET /folders` lists the folders you can see in the library, and polls like every other list.
`parent_id=12` lists the folders directly inside folder 12, and `top_level=1` the folders at the top.
Each folder carries `parent_id`, and its place in the tree as `ancestors` (root first, as
`{id, name}`) and as a display `path` such as `Clients / Acme / 2026`. Match on ids rather than on
`path`: a folder's name may itself contain ` / `. If your token is limited to some clients, the
trail starts at the first folder you can see.
Creating a folder:
```bash
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Acme","parent_id":12}' \
https://your-install.example.com/api/v1/folders
```
A new folder answers `201`. If a folder with that name already exists in the same place, you get
that folder back with a `200` instead, so a sync job can create a folder without looking first.
`PATCH /folders/{id}` takes `name`, `parent_id`, or both. `parent_id: null` moves the folder to the
top. A folder moves with everything inside it, and cannot go into itself or one of its own
subfolders.
**Deleting a folder that is not empty must be asked for.** `DELETE /folders/{id}` deletes an empty
folder. A folder holding files or other folders answers `409` unless you send
`content_action=cascade_delete`, which deletes it with every folder and file inside it, as the web
screen does. There is no restore. The cascade is refused with `403` if the folder holds a file your
token may not delete.
Sharing works as it does for a file, at `/folders/{id}/assignments`. A client a folder is shared
with sees everything inside it, including what is added later.
A folder's `public` flag is reported but cannot be changed here: making a folder public publishes
everything in it, and is done on the web.
---
## Staff accounts
`/users` manages the people who administer the installation, and the role assigned to each of them.
@@ -443,7 +487,8 @@ sign-in — this un-sticks an account, it does not exempt one.
## Retries and duplicate requests
Assignments and group membership are idempotent. **Creating a file or a client is not** — a retried
Assignments and group membership are idempotent, and so is creating a folder (see above).
**Creating a file or a client is not** — a retried
`POST` that actually succeeded the first time creates a second one. Until idempotency keys exist,
check before retrying a create you are unsure about.
@@ -506,6 +551,7 @@ Recorded so they read as decisions rather than gaps:
- **Webhooks.** Poll instead; see above.
- **Idempotency keys.** See "Retries" above.
- **Share links, notifications, thumbnails, settings.**
- **Making a folder public**, or changing its public page. See "Folders" above.
- **Creating and deleting roles.** `GET /roles` reads them and `role_id` assigns one; defining a
role's permission set stays in the UI.
+718 -1
View File
@@ -2328,6 +2328,624 @@
}
}
},
"/folders/{folder}/assignments": {
"post": {
"operationId": "folders.assignments.store",
"description": "Sharing it again with the same client or group leaves one share.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.",
"summary": "Share a folder",
"tags": [
"FolderAssignments"
],
"parameters": [
{
"name": "folder",
"in": "path",
"required": true,
"description": "The folder ID",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "`FolderResource`",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/FolderResource"
}
},
"required": [
"data"
]
}
}
}
},
"403": {
"$ref": "#/components/responses/AuthorizationException"
},
"404": {
"$ref": "#/components/responses/ModelNotFoundException"
},
"401": {
"$ref": "#/components/responses/AuthenticationException"
}
}
},
"delete": {
"operationId": "folders.assignments.destroy",
"description": "Requires a token with any of these abilities: `edit_files`, `edit_others_files`.",
"summary": "Stop sharing a folder",
"tags": [
"FolderAssignments"
],
"parameters": [
{
"name": "folder",
"in": "path",
"required": true,
"description": "The folder ID",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "`FolderResource`",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/FolderResource"
}
},
"required": [
"data"
]
}
}
}
},
"403": {
"$ref": "#/components/responses/AuthorizationException"
},
"404": {
"$ref": "#/components/responses/ModelNotFoundException"
},
"401": {
"$ref": "#/components/responses/AuthenticationException"
}
}
}
},
"/folders": {
"get": {
"operationId": "folders.index",
"description": "Cursor paginated, like every list. Pass `updated_since` to poll for\nfolders created, renamed or moved since a point in time. `parent_id`\nlists the folders directly inside one folder, and `top_level=1` the\nfolders at the top of the library.\n\nMoving a folder updates the folder itself and every folder under it,\nso a poll sees the whole moved subtree.\n\nRequires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.",
"summary": "List folders",
"tags": [
"Folders"
],
"parameters": [
{
"name": "updated_since",
"in": "query",
"schema": {
"type": [
"string",
"null"
],
"format": "date-time"
}
},
{
"name": "per_page",
"in": "query",
"schema": {
"type": [
"integer",
"null"
],
"minimum": 1,
"maximum": 100
}
},
{
"name": "cursor",
"in": "query",
"schema": {
"type": [
"string",
"null"
]
}
},
{
"name": "parent_id",
"in": "query",
"schema": {
"type": [
"integer",
"null"
]
}
},
{
"name": "top_level",
"in": "query",
"schema": {
"type": [
"boolean",
"null"
]
}
},
{
"name": "search",
"in": "query",
"schema": {
"type": [
"string",
"null"
],
"maxLength": 255
}
}
],
"responses": {
"200": {
"description": "Paginated set of `FolderResource`",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FolderResource"
}
},
"links": {
"type": "object",
"properties": {
"first": {
"type": [
"string",
"null"
]
},
"last": {
"type": [
"string",
"null"
]
},
"prev": {
"type": [
"string",
"null"
]
},
"next": {
"type": [
"string",
"null"
]
}
},
"required": [
"first",
"last",
"prev",
"next"
]
},
"meta": {
"type": "object",
"properties": {
"path": {
"type": [
"string",
"null"
],
"description": "Base path for paginator generated URLs."
},
"per_page": {
"type": "integer",
"description": "Number of items shown per page.",
"minimum": 0
},
"next_cursor": {
"type": [
"string",
"null"
],
"description": "The \"cursor\" that points to the next set of items."
},
"prev_cursor": {
"type": [
"string",
"null"
],
"description": "The \"cursor\" that points to the previous set of items."
}
},
"required": [
"path",
"per_page",
"next_cursor",
"prev_cursor"
]
}
},
"required": [
"data",
"links",
"meta"
]
}
}
}
},
"422": {
"$ref": "#/components/responses/ValidationException"
},
"401": {
"$ref": "#/components/responses/AuthenticationException"
}
}
},
"post": {
"operationId": "folders.store",
"description": "At the top of the library, or inside `parent_id`. Requires the\n`create_own_folders` ability, and `upload` with it.\n\nIf a folder with the same name already exists in the same place, that\nfolder is returned with a 200 instead of a second one being made, so\nretrying a request is safe. A new folder answers 201.\n\nRequires a token with the ability: `create_own_folders`.",
"summary": "Create a folder",
"tags": [
"Folders"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 255
},
"parent_id": {
"type": [
"integer",
"null"
]
}
},
"required": [
"name"
]
}
}
}
},
"responses": {
"201": {
"description": "`FolderResource`",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"allOf": [
{
"$ref": "#/components/schemas/FolderResource"
},
{
"type": "object",
"required": [
"assignments"
]
}
]
}
},
"required": [
"data"
]
}
}
}
},
"200": {
"description": "`FolderResource`",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"allOf": [
{
"$ref": "#/components/schemas/FolderResource"
},
{
"type": "object",
"required": [
"assignments"
]
}
]
}
},
"required": [
"data"
]
}
}
}
},
"403": {
"description": "An error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Error overview.",
"examples": [
""
]
}
},
"required": [
"message"
]
}
}
}
},
"422": {
"$ref": "#/components/responses/ValidationException"
},
"401": {
"$ref": "#/components/responses/AuthenticationException"
}
}
}
},
"/folders/{folder}": {
"get": {
"operationId": "folders.show",
"description": "Requires a token with any of these abilities: `upload`, `edit_files`, `edit_others_files`.",
"summary": "Show a folder, with the clients and groups it is shared with",
"tags": [
"Folders"
],
"parameters": [
{
"name": "folder",
"in": "path",
"required": true,
"description": "The folder ID",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "`FolderResource`",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"allOf": [
{
"$ref": "#/components/schemas/FolderResource"
},
{
"type": "object",
"required": [
"assignments"
]
}
]
}
},
"required": [
"data"
]
}
}
}
},
"403": {
"$ref": "#/components/responses/AuthorizationException"
},
"404": {
"$ref": "#/components/responses/ModelNotFoundException"
},
"401": {
"$ref": "#/components/responses/AuthenticationException"
}
}
},
"patch": {
"operationId": "folders.update",
"description": "Only the fields you send change. `parent_id: null` moves the folder to\nthe top of the library. A folder moves with everything inside it, and\ncannot be moved into itself or one of its own subfolders.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.",
"summary": "Rename or move a folder",
"tags": [
"Folders"
],
"parameters": [
{
"name": "folder",
"in": "path",
"required": true,
"description": "The folder ID",
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 255
},
"parent_id": {
"type": [
"integer",
"null"
]
}
}
}
}
}
},
"responses": {
"200": {
"description": "`FolderResource`",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"allOf": [
{
"$ref": "#/components/schemas/FolderResource"
},
{
"type": "object",
"required": [
"assignments"
]
}
]
}
},
"required": [
"data"
]
}
}
}
},
"403": {
"$ref": "#/components/responses/AuthorizationException"
},
"422": {
"$ref": "#/components/responses/ValidationException"
},
"404": {
"$ref": "#/components/responses/ModelNotFoundException"
},
"401": {
"$ref": "#/components/responses/AuthenticationException"
}
}
},
"delete": {
"operationId": "folders.destroy",
"description": "An empty folder is deleted straight away. A folder holding files or\nother folders answers 409 unless you send\n`content_action=cascade_delete`, which deletes the folder, every folder\nunder it and every file inside them, as the web screen does. There is\nno restore.\n\nA cascade is refused with 403 if the folder holds any file this token\nmay not delete itself.\n\nRequires a token with any of these abilities: `delete_files`, `delete_others_files`.",
"summary": "Delete a folder",
"tags": [
"Folders"
],
"parameters": [
{
"name": "folder",
"in": "path",
"required": true,
"description": "The folder ID",
"schema": {
"type": "integer"
}
},
{
"name": "content_action",
"in": "query",
"schema": {
"type": [
"string",
"null"
],
"enum": [
"cascade_delete",
null
]
}
}
],
"responses": {
"204": {
"description": "No content",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {}
}
}
}
},
"409": {
"description": "An error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Error overview.",
"examples": [
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it."
]
}
},
"required": [
"message"
]
}
}
}
},
"403": {
"$ref": "#/components/responses/AuthorizationException"
},
"422": {
"$ref": "#/components/responses/ValidationException"
},
"404": {
"$ref": "#/components/responses/ModelNotFoundException"
},
"401": {
"$ref": "#/components/responses/AuthenticationException"
}
}
}
},
"/groups/{group}/members": {
"post": {
"operationId": "groups.members.store",
@@ -4189,17 +4807,25 @@
"object",
"null"
],
"description": "GET /folders/{id} has the rest, its place in the tree included.",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"parent_id": {
"type": [
"integer",
"null"
]
}
},
"required": [
"id",
"name"
"name",
"parent_id"
]
},
"uploaded_by": {
@@ -4303,6 +4929,97 @@
],
"title": "FileResource"
},
"FolderResource": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"parent_id": {
"type": [
"integer",
"null"
]
},
"ancestors": {
"type": "array",
"description": "The folders above this one, root first, as far up as the\ncaller may see. Empty for a folder at the top of the library.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
}
},
"path": {
"type": "string",
"description": "The same trail as one string, this folder included:\n\"Clients / Acme / 2026\". For display; match on ids, since a\nfolder name may itself contain \" / \"."
},
"public": {
"type": "boolean",
"description": "Read-only here. Making a folder public publishes everything\ninside it, and is done on the web."
},
"created_at": {
"type": [
"string",
"null"
]
},
"updated_at": {
"type": [
"string",
"null"
]
},
"assignments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"group",
"client"
]
},
"id": {
"type": "integer"
},
"name": {}
},
"required": [
"type",
"id",
"name"
]
}
}
},
"required": [
"id",
"name",
"parent_id",
"ancestors",
"path",
"public",
"created_at",
"updated_at"
],
"title": "FolderResource"
},
"GroupResource": {
"type": "object",
"properties": {
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Inicies la sessió amb un compte connectat. Tenir una contrasenya pròpia et permet entrar sense ell, i cal per activar la verificació en dos passos.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "La teva contrasenya la gestiona el directori de la teva organització, així que no es pot canviar aquí.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Un enllaç que qualsevol pot obrir, sense iniciar sessió. El pots revocar quan vulguis.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider només confirma una adreça quan el claim opcional \"xms_edov\" és al registre de la teva aplicació (Token configuration → optional claims → ID token). Fins que hi sigui, els comptes es creen igual, però tots queden esperant a Sol·licituds de compte, marquis el que marquis aquí."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider només confirma una adreça quan el claim opcional \"xms_edov\" és al registre de la teva aplicació (Token configuration → optional claims → ID token). Fins que hi sigui, els comptes es creen igual, però tots queden esperant a Sol·licituds de compte, marquis el que marquis aquí.",
"File storage": "Emmagatzematge de fitxers",
"Local disk": "Disc local",
"S3-compatible storage": "Emmagatzematge compatible amb S3",
"Storage available": "Espai disponible",
"Managed by provider": "Gestionat pel proveïdor",
"Temporary upload space": "Espai temporal per a pujades",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Les pujades fan servir espai temporal mentre es munten, també quan els fitxers es desen en un emmagatzematge extern.",
"Too many attempts. Wait a minute and try again.": "Massa intents. Espera un minut i torna-ho a provar.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Els fitxers que has pujat s'eliminen tan bon punt confirmis. No es poden recuperar.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Els fitxers que has pujat deixen d'estar disponibles per a tothom amb qui els has compartit tan bon punt confirmis.",
"When someone deletes their own account, their files": "Quan algú elimina el seu propi compte, els seus fitxers",
"Are deleted when the grace period ends": "S'eliminen quan acaba el període de gràcia",
"Are deleted right away": "S'eliminen immediatament",
"Set by your hosting plan.": "Ho defineix el teu pla d'allotjament.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "En tots dos casos, des del moment que s'elimina el compte, ningú excepte l'equip pot veure ni baixar aquests fitxers. Només s'eliminen els fitxers que va pujar aquesta persona, i les seves carpetes només si no hi queda res més a dins.",
"This applies when": "Això s'aplica quan",
"Anyone deletes their own account": "Qualsevol persona elimina el seu propi compte",
"A client deletes their own account": "Un client elimina el seu propi compte",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "El que puja algú de l'equip sol ser feina de la teva organització per als seus clients. Tria només clients per continuar servint aquests fitxers fins que el compte s'esborri definitivament.",
"Delete the selected files?": "Vols eliminar els fitxers seleccionats?",
"None of the selected files could be deleted.": "No s'ha pogut eliminar cap dels fitxers seleccionats.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Només s'eliminen els fitxers que tens permís per eliminar. Deixaran d'estar disponibles per a tothom amb qui s'havien compartit.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "A les pàgines d'inici de sessió i de baixada. Deixa-ho desactivat si el teu logotip ja mostra el nom.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Es mostra fins a 320 × 128 píxels, de manera que hi caben tant un logotip ample com un de quadrat. PNG, JPG, GIF o WebP, fins a 2 MB.",
"Show the site name under the logo": "Mostra el nom del lloc sota el logotip",
"They will no longer be available to anyone they were shared with.": "Deixaran d'estar disponibles per a tothom amb qui s'havien compartit.",
"Uploading into :folder": "Pujant a :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Přihlašujete se přes propojený účet. Vlastní heslo vám umožní přihlásit se i bez něj a je potřeba k zapnutí dvoufázového ověření.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Vaše heslo spravuje adresář vaší organizace, takže ho tu nelze změnit.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Odkaz, který může kdokoli otevřít bez přihlášení. Kdykoli ho můžete zrušit.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider potvrdí adresu jen tehdy, když je v registraci vaší aplikace volitelný nárok „xms_edov\" (Token configuration → optional claims → ID token). Dokud tam není, účty se sice vytvoří, ale všechny čekají v Žádostech o účet, ať je toto pole nastavené jakkoli."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider potvrdí adresu jen tehdy, když je v registraci vaší aplikace volitelný nárok „xms_edov\" (Token configuration → optional claims → ID token). Dokud tam není, účty se sice vytvoří, ale všechny čekají v Žádostech o účet, ať je toto pole nastavené jakkoli.",
"File storage": "Úložiště souborů",
"Local disk": "Místní disk",
"S3-compatible storage": "Úložiště kompatibilní s S3",
"Storage available": "Dostupné místo",
"Managed by provider": "Spravuje poskytovatel",
"Temporary upload space": "Dočasné místo pro nahrávání",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Nahrávané soubory se při skládání ukládají do dočasného místa, i když se soubory ukládají externě.",
"Too many attempts. Wait a minute and try again.": "Příliš mnoho pokusů. Počkejte minutu a zkuste to znovu.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Soubory, které jste nahráli, budou smazány hned po potvrzení. Nelze je obnovit.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Soubory, které jste nahráli, přestanou být dostupné všem, se kterými jste je sdíleli, hned po potvrzení.",
"When someone deletes their own account, their files": "Když někdo smaže svůj vlastní účet, jeho soubory",
"Are deleted when the grace period ends": "Budou smazány po skončení ochranné lhůty",
"Are deleted right away": "Budou smazány okamžitě",
"Set by your hosting plan.": "Nastaveno vaším hostingovým tarifem.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "V obou případech od chvíle smazání účtu nikdo kromě týmu nemůže tyto soubory zobrazit ani stáhnout. Mažou se jen soubory, které daný člověk nahrál, a jeho složky jen tehdy, pokud v nich nic jiného nezůstalo.",
"This applies when": "Platí to, když",
"Anyone deletes their own account": "Kdokoli smaže svůj vlastní účet",
"A client deletes their own account": "Klient smaže svůj vlastní účet",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "To, co nahraje člen týmu, je často práce vaší organizace pro její klienty. Zvolte jen klienty, pokud chcete tyto soubory dál poskytovat až do trvalého vymazání účtu.",
"Delete the selected files?": "Smazat vybrané soubory?",
"None of the selected files could be deleted.": "Žádný z vybraných souborů nebylo možné smazat.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Smažou se jen soubory, ke kterým máte oprávnění. Přestanou být dostupné všem, se kterými byly sdíleny.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Na přihlašovací stránce a stránce stahování. Nechte vypnuté, pokud logo už název obsahuje.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Zobrazuje se až v rozměru 320 × 128 pixelů, takže se vejde široké i čtvercové logo. PNG, JPG, GIF nebo WebP, do 2 MB.",
"Show the site name under the logo": "Zobrazit název webu pod logem",
"They will no longer be available to anyone they were shared with.": "Přestanou být dostupné všem, se kterými byly sdíleny.",
"Uploading into :folder": "Nahrávání do: :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Du meldest dich über ein verbundenes Konto an. Ein eigenes Passwort erlaubt dir die Anmeldung ohne dieses Konto und wird für die Zwei-Faktor-Authentifizierung benötigt.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Dein Passwort wird vom Verzeichnis deiner Organisation verwaltet und kann hier nicht geändert werden.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Ein Link, den jeder ohne Anmeldung öffnen kann. Du kannst ihn jederzeit widerrufen.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider bestätigt eine Adresse nur, wenn der optionale Anspruch „xms_edov\" in deiner App-Registrierung steht (Token configuration → optional claims → ID token). Solange er fehlt, werden Konten zwar angelegt, warten aber alle in den Kontoanfragen — egal, was hier angehakt ist."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider bestätigt eine Adresse nur, wenn der optionale Anspruch „xms_edov\" in deiner App-Registrierung steht (Token configuration → optional claims → ID token). Solange er fehlt, werden Konten zwar angelegt, warten aber alle in den Kontoanfragen — egal, was hier angehakt ist.",
"File storage": "Dateispeicher",
"Local disk": "Lokaler Datenträger",
"S3-compatible storage": "S3-kompatibler Speicher",
"Storage available": "Verfügbarer Speicher",
"Managed by provider": "Vom Anbieter verwaltet",
"Temporary upload space": "Temporärer Upload-Speicher",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Uploads belegen temporären Speicher, während sie zusammengesetzt werden – auch wenn Dateien extern gespeichert werden.",
"Too many attempts. Wait a minute and try again.": "Zu viele Versuche. Bitte warten Sie eine Minute und versuchen Sie es erneut.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Die von Ihnen hochgeladenen Dateien werden gelöscht, sobald Sie bestätigen. Sie können nicht wiederhergestellt werden.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Die von Ihnen hochgeladenen Dateien sind für alle, mit denen Sie sie geteilt haben, nicht mehr verfügbar, sobald Sie bestätigen.",
"When someone deletes their own account, their files": "Wenn jemand das eigene Konto löscht, werden die Dateien dieser Person",
"Are deleted when the grace period ends": "Nach Ablauf der Karenzzeit gelöscht",
"Are deleted right away": "Sofort gelöscht",
"Set by your hosting plan.": "Durch Ihren Hosting-Tarif festgelegt.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "In beiden Fällen kann ab dem Löschen des Kontos niemand außer dem Team diese Dateien sehen oder herunterladen. Gelöscht werden nur die eigenen Uploads der Person und ihre Ordner nur dann, wenn nichts anderes mehr darin liegt.",
"This applies when": "Dies gilt, wenn",
"Anyone deletes their own account": "Jemand das eigene Konto löscht",
"A client deletes their own account": "Ein Kunde das eigene Konto löscht",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Uploads von Teammitgliedern sind oft Arbeit Ihrer Organisation für ihre Kunden. Wählen Sie „nur Kunden“, damit diese Dateien bis zur endgültigen Löschung des Kontos weiter bereitgestellt werden.",
"Delete the selected files?": "Ausgewählte Dateien löschen?",
"None of the selected files could be deleted.": "Keine der ausgewählten Dateien konnte gelöscht werden.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Es werden nur die Dateien gelöscht, die Sie löschen dürfen. Sie stehen dann niemandem mehr zur Verfügung, mit dem sie geteilt wurden.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Auf der Anmelde- und der Download-Seite. Lassen Sie es aus, wenn Ihr Logo den Namen bereits zeigt.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wird bis zu 320 × 128 Pixel groß angezeigt, sodass breite und quadratische Logos gleichermaßen passen. PNG, JPG, GIF oder WebP, bis 2 MB.",
"Show the site name under the logo": "Seitennamen unter dem Logo anzeigen",
"They will no longer be available to anyone they were shared with.": "Sie stehen dann niemandem mehr zur Verfügung, mit dem sie geteilt wurden.",
"Uploading into :folder": "Hochladen in :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Inicias sesión con una cuenta conectada. Tener una contraseña propia te permite entrar sin ella, y hace falta para activar la verificación en dos pasos.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Tu contraseña la gestiona el directorio de tu organización, así que no se puede cambiar aquí.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Un enlace que cualquiera puede abrir, sin iniciar sesión. Puedes revocarlo cuando quieras.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider solo confirma una dirección cuando el claim opcional \"xms_edov\" está en el registro de tu aplicación (Token configuration → optional claims → ID token). Hasta que esté, las cuentas se crean igual, pero todas quedan esperando en Solicitudes de cuenta, marques lo que marques aquí."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider solo confirma una dirección cuando el claim opcional \"xms_edov\" está en el registro de tu aplicación (Token configuration → optional claims → ID token). Hasta que esté, las cuentas se crean igual, pero todas quedan esperando en Solicitudes de cuenta, marques lo que marques aquí.",
"Too many attempts. Wait a minute and try again.": "Demasiados intentos. Espera un minuto e inténtalo de nuevo.",
"File storage": "Almacenamiento de archivos",
"Local disk": "Disco local",
"S3-compatible storage": "Almacenamiento compatible con S3",
"Storage available": "Espacio disponible",
"Managed by provider": "Gestionado por el proveedor",
"Temporary upload space": "Espacio temporal para subidas",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Las subidas usan espacio temporal mientras se ensamblan, también cuando los archivos se guardan en un almacenamiento externo.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Los archivos que subiste se eliminan en cuanto confirmes. No se pueden recuperar.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Los archivos que subiste dejan de estar disponibles para todas las personas con quienes los compartiste en cuanto confirmes.",
"When someone deletes their own account, their files": "Cuando alguien elimina su propia cuenta, sus archivos",
"Are deleted when the grace period ends": "Se eliminan al terminar el período de gracia",
"Are deleted right away": "Se eliminan en el momento",
"Set by your hosting plan.": "Lo define tu plan de alojamiento.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "En ambos casos, desde que se elimina la cuenta, nadie salvo el personal puede ver ni descargar esos archivos. Solo se eliminan los archivos que subió esa persona, y sus carpetas solo si no queda nada más dentro.",
"This applies when": "Esto se aplica cuando",
"Anyone deletes their own account": "Cualquier persona elimina su propia cuenta",
"A client deletes their own account": "Un cliente elimina su propia cuenta",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Lo que sube alguien del personal suele ser trabajo de tu organización para sus clientes. Elige solo clientes para seguir sirviendo esos archivos hasta que la cuenta se borre definitivamente.",
"Delete the selected files?": "¿Eliminar los archivos seleccionados?",
"None of the selected files could be deleted.": "No se pudo eliminar ninguno de los archivos seleccionados.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Solo se eliminan los archivos que tienes permiso para eliminar. Dejarán de estar disponibles para quienes fueron compartidos.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "En las páginas de inicio de sesión y de descarga. Déjalo desactivado si tu logo ya muestra el nombre.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Se muestra hasta 320 × 128 píxeles, así que entran tanto un logo ancho como uno cuadrado. PNG, JPG, GIF o WebP, hasta 2 MB.",
"Show the site name under the logo": "Mostrar el nombre del sitio debajo del logo",
"They will no longer be available to anyone they were shared with.": "Dejarán de estar disponibles para quienes fueron compartidos.",
"Uploading into :folder": "Subiendo a :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Vous vous connectez via un compte associé. Un mot de passe à vous permet de vous connecter sans lui, et il est nécessaire pour activer la double authentification.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Votre mot de passe est géré par l'annuaire de votre organisation : il ne peut pas être modifié ici.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Un lien que tout le monde peut ouvrir, sans se connecter. Vous pouvez le révoquer à tout moment.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider ne confirme une adresse que si la revendication facultative « xms_edov » figure dans votre inscription d'application (Token configuration → optional claims → ID token). Tant qu'elle manque, les comptes sont bien créés, mais ils attendent tous dans Demandes de compte, quelle que soit cette case."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider ne confirme une adresse que si la revendication facultative « xms_edov » figure dans votre inscription d'application (Token configuration → optional claims → ID token). Tant qu'elle manque, les comptes sont bien créés, mais ils attendent tous dans Demandes de compte, quelle que soit cette case.",
"File storage": "Stockage des fichiers",
"Local disk": "Disque local",
"S3-compatible storage": "Stockage compatible S3",
"Storage available": "Espace disponible",
"Managed by provider": "Géré par le fournisseur",
"Temporary upload space": "Espace temporaire des envois",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Les envois utilisent un espace temporaire pendant leur assemblage, même lorsque les fichiers sont stockés en externe.",
"Too many attempts. Wait a minute and try again.": "Trop de tentatives. Veuillez patienter une minute et réessayer.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Les fichiers que vous avez envoyés sont supprimés dès que vous confirmez. Ils ne pourront pas être récupérés.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Les fichiers que vous avez envoyés ne sont plus accessibles aux personnes avec qui vous les avez partagés dès que vous confirmez.",
"When someone deletes their own account, their files": "Quand quelqu'un supprime son propre compte, ses fichiers",
"Are deleted when the grace period ends": "Sont supprimés à la fin du délai de grâce",
"Are deleted right away": "Sont supprimés immédiatement",
"Set by your hosting plan.": "Défini par votre offre d'hébergement.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Dans les deux cas, dès la suppression du compte, personne d'autre que l'équipe ne peut voir ni télécharger ces fichiers. Seuls les fichiers envoyés par cette personne sont supprimés, et ses dossiers seulement s'il n'y reste rien d'autre.",
"This applies when": "Cela s'applique quand",
"Anyone deletes their own account": "N'importe qui supprime son propre compte",
"A client deletes their own account": "Un client supprime son propre compte",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Les envois d'un membre de l'équipe sont souvent le travail de votre organisation pour ses clients. Choisissez « clients uniquement » pour continuer à servir ces fichiers jusqu'à l'effacement du compte.",
"Delete the selected files?": "Supprimer les fichiers sélectionnés ?",
"None of the selected files could be deleted.": "Aucun des fichiers sélectionnés n'a pu être supprimé.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Seuls les fichiers que vous avez le droit de supprimer sont supprimés. Ils ne seront plus accessibles aux personnes avec qui ils étaient partagés.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Sur les pages de connexion et de téléchargement. Laissez désactivé si votre logo affiche déjà le nom.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Affiché jusqu'à 320 × 128 pixels : un logo large comme un logo carré y trouvent leur place. PNG, JPG, GIF ou WebP, jusqu'à 2 Mo.",
"Show the site name under the logo": "Afficher le nom du site sous le logo",
"They will no longer be available to anyone they were shared with.": "Ils ne seront plus accessibles aux personnes avec qui ils étaient partagés.",
"Uploading into :folder": "Envoi dans :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Anda masuk melalui akun yang terhubung. Kata sandi sendiri memungkinkan Anda masuk tanpa akun itu, dan diperlukan untuk mengaktifkan autentikasi dua faktor.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Kata sandi Anda dikelola oleh direktori organisasi Anda, jadi tidak bisa diubah di sini.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Tautan yang bisa dibuka siapa saja tanpa masuk. Anda bisa mencabutnya kapan saja.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider hanya mengonfirmasi sebuah alamat bila klaim opsional \"xms_edov\" ada pada pendaftaran aplikasi Anda (Token configuration → optional claims → ID token). Sampai itu ada, akun tetap dibuat, tetapi semuanya menunggu di Permintaan akun, apa pun isi kotak ini."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider hanya mengonfirmasi sebuah alamat bila klaim opsional \"xms_edov\" ada pada pendaftaran aplikasi Anda (Token configuration → optional claims → ID token). Sampai itu ada, akun tetap dibuat, tetapi semuanya menunggu di Permintaan akun, apa pun isi kotak ini.",
"File storage": "Penyimpanan berkas",
"Local disk": "Disk lokal",
"S3-compatible storage": "Penyimpanan kompatibel S3",
"Storage available": "Ruang tersedia",
"Managed by provider": "Dikelola oleh penyedia",
"Temporary upload space": "Ruang unggahan sementara",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Unggahan memakai ruang sementara selama dirakit, termasuk saat berkas disimpan di penyimpanan eksternal.",
"Too many attempts. Wait a minute and try again.": "Terlalu banyak percobaan. Tunggu satu menit lalu coba lagi.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "File yang Anda unggah dihapus begitu Anda mengonfirmasi. File tersebut tidak dapat dipulihkan.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "File yang Anda unggah tidak lagi tersedia bagi semua orang yang Anda bagikan begitu Anda mengonfirmasi.",
"When someone deletes their own account, their files": "Saat seseorang menghapus akunnya sendiri, file miliknya",
"Are deleted when the grace period ends": "Dihapus saat masa tenggang berakhir",
"Are deleted right away": "Langsung dihapus",
"Set by your hosting plan.": "Diatur oleh paket hosting Anda.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Dalam kedua kasus, sejak akun dihapus, tidak ada yang selain staf dapat melihat atau mengunduh file tersebut. Hanya file unggahan orang itu sendiri yang dihapus, dan foldernya hanya jika tidak ada isi lain yang tersisa.",
"This applies when": "Ini berlaku saat",
"Anyone deletes their own account": "Siapa pun menghapus akunnya sendiri",
"A client deletes their own account": "Klien menghapus akunnya sendiri",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Unggahan staf sering kali merupakan pekerjaan organisasi Anda untuk kliennya. Pilih hanya klien agar file tersebut tetap tersedia hingga akun dihapus permanen.",
"Delete the selected files?": "Hapus berkas yang dipilih?",
"None of the selected files could be deleted.": "Tidak ada berkas terpilih yang dapat dihapus.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Hanya berkas yang boleh Anda hapus yang akan dihapus. Berkas tersebut tidak akan tersedia lagi bagi siapa pun yang sebelumnya menerimanya.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Di halaman masuk dan halaman unduhan. Biarkan nonaktif jika logo Anda sudah memuat nama.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Ditampilkan hingga 320 × 128 piksel, jadi logo lebar maupun persegi sama-sama muat. PNG, JPG, GIF, atau WebP, hingga 2 MB.",
"Show the site name under the logo": "Tampilkan nama situs di bawah logo",
"They will no longer be available to anyone they were shared with.": "Berkas tersebut tidak akan tersedia lagi bagi siapa pun yang sebelumnya menerimanya.",
"Uploading into :folder": "Mengunggah ke :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Accedi con un account collegato. Una password tua ti permette di accedere senza di esso ed è necessaria per attivare l'autenticazione a due fattori.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "La tua password è gestita dalla directory della tua organizzazione, quindi non può essere cambiata qui.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Un link che chiunque può aprire, senza accedere. Puoi revocarlo quando vuoi.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider conferma un indirizzo solo quando il claim facoltativo \"xms_edov\" è nella registrazione della tua app (Token configuration → optional claims → ID token). Finché non c'è, gli account vengono creati lo stesso, ma restano tutti in attesa in Richieste di account, qualunque cosa dica questa casella."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider conferma un indirizzo solo quando il claim facoltativo \"xms_edov\" è nella registrazione della tua app (Token configuration → optional claims → ID token). Finché non c'è, gli account vengono creati lo stesso, ma restano tutti in attesa in Richieste di account, qualunque cosa dica questa casella.",
"File storage": "Archiviazione dei file",
"Local disk": "Disco locale",
"S3-compatible storage": "Archiviazione compatibile S3",
"Storage available": "Spazio disponibile",
"Managed by provider": "Gestito dal provider",
"Temporary upload space": "Spazio temporaneo per i caricamenti",
"Uploads use temporary space while being assembled, including when files are stored externally.": "I caricamenti usano spazio temporaneo mentre vengono assemblati, anche quando i file sono archiviati esternamente.",
"Too many attempts. Wait a minute and try again.": "Troppi tentativi. Aspetta un minuto e riprova.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "I file che hai caricato vengono eliminati non appena confermi. Non potranno essere recuperati.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "I file che hai caricato non sono più disponibili per le persone con cui li hai condivisi non appena confermi.",
"When someone deletes their own account, their files": "Quando qualcuno elimina il proprio account, i suoi file",
"Are deleted when the grace period ends": "Vengono eliminati al termine del periodo di tolleranza",
"Are deleted right away": "Vengono eliminati subito",
"Set by your hosting plan.": "Stabilito dal tuo piano di hosting.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "In entrambi i casi, dal momento in cui l'account viene eliminato, nessuno tranne lo staff può vedere o scaricare quei file. Vengono eliminati solo i file caricati da quella persona, e le sue cartelle solo se al loro interno non resta nient'altro.",
"This applies when": "Si applica quando",
"Anyone deletes their own account": "Chiunque elimina il proprio account",
"A client deletes their own account": "Un cliente elimina il proprio account",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "I caricamenti di un membro dello staff sono spesso lavoro della tua organizzazione per i suoi clienti. Scegli solo clienti per continuare a servire quei file finché l'account non viene cancellato.",
"Delete the selected files?": "Eliminare i file selezionati?",
"None of the selected files could be deleted.": "Non è stato possibile eliminare nessuno dei file selezionati.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Vengono eliminati solo i file che hai il permesso di eliminare. Non saranno più disponibili per le persone con cui erano condivisi.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Nelle pagine di accesso e di download. Lascialo disattivato se il tuo logo mostra già il nome.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Mostrato fino a 320 × 128 pixel, così ci stanno sia un logo largo sia uno quadrato. PNG, JPG, GIF o WebP, fino a 2 MB.",
"Show the site name under the logo": "Mostra il nome del sito sotto il logo",
"They will no longer be available to anyone they were shared with.": "Non saranno più disponibili per le persone con cui erano condivisi.",
"Uploading into :folder": "Caricamento in :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "連携アカウントでサインインしています。自分のパスワードがあれば連携なしでもサインインでき、二段階認証を有効にするにも必要です。",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "パスワードは組織のディレクトリで管理されているため、ここでは変更できません。",
"A link anyone can open, without signing in. You can revoke it at any time.": "サインインなしで誰でも開けるリンクです。いつでも取り消せます。",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider は、アプリ登録にオプションの要求「xms_edov」がある場合にのみアドレスを確認します(Token configuration → optional claims → ID token)。追加されるまで、アカウントは作成されますが、このチェックの状態にかかわらずすべてアカウント申請で待機します。"
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider は、アプリ登録にオプションの要求「xms_edov」がある場合にのみアドレスを確認します(Token configuration → optional claims → ID token)。追加されるまで、アカウントは作成されますが、このチェックの状態にかかわらずすべてアカウント申請で待機します。",
"File storage": "ファイルの保存先",
"Local disk": "ローカルディスク",
"S3-compatible storage": "S3 互換ストレージ",
"Storage available": "空き容量",
"Managed by provider": "プロバイダーが管理",
"Temporary upload space": "アップロード用の一時領域",
"Uploads use temporary space while being assembled, including when files are stored externally.": "アップロードは組み立ての間、一時領域を使用します。ファイルを外部ストレージに保存している場合も同様です。",
"Too many attempts. Wait a minute and try again.": "試行回数が多すぎます。1 分待ってからもう一度お試しください。",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "アップロードしたファイルは確認した時点で削除されます。元に戻すことはできません。",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "アップロードしたファイルは、確認した時点で共有相手の誰からも利用できなくなります。",
"When someone deletes their own account, their files": "ユーザーが自分のアカウントを削除したとき、そのファイルは",
"Are deleted when the grace period ends": "猶予期間の終了時に削除する",
"Are deleted right away": "すぐに削除する",
"Set by your hosting plan.": "ご利用のホスティングプランで設定されています。",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "どちらの場合も、アカウントが削除された時点から、スタッフ以外はそのファイルを表示もダウンロードもできません。削除されるのはその人がアップロードしたファイルだけで、フォルダーは中に他に何も残っていない場合にのみ削除されます。",
"This applies when": "適用対象",
"Anyone deletes their own account": "誰かが自分のアカウントを削除したとき",
"A client deletes their own account": "クライアントが自分のアカウントを削除したとき",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "スタッフのアップロードは、多くの場合、組織がクライアントのために作成したものです。クライアントのみを選ぶと、アカウントが消去されるまでそれらのファイルを引き続き提供します。",
"Delete the selected files?": "選択したファイルを削除しますか?",
"None of the selected files could be deleted.": "選択したファイルはどれも削除できませんでした。",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "削除する権限のあるファイルだけが削除されます。共有していた相手全員から利用できなくなります。",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "サインインページとダウンロードページに表示されます。ロゴに名前が入っている場合はオフのままにしてください。",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大 320 × 128 ピクセルで表示されるため、横長のロゴも正方形のロゴも収まります。PNG、JPG、GIF、WebP、2 MB まで。",
"Show the site name under the logo": "ロゴの下にサイト名を表示する",
"They will no longer be available to anyone they were shared with.": "共有していた相手全員から利用できなくなります。",
"Uploading into :folder": "アップロード先: :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Je logt in met een gekoppeld account. Een eigen wachtwoord laat je inloggen zonder dat account en is nodig om tweestapsverificatie aan te zetten.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Je wachtwoord wordt beheerd door de directory van je organisatie en kan hier niet worden gewijzigd.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Een link die iedereen kan openen, zonder in te loggen. Je kunt hem altijd intrekken.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider bevestigt een adres alleen als de optionele claim \"xms_edov\" in je app-registratie staat (Token configuration → optional claims → ID token). Zolang die ontbreekt, worden accounts wel aangemaakt, maar wachten ze allemaal bij Accountaanvragen, wat hier ook is aangevinkt."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider bevestigt een adres alleen als de optionele claim \"xms_edov\" in je app-registratie staat (Token configuration → optional claims → ID token). Zolang die ontbreekt, worden accounts wel aangemaakt, maar wachten ze allemaal bij Accountaanvragen, wat hier ook is aangevinkt.",
"File storage": "Bestandsopslag",
"Local disk": "Lokale schijf",
"S3-compatible storage": "S3-compatibele opslag",
"Storage available": "Beschikbare ruimte",
"Managed by provider": "Beheerd door de provider",
"Temporary upload space": "Tijdelijke uploadruimte",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Uploads gebruiken tijdelijke ruimte terwijl ze worden samengevoegd, ook als bestanden extern worden opgeslagen.",
"Too many attempts. Wait a minute and try again.": "Te veel pogingen. Wacht een minuut en probeer het opnieuw.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "De bestanden die je hebt geüpload worden verwijderd zodra je bevestigt. Ze kunnen niet worden hersteld.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "De bestanden die je hebt geüpload zijn niet meer beschikbaar voor iedereen met wie je ze hebt gedeeld zodra je bevestigt.",
"When someone deletes their own account, their files": "Als iemand zijn eigen account verwijdert, worden diens bestanden",
"Are deleted when the grace period ends": "Verwijderd als de respijtperiode afloopt",
"Are deleted right away": "Direct verwijderd",
"Set by your hosting plan.": "Ingesteld door je hostingabonnement.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "In beide gevallen kan vanaf het moment dat het account is verwijderd niemand behalve het team die bestanden zien of downloaden. Alleen de eigen uploads worden verwijderd, en de eigen mappen alleen als er verder niets meer in staat.",
"This applies when": "Dit geldt als",
"Anyone deletes their own account": "Iemand zijn eigen account verwijdert",
"A client deletes their own account": "Een klant zijn eigen account verwijdert",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Uploads van teamleden zijn vaak werk van je organisatie voor haar klanten. Kies alleen klanten om die bestanden te blijven aanbieden tot het account definitief is gewist.",
"Delete the selected files?": "Geselecteerde bestanden verwijderen?",
"None of the selected files could be deleted.": "Geen van de geselecteerde bestanden kon worden verwijderd.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Alleen de bestanden die je mag verwijderen worden verwijderd. Ze zijn niet langer beschikbaar voor iedereen met wie ze gedeeld waren.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Op de inlog- en downloadpagina's. Laat het uit als je logo de naam al toont.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Getoond tot 320 × 128 pixels, dus zowel een breed als een vierkant logo past. PNG, JPG, GIF of WebP, tot 2 MB.",
"Show the site name under the logo": "Sitenaam onder het logo tonen",
"They will no longer be available to anyone they were shared with.": "Ze zijn niet langer beschikbaar voor iedereen met wie ze gedeeld waren.",
"Uploading into :folder": "Uploaden naar :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Logujesz się przez połączone konto. Własne hasło pozwala zalogować się bez niego i jest potrzebne do włączenia uwierzytelniania dwuskładnikowego.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Twoim hasłem zarządza katalog Twojej organizacji, więc nie można go tu zmienić.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Link, który każdy może otworzyć bez logowania. Możesz go w każdej chwili unieważnić.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider potwierdza adres tylko wtedy, gdy opcjonalne oświadczenie „xms_edov\" jest w rejestracji Twojej aplikacji (Token configuration → optional claims → ID token). Dopóki go nie ma, konta są tworzone, ale wszystkie czekają w Prośbach o konto, niezależnie od tego pola."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider potwierdza adres tylko wtedy, gdy opcjonalne oświadczenie „xms_edov\" jest w rejestracji Twojej aplikacji (Token configuration → optional claims → ID token). Dopóki go nie ma, konta są tworzone, ale wszystkie czekają w Prośbach o konto, niezależnie od tego pola.",
"File storage": "Przechowywanie plików",
"Local disk": "Dysk lokalny",
"S3-compatible storage": "Magazyn zgodny z S3",
"Storage available": "Dostępne miejsce",
"Managed by provider": "Zarządzane przez dostawcę",
"Temporary upload space": "Tymczasowe miejsce na przesyłanie",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Przesyłane pliki zajmują tymczasowe miejsce podczas składania, także gdy pliki są przechowywane w magazynie zewnętrznym.",
"Too many attempts. Wait a minute and try again.": "Zbyt wiele prób. Odczekaj minutę i spróbuj ponownie.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Przesłane przez ciebie pliki zostaną usunięte, gdy tylko potwierdzisz. Nie da się ich odzyskać.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Przesłane przez ciebie pliki przestaną być dostępne dla wszystkich, którym je udostępniono, gdy tylko potwierdzisz.",
"When someone deletes their own account, their files": "Gdy ktoś usuwa własne konto, jego pliki",
"Are deleted when the grace period ends": "Zostają usunięte po upływie okresu karencji",
"Are deleted right away": "Zostają usunięte od razu",
"Set by your hosting plan.": "Ustalone przez twój plan hostingowy.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "W obu przypadkach od chwili usunięcia konta nikt poza zespołem nie może zobaczyć ani pobrać tych plików. Usuwane są tylko pliki przesłane przez tę osobę, a jej foldery tylko wtedy, gdy nic innego w nich nie zostało.",
"This applies when": "Dotyczy to sytuacji, gdy",
"Anyone deletes their own account": "Ktokolwiek usuwa własne konto",
"A client deletes their own account": "Klient usuwa własne konto",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "To, co przesyłają członkowie zespołu, to często praca twojej organizacji dla jej klientów. Wybierz tylko klientów, aby nadal udostępniać te pliki aż do trwałego wymazania konta.",
"Delete the selected files?": "Usunąć zaznaczone pliki?",
"None of the selected files could be deleted.": "Nie udało się usunąć żadnego z zaznaczonych plików.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Usuwane są tylko pliki, które możesz usunąć. Przestaną być dostępne dla wszystkich, którym je udostępniono.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Na stronach logowania i pobierania. Zostaw wyłączone, jeśli logo już zawiera nazwę.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wyświetlane w rozmiarze do 320 × 128 pikseli, więc zmieści się zarówno szerokie, jak i kwadratowe logo. PNG, JPG, GIF lub WebP, do 2 MB.",
"Show the site name under the logo": "Pokaż nazwę witryny pod logo",
"They will no longer be available to anyone they were shared with.": "Przestaną być dostępne dla wszystkich, którym je udostępniono.",
"Uploading into :folder": "Przesyłanie do: :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Você entra com uma conta conectada. Ter uma senha própria permite entrar sem ela e é necessária para ativar a verificação em duas etapas.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Sua senha é gerenciada pelo diretório da sua organização, então não pode ser alterada aqui.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Um link que qualquer pessoa pode abrir, sem entrar na conta. Você pode revogá-lo quando quiser.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": "O :provider só confirma um endereço quando a claim opcional \"xms_edov\" está no registro do seu aplicativo (Token configuration → optional claims → ID token). Até lá, as contas são criadas, mas todas ficam aguardando em Solicitações de conta, independentemente desta caixa."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": "O :provider só confirma um endereço quando a claim opcional \"xms_edov\" está no registro do seu aplicativo (Token configuration → optional claims → ID token). Até lá, as contas são criadas, mas todas ficam aguardando em Solicitações de conta, independentemente desta caixa.",
"File storage": "Armazenamento de arquivos",
"Local disk": "Disco local",
"S3-compatible storage": "Armazenamento compatível com S3",
"Storage available": "Espaço disponível",
"Managed by provider": "Gerenciado pelo provedor",
"Temporary upload space": "Espaço temporário de envio",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Os envios usam espaço temporário enquanto são montados, inclusive quando os arquivos ficam em um armazenamento externo.",
"Too many attempts. Wait a minute and try again.": "Tentativas demais. Aguarde um minuto e tente novamente.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Os arquivos que você enviou são excluídos assim que você confirmar. Não será possível recuperá-los.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Os arquivos que você enviou deixam de estar disponíveis para todas as pessoas com quem você os compartilhou assim que você confirmar.",
"When someone deletes their own account, their files": "Quando alguém exclui a própria conta, os arquivos dessa pessoa",
"Are deleted when the grace period ends": "São excluídos ao fim do período de carência",
"Are deleted right away": "São excluídos na hora",
"Set by your hosting plan.": "Definido pelo seu plano de hospedagem.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Em ambos os casos, a partir do momento em que a conta é excluída, ninguém além da equipe pode ver ou baixar esses arquivos. Só são excluídos os arquivos enviados por essa pessoa, e as pastas dela apenas se não sobrar mais nada dentro.",
"This applies when": "Isso vale quando",
"Anyone deletes their own account": "Qualquer pessoa exclui a própria conta",
"A client deletes their own account": "Um cliente exclui a própria conta",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "O que um membro da equipe envia costuma ser trabalho da sua organização para os clientes. Escolha apenas clientes para continuar servindo esses arquivos até a conta ser apagada.",
"Delete the selected files?": "Excluir os arquivos selecionados?",
"None of the selected files could be deleted.": "Não foi possível excluir nenhum dos arquivos selecionados.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Só são excluídos os arquivos que você tem permissão para excluir. Eles deixarão de estar disponíveis para todos com quem foram compartilhados.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Nas páginas de login e de download. Deixe desativado se o seu logo já mostra o nome.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Exibido em até 320 × 128 pixels, então cabem tanto um logo largo quanto um quadrado. PNG, JPG, GIF ou WebP, até 2 MB.",
"Show the site name under the logo": "Mostrar o nome do site abaixo do logo",
"They will no longer be available to anyone they were shared with.": "Eles deixarão de estar disponíveis para todos com quem foram compartilhados.",
"Uploading into :folder": "Enviando para :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Вы входите через подключённый аккаунт. Собственный пароль позволит входить без него и нужен для включения двухфакторной аутентификации.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Вашим паролем управляет каталог вашей организации, поэтому изменить его здесь нельзя.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Ссылка, которую может открыть любой без входа в систему. Вы можете отозвать её в любой момент.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider подтверждает адрес только тогда, когда в регистрации вашего приложения есть необязательное утверждение «xms_edov» (Token configuration → optional claims → ID token). Пока его нет, учётные записи создаются, но все ждут в Запросах на аккаунт, что бы ни стояло в этом поле."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider подтверждает адрес только тогда, когда в регистрации вашего приложения есть необязательное утверждение «xms_edov» (Token configuration → optional claims → ID token). Пока его нет, учётные записи создаются, но все ждут в Запросах на аккаунт, что бы ни стояло в этом поле.",
"File storage": "Хранилище файлов",
"Local disk": "Локальный диск",
"S3-compatible storage": "S3-совместимое хранилище",
"Storage available": "Доступно",
"Managed by provider": "Управляется провайдером",
"Temporary upload space": "Временное место для загрузок",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Во время сборки загрузки занимают временное место, даже если файлы хранятся во внешнем хранилище.",
"Too many attempts. Wait a minute and try again.": "Слишком много попыток. Подождите минуту и попробуйте ещё раз.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Загруженные вами файлы будут удалены сразу после подтверждения. Восстановить их будет невозможно.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Загруженные вами файлы перестанут быть доступны всем, с кем вы ими поделились, сразу после подтверждения.",
"When someone deletes their own account, their files": "Когда кто-то удаляет свою учётную запись, его файлы",
"Are deleted when the grace period ends": "Удаляются по окончании отсрочки",
"Are deleted right away": "Удаляются сразу",
"Set by your hosting plan.": "Задаётся вашим тарифом хостинга.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "В обоих случаях с момента удаления учётной записи никто, кроме команды, не может просматривать или скачивать эти файлы. Удаляются только файлы, загруженные этим человеком, а его папки — только если в них больше ничего не осталось.",
"This applies when": "Это применяется, когда",
"Anyone deletes their own account": "Кто угодно удаляет свою учётную запись",
"A client deletes their own account": "Клиент удаляет свою учётную запись",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Загрузки участников команды — часто работа вашей организации для её клиентов. Выберите «только клиенты», чтобы эти файлы оставались доступны до окончательного удаления учётной записи.",
"Delete the selected files?": "Удалить выбранные файлы?",
"None of the selected files could be deleted.": "Ни один из выбранных файлов не удалось удалить.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Удаляются только файлы, которые вам разрешено удалять. Они больше не будут доступны никому, кому были открыты.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "На страницах входа и загрузки. Оставьте выключенным, если на логотипе уже есть название.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Показывается размером до 320 × 128 пикселей, поэтому поместится и широкий, и квадратный логотип. PNG, JPG, GIF или WebP, до 2 МБ.",
"Show the site name under the logo": "Показывать название сайта под логотипом",
"They will no longer be available to anyone they were shared with.": "Они больше не будут доступны никому, кому были открыты.",
"Uploading into :folder": "Загрузка в: :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Unaingia kwa akaunti iliyounganishwa. Nenosiri lako mwenyewe hukuruhusu kuingia bila akaunti hiyo, na linahitajika ili kuwasha uthibitishaji wa hatua mbili.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Nenosiri lako linasimamiwa na orodha ya shirika lako, kwa hiyo haliwezi kubadilishwa hapa.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Kiungo ambacho mtu yeyote anaweza kufungua bila kuingia. Unaweza kukibatilisha wakati wowote.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider huthibitisha anwani pale tu dai la hiari \"xms_edov\" lipo kwenye usajili wa programu yako (Token configuration → optional claims → ID token). Hadi liwepo, akaunti bado huundwa, lakini zote hungoja katika Maombi ya akaunti, bila kujali kisanduku hiki."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider huthibitisha anwani pale tu dai la hiari \"xms_edov\" lipo kwenye usajili wa programu yako (Token configuration → optional claims → ID token). Hadi liwepo, akaunti bado huundwa, lakini zote hungoja katika Maombi ya akaunti, bila kujali kisanduku hiki.",
"File storage": "Hifadhi ya mafaili",
"Local disk": "Diski ya ndani",
"S3-compatible storage": "Hifadhi inayooana na S3",
"Storage available": "Nafasi inayopatikana",
"Managed by provider": "Inasimamiwa na mtoa huduma",
"Temporary upload space": "Nafasi ya muda ya kupakia",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Mafaili yanayopakiwa hutumia nafasi ya muda yanapounganishwa, hata mafaili yanapohifadhiwa nje.",
"Too many attempts. Wait a minute and try again.": "Majaribio mengi mno. Subiri dakika moja kisha jaribu tena.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Faili ulizopakia zinafutwa mara tu unapothibitisha. Haziwezi kurejeshwa.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Faili ulizopakia hazipatikani tena kwa kila mtu uliyeshiriki naye mara tu unapothibitisha.",
"When someone deletes their own account, their files": "Mtu anapofuta akaunti yake mwenyewe, faili zake",
"Are deleted when the grace period ends": "Hufutwa muda wa neema unapoisha",
"Are deleted right away": "Hufutwa papo hapo",
"Set by your hosting plan.": "Imewekwa na mpango wako wa upangishaji.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Kwa vyovyote vile, tangu akaunti inapofutwa, hakuna mtu isipokuwa wafanyakazi anayeweza kuona au kupakua faili hizo. Hufutwa faili alizopakia mtu huyo pekee, na folda zake ikiwa tu hakuna kitu kingine kilichobaki ndani.",
"This applies when": "Hii inatumika wakati",
"Anyone deletes their own account": "Mtu yeyote anafuta akaunti yake mwenyewe",
"A client deletes their own account": "Mteja anafuta akaunti yake mwenyewe",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Faili zinazopakiwa na wafanyakazi mara nyingi ni kazi ya shirika lako kwa wateja wake. Chagua wateja pekee ili faili hizo ziendelee kupatikana hadi akaunti ifutwe kabisa.",
"Delete the selected files?": "Futa mafaili yaliyochaguliwa?",
"None of the selected files could be deleted.": "Hakuna faili lililochaguliwa lililoweza kufutwa.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Mafaili unayoruhusiwa kufuta pekee ndiyo yanayofutwa. Hayatapatikana tena kwa yeyote aliyeshirikishwa nayo.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Kwenye ukurasa wa kuingia na ukurasa wa kupakua. Iache imezimwa ikiwa nembo yako tayari ina jina.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Huonyeshwa hadi pikseli 320 × 128, kwa hivyo nembo pana na ya mraba zote zinatosha. PNG, JPG, GIF au WebP, hadi MB 2.",
"Show the site name under the logo": "Onyesha jina la tovuti chini ya nembo",
"They will no longer be available to anyone they were shared with.": "Hayatapatikana tena kwa yeyote aliyeshirikishwa nayo.",
"Uploading into :folder": "Inapakia kwenye :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Bağlı bir hesapla oturum açıyorsunuz. Kendinize ait bir parola, o hesap olmadan da giriş yapmanızı sağlar ve iki adımlı doğrulamayı açmak için gereklidir.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Parolanız kuruluşunuzun dizini tarafından yönetiliyor, bu yüzden burada değiştirilemez.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Herkesin oturum açmadan açabileceği bir bağlantı. İstediğiniz zaman iptal edebilirsiniz.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider bir adresi yalnızca uygulama kaydınızda \"xms_edov\" isteğe bağlı talebi varsa doğrular (Token configuration → optional claims → ID token). O eklenene kadar hesaplar yine oluşturulur, ancak bu kutu ne olursa olsun hepsi Hesap isteklerinde bekler."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider bir adresi yalnızca uygulama kaydınızda \"xms_edov\" isteğe bağlı talebi varsa doğrular (Token configuration → optional claims → ID token). O eklenene kadar hesaplar yine oluşturulur, ancak bu kutu ne olursa olsun hepsi Hesap isteklerinde bekler.",
"File storage": "Dosya depolama",
"Local disk": "Yerel disk",
"S3-compatible storage": "S3 uyumlu depolama",
"Storage available": "Kullanılabilir alan",
"Managed by provider": "Sağlayıcı tarafından yönetilir",
"Temporary upload space": "Geçici yükleme alanı",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Yüklemeler birleştirilirken geçici alan kullanır; dosyalar harici depolamada tutulduğunda da.",
"Too many attempts. Wait a minute and try again.": "Çok fazla deneme. Lütfen bir dakika bekleyip yeniden deneyin.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Yüklediğiniz dosyalar onayladığınız anda silinir. Geri getirilemez.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Yüklediğiniz dosyalar, onayladığınız anda paylaştığınız herkes için kullanılamaz hale gelir.",
"When someone deletes their own account, their files": "Biri kendi hesabını sildiğinde, dosyaları",
"Are deleted when the grace period ends": "Bekleme süresi bitince silinir",
"Are deleted right away": "Hemen silinir",
"Set by your hosting plan.": "Barındırma planınız tarafından belirlenir.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Her iki durumda da hesap silindiği andan itibaren ekip dışında kimse bu dosyaları göremez veya indiremez. Yalnızca o kişinin yüklediği dosyalar silinir; klasörleri ise yalnızca içinde başka bir şey kalmadıysa silinir.",
"This applies when": "Şu durumda geçerlidir",
"Anyone deletes their own account": "Herhangi biri kendi hesabını sildiğinde",
"A client deletes their own account": "Bir müşteri kendi hesabını sildiğinde",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Ekip üyelerinin yüklediği dosyalar çoğu zaman kuruluşunuzun müşterileri için yaptığı iştir. Hesap tamamen silinene kadar bu dosyaları sunmaya devam etmek için yalnızca müşterileri seçin.",
"Delete the selected files?": "Seçili dosyalar silinsin mi?",
"None of the selected files could be deleted.": "Seçili dosyaların hiçbiri silinemedi.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Yalnızca silme izniniz olan dosyalar silinir. Paylaşıldıkları hiç kimse için artık erişilebilir olmayacaklar.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Giriş ve indirme sayfalarında. Logonuz adı zaten gösteriyorsa kapalı bırakın.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "En fazla 320 × 128 piksel olarak gösterilir; böylece hem geniş hem kare logolar sığar. PNG, JPG, GIF veya WebP, en fazla 2 MB.",
"Show the site name under the logo": "Site adını logonun altında göster",
"They will no longer be available to anyone they were shared with.": "Paylaşıldıkları hiç kimse için artık erişilebilir olmayacaklar.",
"Uploading into :folder": "Yükleme hedefi: :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "Bạn đăng nhập bằng tài khoản đã liên kết. Có mật khẩu riêng giúp bạn đăng nhập mà không cần tài khoản đó, và cần thiết để bật xác thực hai bước.",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "Mật khẩu của bạn do thư mục của tổ chức quản lý, nên không thể đổi tại đây.",
"A link anyone can open, without signing in. You can revoke it at any time.": "Một liên kết ai cũng mở được mà không cần đăng nhập. Bạn có thể thu hồi bất cứ lúc nào.",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider chỉ xác nhận một địa chỉ khi khai báo tùy chọn \"xms_edov\" có trong đăng ký ứng dụng của bạn (Token configuration → optional claims → ID token). Cho đến khi có, tài khoản vẫn được tạo, nhưng tất cả đều chờ trong Yêu cầu tài khoản, dù ô này được đặt thế nào."
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": ":provider chỉ xác nhận một địa chỉ khi khai báo tùy chọn \"xms_edov\" có trong đăng ký ứng dụng của bạn (Token configuration → optional claims → ID token). Cho đến khi có, tài khoản vẫn được tạo, nhưng tất cả đều chờ trong Yêu cầu tài khoản, dù ô này được đặt thế nào.",
"File storage": "Lưu trữ tệp",
"Local disk": "Ổ đĩa cục bộ",
"S3-compatible storage": "Lưu trữ tương thích S3",
"Storage available": "Dung lượng còn trống",
"Managed by provider": "Do nhà cung cấp quản lý",
"Temporary upload space": "Dung lượng tải lên tạm thời",
"Uploads use temporary space while being assembled, including when files are stored externally.": "Khi tải lên, tệp dùng dung lượng tạm thời trong lúc được ghép lại, kể cả khi tệp được lưu ở bộ lưu trữ bên ngoài.",
"Too many attempts. Wait a minute and try again.": "Bạn đã thử quá nhiều lần. Hãy đợi một phút rồi thử lại.",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "Các tệp bạn đã tải lên sẽ bị xóa ngay khi bạn xác nhận. Không thể khôi phục lại.",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "Các tệp bạn đã tải lên sẽ không còn khả dụng với bất kỳ ai bạn đã chia sẻ ngay khi bạn xác nhận.",
"When someone deletes their own account, their files": "Khi ai đó tự xóa tài khoản của mình, các tệp của họ",
"Are deleted when the grace period ends": "Bị xóa khi hết thời gian chờ",
"Are deleted right away": "Bị xóa ngay lập tức",
"Set by your hosting plan.": "Do gói lưu trữ của bạn quy định.",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "Dù chọn cách nào, kể từ lúc tài khoản bị xóa, không ai ngoài nhân sự có thể xem hay tải xuống các tệp đó. Chỉ những tệp do chính người đó tải lên bị xóa, và thư mục của họ chỉ bị xóa nếu bên trong không còn gì khác.",
"This applies when": "Áp dụng khi",
"Anyone deletes their own account": "Bất kỳ ai tự xóa tài khoản của mình",
"A client deletes their own account": "Một khách hàng tự xóa tài khoản của mình",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "Tệp do nhân sự tải lên thường là công việc của tổ chức bạn dành cho khách hàng. Chọn chỉ khách hàng để tiếp tục cung cấp các tệp đó cho đến khi tài khoản bị xóa vĩnh viễn.",
"Delete the selected files?": "Xóa các tệp đã chọn?",
"None of the selected files could be deleted.": "Không xóa được tệp nào trong số các tệp đã chọn.",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "Chỉ những tệp bạn được phép xóa mới bị xóa. Chúng sẽ không còn khả dụng với bất kỳ ai từng được chia sẻ.",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "Trên trang đăng nhập và trang tải xuống. Hãy để tắt nếu logo của bạn đã có tên.",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Hiển thị tối đa 320 × 128 pixel, nên cả logo ngang lẫn logo vuông đều vừa. PNG, JPG, GIF hoặc WebP, tối đa 2 MB.",
"Show the site name under the logo": "Hiển thị tên trang dưới logo",
"They will no longer be available to anyone they were shared with.": "Chúng sẽ không còn khả dụng với bất kỳ ai từng được chia sẻ.",
"Uploading into :folder": "Đang tải lên vào :folder"
}
+28 -1
View File
@@ -2256,5 +2256,32 @@
"You sign in through a connected account. A password of your own lets you sign in without it, and is needed to turn on two-factor authentication.": "你通过关联账户登录。拥有自己的密码可以不依赖该账户登录,并且开启两步验证时需要它。",
"Your password is managed by your organisation's directory, so it cannot be changed here.": "你的密码由所在组织的目录管理,因此无法在此更改。",
"A link anyone can open, without signing in. You can revoke it at any time.": "任何人无需登录即可打开的链接。你可以随时撤销它。",
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": "只有当应用注册中包含可选声明“xms_edov”时,:provider 才会确认地址(Token configuration → optional claims → ID token)。在添加之前,账户仍会创建,但无论此处如何勾选,它们都会在账户申请中等待。"
":provider only confirms an address when the \"xms_edov\" optional claim is on your app registration (Token configuration → optional claims → ID token). Until it is there, accounts are still created, but every one of them waits in Account requests however this box is set.": "只有当应用注册中包含可选声明“xms_edov”时,:provider 才会确认地址(Token configuration → optional claims → ID token)。在添加之前,账户仍会创建,但无论此处如何勾选,它们都会在账户申请中等待。",
"File storage": "文件存储",
"Local disk": "本地磁盘",
"S3-compatible storage": "S3 兼容存储",
"Storage available": "可用空间",
"Managed by provider": "由服务商管理",
"Temporary upload space": "上传临时空间",
"Uploads use temporary space while being assembled, including when files are stored externally.": "上传的文件在合并期间会占用临时空间,即使文件存储在外部存储中也是如此。",
"Too many attempts. Wait a minute and try again.": "尝试次数过多,请等待一分钟后重试。",
"The files you uploaded are deleted as soon as you confirm. They cannot be recovered.": "你上传的文件会在你确认后立即删除,且无法恢复。",
"The files you uploaded stop being available to everyone you shared them with as soon as you confirm.": "你确认后,你上传的文件将立即对所有与你共享的人不可用。",
"When someone deletes their own account, their files": "当有人删除自己的账户时,其文件",
"Are deleted when the grace period ends": "在宽限期结束时删除",
"Are deleted right away": "立即删除",
"Set by your hosting plan.": "由你的托管方案设定。",
"Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.": "无论选择哪种,从账户被删除的那一刻起,除团队成员外,任何人都无法查看或下载这些文件。只会删除此人自己上传的文件,其文件夹仅在里面不再有其他内容时才会删除。",
"This applies when": "适用情形",
"Anyone deletes their own account": "任何人删除自己的账户",
"A client deletes their own account": "客户删除自己的账户",
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.": "团队成员上传的文件通常是你的组织为客户完成的工作。选择仅限客户,可在账户被彻底抹除前继续提供这些文件。",
"Delete the selected files?": "要删除所选文件吗?",
"None of the selected files could be deleted.": "所选文件均无法删除。",
"Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.": "只会删除你有权删除的文件。这些文件将不再对任何已共享的人开放。",
"On the sign-in and download pages. Leave it off if your logo already says the name.": "显示在登录页和下载页上。如果你的标志已包含名称,请保持关闭。",
"Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大按 320 × 128 像素显示,宽标志和方形标志都能放下。PNG、JPG、GIF 或 WebP,最大 2 MB。",
"Show the site name under the logo": "在标志下方显示站点名称",
"They will no longer be available to anyone they were shared with.": "这些文件将不再对任何已共享的人开放。",
"Uploading into :folder": "上传到 :folder"
}
+22 -1
View File
@@ -1,10 +1,17 @@
import '../css/app.css';
// Stylesheets an installed package ships under resources/css, the styling
// counterpart of the package pages resolved below. Imported after app.css so
// a package can restyle what core draws; core names no package and no style.
import.meta.glob('../../vendor/*/*/resources/css/*.css', { eager: true });
import { createInertiaApp, router } from '@inertiajs/react';
import axios from 'axios';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';
import { route as routeFn } from 'ziggy-js';
import { PasswordConfirmationDialog } from './components/password-confirmation-dialog';
import { initializeTheme } from './hooks/use-appearance';
import { xsrfCookieName } from './lib/xsrf';
@@ -75,7 +82,21 @@ createInertiaApp({
const root = createRoot(el);
root.render(<App {...props} />);
// The password dialog sits beside every page rather than in any
// one layout: the writes it answers for are spread across the
// staff shell, the settings screens and every portal theme, and
// it needs the page context (translations) the children of <App>
// are given. The page itself renders as Inertia would on its own.
root.render(
<App {...props}>
{({ Component, props: pageProps, key }) => (
<>
{createElement(Component, { key, ...pageProps })}
<PasswordConfirmationDialog />
</>
)}
</App>,
);
},
progress: {
color: '#4B5563',
+3 -1
View File
@@ -7,7 +7,9 @@ export default function AppLogoIcon(props: SVGAttributes<SVGElement>) {
const gradientId = useId();
return (
<svg {...props} viewBox="0 0 234.26482 252.25172" xmlns="http://www.w3.org/2000/svg">
// Marked so an installed package can restyle the default mark; only
// drawn when no logo was uploaded in Branding, which always wins.
<svg data-slot="app-logo-default" {...props} viewBox="0 0 234.26482 252.25172" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899">
<stop offset="0.1675" stopColor="#5219B3" />
+6 -2
View File
@@ -18,11 +18,15 @@ export function AppShell({ children, variant = 'header' }: AppShellProps) {
};
if (variant === 'header') {
return <div className="flex min-h-screen w-full flex-col">{children}</div>;
return (
<div data-surface="staff" className="flex min-h-screen w-full flex-col">
{children}
</div>
);
}
return (
<SidebarProvider defaultOpen={isOpen} open={isOpen} onOpenChange={handleSidebarChange}>
<SidebarProvider data-surface="staff" defaultOpen={isOpen} open={isOpen} onOpenChange={handleSidebarChange}>
{children}
</SidebarProvider>
);
@@ -9,7 +9,10 @@ import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: BreadcrumbItemType[] }) {
return (
<header className="border-sidebar-border/50 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4">
<header
data-slot="app-header"
className="border-sidebar-border/50 flex h-16 shrink-0 items-center gap-2 border-b px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4"
>
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Breadcrumbs breadcrumbs={breadcrumbs} />
@@ -3,8 +3,8 @@ import { Link, usePage } from '@inertiajs/react';
import { AlertTriangle, ArrowUpCircle, HardDrive, ShieldAlert } from 'lucide-react';
import { useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { FileDeliveryDialog, type FileDelivery } from '@/components/file-delivery-dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { UpdateInstructions, type InstallKind } from '@/components/update-instructions';
import { useTranslation } from '@/hooks/use-translation';
import { formatBytes } from '@/lib/format-bytes';
@@ -23,6 +23,8 @@ export interface SystemInfo {
database: string;
storage_used_bytes: number;
storage_free_bytes: number;
storage_driver: string;
upload_temp_free_bytes: number;
update_available: boolean;
latest_version: string | null;
release_url: string | null;
@@ -109,7 +111,9 @@ function StorageDurabilityNotice({ durability }: { durability: StorageDurability
volume: durability.volume,
})
: t('They survive upgrades, but they live in a Docker-managed volume rather than a directory you chose.')}{' '}
{t('That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.')}
{t(
'That means docker compose down -v and docker volume prune both delete them, and a backup of your server can miss them entirely.',
)}
</p>
</AlertDescription>
</Alert>
@@ -207,9 +211,7 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
<li>{t(':count files have been waiting to be checked for over an hour.', { count: system.scanning.pending })}</li>
)}
{system.scanning.definitions_age_hours !== null && system.scanning.definitions_age_hours >= 72 && (
<li>
{t('The virus definitions are :hours hours old.', { hours: system.scanning.definitions_age_hours })}
</li>
<li>{t('The virus definitions are :hours hours old.', { hours: system.scanning.definitions_age_hours })}</li>
)}
</ul>
<Link href="/system/settings/virus-scanning" className="mt-1 inline-block underline hover:no-underline">
@@ -268,12 +270,30 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
<dt className="text-muted-foreground">{t('Storage used')}</dt>
<dd>{formatBytes(system.storage_used_bytes)}</dd>
</div>
{system.storage_free_bytes >= 0 && (
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t('Storage free')}</dt>
<dd>{formatBytes(system.storage_free_bytes)}</dd>
</div>
)}
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t('File storage')}</dt>
<dd>
{system.storage_driver === 'local'
? t('Local disk')
: system.storage_driver === 's3'
? t('S3-compatible storage')
: t('External storage')}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t('Storage available')}</dt>
<dd>
{system.storage_driver !== 'local'
? t('Managed by provider')
: system.storage_free_bytes >= 0
? formatBytes(system.storage_free_bytes)
: t('Unknown')}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t('Temporary upload space')}</dt>
<dd>{system.upload_temp_free_bytes >= 0 ? formatBytes(system.upload_temp_free_bytes) : t('Unknown')}</dd>
</div>
{/* Stated always, flagged only when it is the slow one.
Both halves of the row open the explanation, and the
label is underlined, because the icon alone did not read
@@ -367,6 +387,10 @@ export function SystemWidget({ system, onViewReleaseNotes }: { system: SystemInf
</div>
)}
</dl>
<p className="text-muted-foreground text-xs">
{t('Uploads use temporary space while being assembled, including when files are stored externally.')}
</p>
<FileDeliveryDialog delivery={system.file_delivery} open={deliveryOpen} onOpenChange={setDeliveryOpen} />
</div>
);
@@ -23,6 +23,7 @@ export function WidgetBox({ id, title, headerExtra, children }: { id: string; ti
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
data-slot="card"
className={`bg-card rounded-lg border p-4 ${isDragging ? 'z-10 opacity-50' : ''}`}
>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
+19 -1
View File
@@ -12,8 +12,24 @@ import { useTranslation } from '@/hooks/use-translation';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
export default function DeleteUser({ graceDays }: { graceDays: number }) {
interface DeleteUserProps {
graceDays: number;
/** Their files stop being served to anybody but staff at once. */
filesWithdrawn: boolean;
/** Their files are deleted at once rather than with the account. */
filesDeletedImmediately: boolean;
}
export default function DeleteUser({ graceDays, filesWithdrawn, filesDeletedImmediately }: DeleteUserProps) {
const { t } = useTranslation();
// What happens to the files they uploaded, said before they confirm.
// Deleted outranks withdrawn: a file that is gone is also not served.
const filesNotice = filesDeletedImmediately
? t('The files you uploaded are deleted as soon as you confirm. They cannot be recovered.')
: filesWithdrawn
? t('The files you uploaded stop being available to everyone you shared them with as soon as you confirm.')
: null;
const passwordInput = useRef<HTMLInputElement>(null);
const { data, setData, delete: destroy, processing, reset, errors, clearErrors } = useForm({ password: '' });
@@ -48,6 +64,7 @@ export default function DeleteUser({ graceDays }: { graceDays: number }) {
{ days: graceDays },
)}
</p>
{filesNotice !== null && <p className="text-sm font-medium">{filesNotice}</p>}
</div>
<Dialog>
@@ -61,6 +78,7 @@ export default function DeleteUser({ graceDays }: { graceDays: number }) {
days: graceDays,
})}
</DialogDescription>
{filesDeletedImmediately && <p className="text-destructive text-sm font-medium">{filesNotice}</p>}
<form className="space-y-6" onSubmit={deleteUser}>
<div className="grid gap-2">
<Label htmlFor="password" className="sr-only">
+1 -1
View File
@@ -12,7 +12,7 @@ export function ListToolbar({ children, showClear, onClear }: { children: ReactN
const { t } = useTranslation();
return (
<div className="mb-4 flex flex-wrap items-end gap-3 rounded-lg border p-4">
<div data-slot="list-toolbar" className="mb-4 flex flex-wrap items-end gap-3 rounded-lg border p-4">
{children}
{showClear && (
<Button type="button" variant="ghost" onClick={onClear}>
@@ -0,0 +1,187 @@
import type { PendingVisit } from '@inertiajs/core';
import { router } from '@inertiajs/react';
import axios from 'axios';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler, useEffect, useRef, useState } from 'react';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTranslation } from '@/hooks/use-translation';
interface Refused {
// Typed as pending, but what the start event hands over is the request
// as sent: the visit plus its callbacks (useForm's among them), which
// is what lets a replay finish the form's own submission.
visit: PendingVisit;
hasPassword: boolean;
}
const visitKey = (method: string, url: string) => `${method.toUpperCase()} ${url}`;
/**
* The browser half of RequirePasswordConfirmation.
*
* A write that needs the password re-proved comes back as a 423 instead of
* a redirect. Inertia calls that an "invalid" response; this catches it,
* asks for the password over the page the user is on, and then sends the
* refused request again, exactly as it was first sent -- same data, same
* callbacks -- so the form that sent it carries on as if nothing had
* happened. Nothing navigates, so nothing typed into the form is lost.
*
* Mounted once, around every page, in app.tsx.
*/
export function PasswordConfirmationDialog() {
const { t } = useTranslation();
// Writes in flight, by method and URL. The invalid event carries only
// the response, so this is how a refusal finds the visit that caused it.
const inFlight = useRef(new Map<string, PendingVisit>());
const [refused, setRefused] = useState<Refused | null>(null);
const [password, setPassword] = useState('');
const [error, setError] = useState<string | undefined>();
const [processing, setProcessing] = useState(false);
useEffect(() => {
const removeStart = router.on('start', (event) => {
const visit = event.detail.visit;
if (visit.method !== 'get') {
inFlight.current.set(visitKey(visit.method, visit.url.href), visit);
}
});
// After `invalid` for the same request, so the visit is still here
// when a refusal needs it.
const removeFinish = router.on('finish', (event) => {
const visit = event.detail.visit;
inFlight.current.delete(visitKey(visit.method, visit.url.href));
});
const removeInvalid = router.on('invalid', (event) => {
const response = event.detail.response;
if (response.status !== 423 || response.headers['x-password-confirmation'] !== 'required') {
return;
}
const visit = inFlight.current.get(visitKey(response.config.method ?? '', response.config.url ?? ''));
if (!visit) {
return;
}
// Stops Inertia's own error modal, which would show the raw JSON.
event.preventDefault();
setPassword('');
setError(undefined);
setRefused({ visit, hasPassword: response.data?.has_password !== false });
});
return () => {
removeStart();
removeFinish();
removeInvalid();
};
}, []);
// Cancelling drops the refused request, and the password with it.
const close = () => {
setPassword('');
setRefused(null);
};
const submit: FormEventHandler = async (e) => {
e.preventDefault();
if (!refused) {
return;
}
setProcessing(true);
setError(undefined);
try {
await axios.post(route('password.confirm.store'), { password });
} catch (failure) {
setPassword('');
if (axios.isAxiosError(failure) && failure.response?.status === 422) {
setError(failure.response.data?.errors?.password?.[0] ?? t('Something went wrong. Please try again.'));
} else if (axios.isAxiosError(failure) && failure.response?.status === 429) {
setError(t('Too many attempts. Wait a minute and try again.'));
} else {
setError(t('Something went wrong. Please try again.'));
}
setProcessing(false);
return;
}
setProcessing(false);
setPassword('');
setRefused(null);
// Sent again as it was. The three state flags describe the first
// attempt, which finished; carried over, they would mark the new
// one finished before it started.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { url, completed, cancelled, interrupted, ...options } = refused.visit;
router.visit(url, options);
};
return (
<Dialog open={refused !== null} onOpenChange={(open) => !open && close()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Confirm your password')}</DialogTitle>
<DialogDescription>
{t('This is a secure area of the application. Please confirm your password before continuing.')}
</DialogDescription>
</DialogHeader>
{refused && !refused.hasPassword ? (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t('You sign in through a connected account, so there is no password here to confirm. Set one to continue.')}
</p>
<DialogFooter>
<Button variant="ghost" type="button" onClick={close}>
{t('Cancel')}
</Button>
<Button asChild>
<a href={route('password.edit')}>{t('Set a password')}</a>
</Button>
</DialogFooter>
</div>
) : (
<form onSubmit={submit} className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password-confirmation-dialog-password">{t('Password')}</Label>
<Input
id="password-confirmation-dialog-password"
type="password"
name="password"
placeholder={t('Password')}
autoComplete="current-password"
value={password}
autoFocus
onChange={(e) => setPassword(e.target.value)}
/>
<InputError message={error} />
</div>
<DialogFooter>
<Button variant="ghost" type="button" onClick={close}>
{t('Cancel')}
</Button>
<Button type="submit" disabled={processing || password === ''}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
{t('Confirm password')}
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}
+3 -1
View File
@@ -9,7 +9,9 @@ export default function ProjectSendLogo(props: SVGAttributes<SVGElement>) {
const gradientId = useId();
return (
<svg {...props} viewBox="0 0 1046.5 257" xmlns="http://www.w3.org/2000/svg">
// Marked so an installed package can restyle the default wordmark;
// only drawn when no logo was uploaded in Branding.
<svg data-slot="app-wordmark-default" {...props} viewBox="0 0 1046.5 257" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1="31.5263" y1="211.0108" x2="197.4637" y2="50.766899">
<stop offset="0.1675" stopColor="#5219B3" />
+1 -1
View File
@@ -24,7 +24,7 @@ interface TableShellProps {
*/
export function TableShell({ columns, emptyMessage, isEmpty, children }: TableShellProps) {
return (
<div className="overflow-x-auto rounded-lg border">
<div data-slot="table-shell" className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b text-left">
+1 -1
View File
@@ -36,7 +36,7 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
return <Comp data-slot="button" data-variant={variant ?? 'default'} className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
});
Button.displayName = 'Button';
+1 -1
View File
@@ -3,7 +3,7 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-lg border bg-card text-card-foreground shadow-xs', className)} {...props} />
<div ref={ref} data-slot="card" className={cn('rounded-lg border bg-card text-card-foreground shadow-xs', className)} {...props} />
));
Card.displayName = 'Card';
+6
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useCapability } from '@/hooks/use-capability';
import { ALL, useListQuery } from '@/hooks/use-list-query';
import { useZipDownload } from '@/hooks/use-zip-download';
import { type MyFilesProps } from '@/types/portal';
@@ -14,6 +15,10 @@ import { type MyFilesProps } from '@/types/portal';
*/
export function usePortalFiles({ folder, search, category, owner, sort, direction, pagination }: MyFilesProps) {
const zip = useZipDownload();
// Withheld on some hosted plans. In the portal, selecting rows exists
// only to zip them, so themes hide the checkboxes and the selection
// bar along with the folder's zip button when this is false.
const canZip = useCapability('downloads.zip');
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
const [selectedFolderIds, setSelectedFolderIds] = useState<Set<number>>(new Set());
@@ -61,6 +66,7 @@ export function usePortalFiles({ folder, search, category, owner, sort, directio
return {
zip,
canZip,
selectedFileIds,
selectedFolderIds,
selectionCount,
@@ -35,11 +35,20 @@ export default function AuthSimpleLayout({ children, title, description }: AuthL
<div className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-4">
<Link href={route('home')} className="flex flex-col items-center gap-2 font-medium">
{/* A box rather than a height: 128px tall and up
to 320px wide, so a square logo is shown at a
size that reads (48px, then 80px, were both
reported as too small) and a wide one still
fits a phone. */}
{branding?.logo_url ? (
<img src={branding.logo_url} alt={name} className="mb-1 h-12 w-auto object-contain" />
<img src={branding.logo_url} alt={name} className="mb-1 h-32 w-auto max-w-80 object-contain" />
) : (
<ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
)}
{/* Opt-in on the Branding screen: many logos
already say the name, and would then say it
twice. */}
{branding?.show_site_name && <span className="text-lg font-semibold">{name}</span>}
<span className="sr-only">{title}</span>
</Link>
+4 -4
View File
@@ -12,10 +12,10 @@ import AuthLayout from '@/layouts/auth-layout';
interface ConfirmPasswordProps {
/** False for an account that signs in through a provider and has no password to confirm with. */
has_local_password: boolean;
has_password: boolean;
}
export default function ConfirmPassword({ has_local_password }: ConfirmPasswordProps) {
export default function ConfirmPassword({ has_password }: ConfirmPasswordProps) {
const { t } = useTranslation();
const { data, setData, post, processing, errors, reset } = useForm({
@@ -37,7 +37,7 @@ export default function ConfirmPassword({ has_local_password }: ConfirmPasswordP
>
<Head title={t('Confirm password')} />
{!has_local_password && (
{!has_password && (
<div className="space-y-4">
<p className="text-muted-foreground text-sm">
{t('You sign in through a connected account, so there is no password here to confirm. Set one to continue.')}
@@ -48,7 +48,7 @@ export default function ConfirmPassword({ has_local_password }: ConfirmPasswordP
</div>
)}
<form onSubmit={submit} hidden={!has_local_password}>
<form onSubmit={submit} hidden={!has_password}>
<div className="space-y-6">
<div className="grid gap-2">
<Label htmlFor="password">{t('Password')}</Label>
+37 -1
View File
@@ -23,13 +23,14 @@ interface Watermark {
interface BrandingEditProps {
logo_url: string | null;
hide_attribution: boolean;
show_site_name: boolean;
watermark: Watermark;
watermark_positions: string[];
}
type Tab = 'logo' | 'watermark' | 'attribution';
export default function BrandingEdit({ logo_url, hide_attribution, watermark, watermark_positions }: BrandingEditProps) {
export default function BrandingEdit({ logo_url, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) {
const { t } = useTranslation();
const { capabilities } = usePage<SharedData>().props;
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -69,6 +70,13 @@ export default function BrandingEdit({ logo_url, hide_attribution, watermark, wa
const attributionForm = useForm({ hide_attribution });
const siteNameForm = useForm({ show_site_name });
const submitSiteName: FormEventHandler = (e) => {
e.preventDefault();
siteNameForm.patch(route('branding.site-name.update'), { preserveScroll: true });
};
// Debounced so dragging a number field is one request when it settles
// rather than one per keystroke — each costs a real GD render on the
// server. The URL is the whole state, so the browser handles the rest:
@@ -195,6 +203,9 @@ export default function BrandingEdit({ logo_url, hide_attribution, watermark, wa
accept="image/*"
onChange={(e) => uploadForm.setData('logo', e.target.files?.[0] ?? null)}
/>
<p className="text-muted-foreground text-sm">
{t('Shown up to 320 × 128 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.')}
</p>
<InputError message={uploadForm.errors.logo} />
<Button type="submit" disabled={uploadForm.processing || uploadForm.data.logo === null}>
{t('Upload logo')}
@@ -206,6 +217,31 @@ export default function BrandingEdit({ logo_url, hide_attribution, watermark, wa
{t('Remove logo')}
</Button>
)}
<form onSubmit={submitSiteName} className="space-y-4 border-t pt-6">
<div className="flex items-start gap-3">
<Checkbox
id="show_site_name"
checked={siteNameForm.data.show_site_name}
onCheckedChange={(checked) => siteNameForm.setData('show_site_name', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="show_site_name">{t('Show the site name under the logo')}</Label>
<p className="text-muted-foreground text-sm">
{t('On the sign-in and download pages. Leave it off if your logo already says the name.')}
</p>
</div>
</div>
<InputError message={siteNameForm.errors.show_site_name} />
<div className="flex items-center gap-3">
<Button type="submit" disabled={siteNameForm.processing}>
{t('Save')}
</Button>
{siteNameForm.recentlySuccessful && <p className="text-muted-foreground text-sm">{t('Saved.')}</p>}
</div>
</form>
</section>
{/* Both panels stay mounted and the inactive one is just
+10 -2
View File
@@ -7,12 +7,14 @@ import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
interface FilesCreateProps {
/** The folder the upload page was opened from, which uploads go into. */
folder: { id: number; name: string } | null;
max_file_size_mb: number;
part_size_mb: number;
allowed_extensions: string[] | null;
}
export default function FilesCreate({ max_file_size_mb, part_size_mb, allowed_extensions }: FilesCreateProps) {
export default function FilesCreate({ folder, max_file_size_mb, part_size_mb, allowed_extensions }: FilesCreateProps) {
const { t } = useTranslation();
const breadcrumbs: BreadcrumbItem[] = [
@@ -24,7 +26,8 @@ export default function FilesCreate({ max_file_size_mb, part_size_mb, allowed_ex
if (fileIds.length === 1) {
router.visit(route('files.edit', fileIds[0]));
} else if (fileIds.length > 1) {
router.visit(route('files.index'));
// Back to where the upload started, so the new files are in view.
router.visit(route('files.index', folder !== null ? { folder: folder.id } : {}));
}
};
@@ -44,7 +47,12 @@ export default function FilesCreate({ max_file_size_mb, part_size_mb, allowed_ex
}
/>
{folder !== null && (
<p className="text-muted-foreground mb-4 text-sm">{t('Uploading into :folder', { folder: folder.name })}</p>
)}
<ChunkedUploadDashboard
folderId={folder?.id}
maxFileSizeMb={max_file_size_mb}
partSizeMb={part_size_mb}
allowedExtensions={allowed_extensions}
+49 -7
View File
@@ -1,7 +1,7 @@
import { type BreadcrumbItem, type SharedData } from '@/types';
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { Head, Link, router, useForm, usePage } from '@inertiajs/react';
import { Archive, ChevronRight, ExternalLink, File as FileIcon, Folder as FolderIcon, FolderPlus, Info, Pencil, X } from 'lucide-react';
import { Archive, ChevronRight, ExternalLink, File as FileIcon, Folder as FolderIcon, FolderPlus, Info, Pencil, Trash2, X } from 'lucide-react';
import { FormEventHandler, useEffect, useRef, useState } from 'react';
import { BulkEditFilesDialog, type BulkEditPayload } from '@/components/bulk-edit-files-dialog';
@@ -28,6 +28,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { ViewModeToggle } from '@/components/view-mode-toggle';
import { ZipDownloadDialog } from '@/components/zip-download-dialog';
import { useCapability } from '@/hooks/use-capability';
import { ALL, useListQuery } from '@/hooks/use-list-query';
import { useTranslation } from '@/hooks/use-translation';
import { useViewMode } from '@/hooks/use-view-mode';
@@ -152,6 +153,9 @@ export default function FilesIndex({
const [panelTarget, setPanelTarget] = useState<DetailsTarget | null>(null);
const zip = useZipDownload();
// Withheld on some hosted plans. Only the zip buttons go: selection
// also drives bulk edit here, so the checkboxes stay.
const canZip = useCapability('downloads.zip');
const [selectedFileIds, setSelectedFileIds] = useState<Set<number>>(new Set());
const [selectedFolderIds, setSelectedFolderIds] = useState<Set<number>>(new Set());
const selectionCount = selectedFileIds.size + selectedFolderIds.size;
@@ -191,6 +195,19 @@ export default function FilesIndex({
{ preserveScroll: true, preserveState: false, onSuccess: () => clearSelection() },
);
// Only the selected files this person may delete. The server asks
// each one again (FilesController::bulkDestroy); this decides whether
// the button shows and what it says.
const deletableFileIds = files.filter((file) => selectedFileIds.has(file.id) && file.can_delete).map((file) => file.id);
const bulkDeleteFiles = () =>
router.delete(route('files.bulk-destroy'), {
data: { file_ids: deletableFileIds },
preserveScroll: true,
preserveState: false,
onSuccess: () => clearSelection(),
});
// A search term or a category filter both switch to a flat, global view,
// dropping the current folder context.
const { values, set, reset, hasFilters } = useListQuery(
@@ -287,7 +304,7 @@ export default function FilesIndex({
<div className="flex items-start justify-between">
<Heading title={t('Files')} description={t('Your shared file library')} />
<div className="flex gap-2">
{folder !== null && !searching && (
{canZip && folder !== null && !searching && (
<Button variant="outline" onClick={() => zip.start({ folder_ids: [folder.id] })}>
<Archive className="size-4" />
{t('Download as zip')}
@@ -393,7 +410,10 @@ export default function FilesIndex({
)}
{can_upload && (
<Button asChild>
<Link href={route('files.create')}>{t('Upload')}</Link>
{/* Into the folder on screen, not the top of the
library (#1801). Not while searching: the
results span folders, so there is no "here". */}
<Link href={route('files.create', folder !== null && !searching ? { folder: folder.id } : {})}>{t('Upload')}</Link>
</Button>
)}
</div>
@@ -549,10 +569,12 @@ export default function FilesIndex({
<div className="bg-muted/40 mb-3 flex items-center justify-between gap-3 rounded-lg border px-4 py-2">
<p className="text-sm font-medium">{t(':count selected', { count: selectionCount })}</p>
<div className="flex items-center gap-2">
<Button size="sm" onClick={downloadSelectionAsZip}>
<Archive className="size-4" />
{t('Download as zip')}
</Button>
{canZip && (
<Button size="sm" onClick={downloadSelectionAsZip}>
<Archive className="size-4" />
{t('Download as zip')}
</Button>
)}
{selectedFileIds.size === 0 ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -584,6 +606,26 @@ export default function FilesIndex({
onConfirm={bulkEditFiles}
/>
)}
{deletableFileIds.length > 0 && (
<ConfirmDialog
trigger={
<Button size="sm" variant="outline" className="text-destructive hover:text-destructive">
<Trash2 className="size-4" />
{t('Delete')}
</Button>
}
title={t('Delete the selected files?')}
description={
deletableFileIds.length === selectedFileIds.size
? t('They will no longer be available to anyone they were shared with.')
: t(
'Only the files you are allowed to delete are removed. They will no longer be available to anyone they were shared with.',
)
}
confirmLabel={t('Delete')}
onConfirm={bulkDeleteFiles}
/>
)}
<Button variant="ghost" size="sm" onClick={clearSelection}>
<X className="size-4" />
{t('Clear')}
+10 -2
View File
@@ -51,6 +51,8 @@ interface PortalEditFileProps {
};
can_delete: boolean;
can_publish: boolean;
/** This client's own root, where the installation gives them one. Null otherwise. */
home_folder_id: number | null;
/** The public links on this file, newest first, and where to make or revoke one. */
share_links: PortalShareLink[];
share_link_store_url: string;
@@ -80,6 +82,7 @@ export default function PortalEditFile({
file,
can_delete,
can_publish,
home_folder_id,
share_links,
share_link_store_url,
can_set_expiration,
@@ -95,7 +98,9 @@ export default function PortalEditFile({
const form = useForm({
name: file.name,
description: file.description ?? '',
folder_id: file.folder_id === null ? 'root' : String(file.folder_id),
// A file with no folder belongs in this client's own root where
// there is one — which is also what the server does with it.
folder_id: file.folder_id === null ? (home_folder_id === null ? 'root' : String(home_folder_id)) : String(file.folder_id),
public: file.public,
commentable: file.commentable,
expires_at: file.expires_at ?? '',
@@ -170,7 +175,10 @@ export default function PortalEditFile({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="root">{t('No folder')}</SelectItem>
{/* Not offered where this client has a home
folder: "no folder" would mean the root
of the library, which is not theirs. */}
{home_folder_id === null && <SelectItem value="root">{t('No folder')}</SelectItem>}
{folders.map((folder) => (
<SelectItem key={folder.id} value={String(folder.id)}>
<span className="flex items-center gap-1.5">
@@ -55,6 +55,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
} = props;
const {
zip,
canZip,
selectedFileIds,
selectedFolderIds,
selectionCount,
@@ -116,7 +117,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
</Link>
</Button>
)}
{folder !== null && !searching && (
{canZip && folder !== null && !searching && (
<Button variant="outline" size="sm" onClick={() => zip.start({ folder_ids: [folder.id] })}>
<Archive className="size-4" />
{t('Download as zip')}
@@ -140,18 +141,20 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
<PortalBreadcrumb breadcrumb={breadcrumb} folderUrl={folderUrl} className="mb-2" />
)}
<SelectionBar
count={selectionCount}
onDownload={downloadSelectionAsZip}
onClear={clearSelection}
className="mb-2 rounded-md px-3 py-1.5"
/>
{canZip && (
<SelectionBar
count={selectionCount}
onDownload={downloadSelectionAsZip}
onClear={clearSelection}
className="mb-2 rounded-md px-3 py-1.5"
/>
)}
<div className="overflow-x-auto rounded-none border border-neutral-300 dark:border-neutral-700">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="border-b border-neutral-300 bg-neutral-100 text-neutral-500 uppercase dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-400">
<th className="w-8 px-2 py-1 text-left font-medium"></th>
{canZip && <th className="w-8 px-2 py-1 text-left font-medium"></th>}
<th className="px-2 py-1 text-left font-medium">{t('Name')}</th>
<th className="w-24 px-2 py-1 text-right font-medium">{t('Size')}</th>
<th className="w-28 px-2 py-1 text-right font-medium">{t('Modified')}</th>
@@ -161,7 +164,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-800">
{folders.length === 0 && files.length === 0 && (
<tr>
<td colSpan={5} className="text-muted-foreground px-4 py-8 text-center">
<td colSpan={canZip ? 5 : 4} className="text-muted-foreground px-4 py-8 text-center">
{searching ? t('No files or folders match your search.') : t('No files have been shared with you yet.')}
</td>
</tr>
@@ -169,13 +172,15 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
{folders.map((row) => (
<tr key={`folder-${row.id}`} className="hover:bg-neutral-100 dark:hover:bg-neutral-900">
<td className="px-2 py-1">
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
/>
</td>
{canZip && (
<td className="px-2 py-1">
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
/>
</td>
)}
<td colSpan={3} className="px-2 py-1">
<Link href={folderUrl(row.id)} className="flex items-center gap-1.5 font-medium hover:underline">
<FolderIcon className="size-3.5 shrink-0 text-neutral-500" />
@@ -196,13 +201,15 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
{files.map((file) => (
<tr key={`file-${file.id}`} className="hover:bg-neutral-100 dark:hover:bg-neutral-900">
<td className="px-2 py-1 align-top">
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
/>
</td>
{canZip && (
<td className="px-2 py-1 align-top">
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
/>
</td>
)}
<td className="px-2 py-1">
<div className="flex items-start gap-1.5">
<FilePreviewDialog
@@ -74,6 +74,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
const {
zip,
canZip,
selectedFileIds,
selectedFolderIds,
selectionCount,
@@ -124,7 +125,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
</Link>
</Button>
)}
{folder !== null && !searching && (
{canZip && folder !== null && !searching && (
<Button variant="outline" onClick={() => zip.start({ folder_ids: [folder.id] })}>
<Archive className="size-4" />
{t('Download as zip')}
@@ -147,7 +148,9 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
<ViewModeToggle value={viewMode} onChange={setViewMode} />
</div>
<SelectionBar count={selectionCount} onDownload={downloadSelectionAsZip} onClear={clearSelection} className="mb-3" />
{canZip && (
<SelectionBar count={selectionCount} onDownload={downloadSelectionAsZip} onClear={clearSelection} className="mb-3" />
)}
{folders.length === 0 && files.length === 0 && (
<p className="text-muted-foreground rounded-lg border px-4 py-10 text-center text-sm">
@@ -159,11 +162,13 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
<div className="space-y-2">
{folders.map((row) => (
<div key={`folder-${row.id}`} className="bg-card flex items-center gap-3 rounded-lg border px-4 py-3">
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
/>
{canZip && (
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
/>
)}
<Link href={folderUrl(row.id)} className="hover:bg-accent/40 -m-3 flex flex-1 items-center gap-3 rounded-lg p-3">
<FolderIcon className="text-primary size-5 shrink-0" />
<p className="text-sm font-medium">{row.name}</p>
@@ -187,11 +192,13 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
every row. The name takes the slack and
the actions are one group at the end. */}
<div className="flex min-w-0 flex-1 items-center gap-3">
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
/>
{canZip && (
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
/>
)}
<FilePreviewDialog
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
mimeType={file.mime_type}
@@ -268,12 +275,14 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
key={`folder-${row.id}`}
className="group hover:border-primary/50 relative flex flex-col items-center justify-center gap-2 rounded-xl border p-6 text-center transition hover:shadow-md"
>
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
className="absolute top-3 left-3"
/>
{canZip && (
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
className="absolute top-3 left-3"
/>
)}
<Link href={folderUrl(row.id)} className="flex w-full flex-col items-center gap-2">
<FolderIcon className="text-primary size-10 shrink-0" strokeWidth={1.5} />
<span className="flex max-w-full items-center gap-1.5">
@@ -299,12 +308,14 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
key={`file-${file.id}`}
className="group hover:border-primary/50 relative overflow-hidden rounded-xl border transition hover:shadow-lg"
>
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
className="bg-background/80 absolute top-3 left-3 z-10"
/>
{canZip && (
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
className="bg-background/80 absolute top-3 left-3 z-10"
/>
)}
{preview_enabled && isPreviewable(file.mime_type) ? (
<FilePreviewDialog
@@ -56,6 +56,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
} = props;
const {
zip,
canZip,
selectedFileIds,
selectedFolderIds,
selectionCount,
@@ -124,7 +125,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
</Link>
</Button>
)}
{folder !== null && !searching && (
{canZip && folder !== null && !searching && (
<Button variant="outline" onClick={() => zip.start({ folder_ids: [folder.id] })}>
<Archive className="size-4" />
{t('Download as zip')}
@@ -144,13 +145,15 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
/>
)}
<SelectionBar
count={selectionCount}
onDownload={downloadSelectionAsZip}
onClear={clearSelection}
className="mb-3 max-w-3xl border-blue-200 bg-blue-50 px-4 py-2 dark:border-blue-900 dark:bg-blue-950"
downloadClassName="bg-blue-600 hover:bg-blue-700"
/>
{canZip && (
<SelectionBar
count={selectionCount}
onDownload={downloadSelectionAsZip}
onClear={clearSelection}
className="mb-3 max-w-3xl border-blue-200 bg-blue-50 px-4 py-2 dark:border-blue-900 dark:bg-blue-950"
downloadClassName="bg-blue-600 hover:bg-blue-700"
/>
)}
<div>
{folders.length === 0 && files.length === 0 && (
@@ -161,7 +164,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
{(folders.length > 0 || files.length > 0) && (
<div className="flex items-center gap-4 border-b border-neutral-200 px-2 pb-2 text-xs font-medium tracking-wide text-neutral-400 uppercase dark:border-neutral-800">
<span className="w-5" />
{canZip && <span className="w-5" />}
<span className="flex-1">{t('Name')}</span>
<span className="w-20 text-right">{t('Size')}</span>
<span className="w-9" />
@@ -173,11 +176,13 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
key={`folder-${row.id}`}
className="flex items-center gap-4 border-b border-neutral-100 px-2 py-4 hover:bg-blue-50/70 dark:border-neutral-900 dark:hover:bg-blue-950/30"
>
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
/>
{canZip && (
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
/>
)}
<Link href={folderUrl(row.id)} className="-my-4 flex flex-1 items-center gap-4 py-4">
<FolderIcon className="size-6 shrink-0 text-blue-600" />
<p className="flex items-center gap-1.5 text-sm font-medium text-neutral-800 dark:text-neutral-200">
@@ -205,11 +210,13 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
key={`file-${file.id}`}
className="flex items-center gap-4 border-b border-neutral-100 px-2 py-4 hover:bg-blue-50/70 dark:border-neutral-900 dark:hover:bg-blue-950/30"
>
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
/>
{canZip && (
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
/>
)}
<div className="flex min-w-0 flex-1 items-center gap-4">
<FilePreviewDialog
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
@@ -57,6 +57,7 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
} = props;
const {
zip,
canZip,
selectedFileIds,
selectedFolderIds,
selectionCount,
@@ -125,7 +126,7 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
</Link>
</Button>
)}
{folder !== null && !searching && (
{canZip && folder !== null && !searching && (
<Button variant="outline" onClick={() => zip.start({ folder_ids: [folder.id] })}>
<Archive className="size-4" />
{t('Download as zip')}
@@ -140,7 +141,9 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
<PortalBreadcrumb breadcrumb={breadcrumb} folderUrl={folderUrl} className="mb-4" />
)}
<SelectionBar count={selectionCount} onDownload={downloadSelectionAsZip} onClear={clearSelection} className="mb-4" />
{canZip && (
<SelectionBar count={selectionCount} onDownload={downloadSelectionAsZip} onClear={clearSelection} className="mb-4" />
)}
{folders.length === 0 && files.length === 0 && (
<p className="text-muted-foreground rounded-lg border px-4 py-10 text-center text-sm">
@@ -154,12 +157,14 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
key={`folder-${row.id}`}
className="relative flex flex-col items-center justify-center gap-2 rounded-xl border p-6 text-center transition hover:border-violet-400 hover:shadow-md"
>
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
className="absolute top-3 left-3"
/>
{canZip && (
<Checkbox
checked={selectedFolderIds.has(row.id)}
onCheckedChange={() => toggleFolder(row.id)}
aria-label={t('Select :name', { name: row.name })}
className="absolute top-3 left-3"
/>
)}
<Link href={folderUrl(row.id)} className="flex w-full flex-col items-center gap-2">
<FolderIcon className="size-10 shrink-0 text-violet-600" strokeWidth={1.5} />
<p className="flex w-full items-center justify-center gap-1.5 truncate text-sm font-medium">
@@ -183,12 +188,14 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
key={`file-${file.id}`}
className="group relative overflow-hidden rounded-xl border transition hover:border-violet-400 hover:shadow-lg"
>
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
className="bg-background/80 absolute top-3 left-3 z-10"
/>
{canZip && (
<Checkbox
checked={selectedFileIds.has(file.id)}
onCheckedChange={() => toggleFile(file.id)}
aria-label={t('Select :name', { name: file.name })}
className="bg-background/80 absolute top-3 left-3 z-10"
/>
)}
{/* Over the thumbnail, not beside the name: a card's
name line is a few characters wide, and an inline
@@ -91,7 +91,13 @@ export default function ConnectedAccounts({ providers, has_local_password }: Con
router.post(
route('connected-accounts.connect', { provider: provider.provider }),
{},
{ onStart: () => setProcessing(true) },
// onFinish too: a password confirmation the
// user cancels ends the request here, and
// the button must not stay dead.
{
onStart: () => setProcessing(true),
onFinish: () => setProcessing(false),
},
)
}
>
+10 -2
View File
@@ -13,7 +13,15 @@ import { type BreadcrumbItem } from '@/types';
* a place to land on by accident — reaching it should be a deliberate
* navigation, not a scroll.
*/
export default function DeleteAccount({ erasureGraceDays }: { erasureGraceDays: number }) {
export default function DeleteAccount({
erasureGraceDays,
filesWithdrawn,
filesDeletedImmediately,
}: {
erasureGraceDays: number;
filesWithdrawn: boolean;
filesDeletedImmediately: boolean;
}) {
const { t } = useTranslation();
const breadcrumbs: BreadcrumbItem[] = [
@@ -26,7 +34,7 @@ export default function DeleteAccount({ erasureGraceDays }: { erasureGraceDays:
<Head title={t('Delete account')} />
<SettingsLayout>
<DeleteUser graceDays={erasureGraceDays} />
<DeleteUser graceDays={erasureGraceDays} filesWithdrawn={filesWithdrawn} filesDeletedImmediately={filesDeletedImmediately} />
</SettingsLayout>
</AppLayout>
);
@@ -24,6 +24,10 @@ interface PrivacySettingsProps {
account_erasure_content_action: string;
account_erasure_reassign_to: number;
reassign_candidates: ReassignCandidate[];
account_self_delete_files: string;
/** A hosting platform made this choice; shown, not offered. */
account_self_delete_files_managed: boolean;
account_self_delete_scope: string;
api_request_log_retention_days: number;
discourage_search_indexing: boolean;
}
@@ -34,6 +38,9 @@ export default function PrivacySettings({
account_erasure_content_action,
account_erasure_reassign_to,
reassign_candidates,
account_self_delete_files,
account_self_delete_files_managed,
account_self_delete_scope,
api_request_log_retention_days,
discourage_search_indexing,
}: PrivacySettingsProps) {
@@ -59,6 +66,8 @@ export default function PrivacySettings({
account_erasure_grace_days: account_erasure_grace_days,
account_erasure_content_action: account_erasure_content_action,
account_erasure_reassign_to: account_erasure_reassign_to ? String(account_erasure_reassign_to) : '',
account_self_delete_files: account_self_delete_files,
account_self_delete_scope: account_self_delete_scope,
api_request_log_retention_days: api_request_log_retention_days,
discourage_search_indexing: discourage_search_indexing,
});
@@ -171,6 +180,56 @@ export default function PrivacySettings({
</div>
)}
<div className="grid gap-2">
<Label htmlFor="account_self_delete_files">{t('When someone deletes their own account, their files')}</Label>
<Select
value={data.account_self_delete_files}
onValueChange={(value) => setData('account_self_delete_files', value)}
disabled={account_self_delete_files_managed}
>
<SelectTrigger id="account_self_delete_files" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="after_grace_period">{t('Are deleted when the grace period ends')}</SelectItem>
<SelectItem value="immediately">{t('Are deleted right away')}</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-sm">
{account_self_delete_files_managed
? t('Set by your hosting plan.')
: t(
'Either way, from the moment the account is deleted, nobody but staff can see or download those files. Only their own uploads are deleted, and their folders only if nothing else is left inside.',
)}
</p>
<InputError className="mt-2" message={errors.account_self_delete_files} />
</div>
<div className="grid gap-2">
<Label htmlFor="account_self_delete_scope">{t('This applies when')}</Label>
<Select value={data.account_self_delete_scope} onValueChange={(value) => setData('account_self_delete_scope', value)}>
<SelectTrigger id="account_self_delete_scope" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">{t('Anyone deletes their own account')}</SelectItem>
<SelectItem value="clients">{t('A client deletes their own account')}</SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground text-sm">
{t(
"A staff member's uploads are often your organization's work for its clients. Choose clients only to keep serving those until the account is erased.",
)}
</p>
<InputError className="mt-2" message={errors.account_self_delete_scope} />
</div>
<div className="grid gap-2">
<Label htmlFor="api_request_log_retention_days">{t('API request history (days)')}</Label>
<Input
+3 -2
View File
@@ -51,7 +51,8 @@ export type Capability =
| 'branding.customize'
| 'attribution.hide'
| 'captcha.configure'
| 'captcha.managed_keys';
| 'captcha.managed_keys'
| 'downloads.zip';
/**
* A sidebar entry contributed by a package — see
@@ -201,7 +202,7 @@ export interface SharedData {
// Shared by the (cloud-only) projectsend/cloud-modules Branding
// module's own ServiceProvider, not by this app's own
// HandleInertiaRequests — absent entirely on community installs.
branding?: { logo_url: string | null };
branding?: { logo_url: string | null; show_site_name: boolean };
[key: string]: unknown;
}
+10 -3
View File
@@ -29,9 +29,16 @@
there is nothing on the client that could work the name out. --}}
<meta name="xsrf-cookie" content="{{ \App\Http\Middleware\ValidateCsrfToken::cookieName() }}">
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
{{-- An installed package may name its own icons in
projectsend.icons; they then replace these as a set, so a
stray default never outranks one of them in some browser. --}}
@forelse (config('projectsend.icons', []) as $icon)
<link rel="{{ $icon['rel'] }}" href="{{ $icon['href'] }}"@isset($icon['type']) type="{{ $icon['type'] }}"@endisset @isset($icon['sizes']) sizes="{{ $icon['sizes'] }}"@endisset>
@empty
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@endforelse
@routes
@viteReactRefresh
+36
View File
@@ -13,6 +13,8 @@ use App\Modules\Comments\Http\Controllers\Api\CommentModerationController;
use App\Modules\Comments\Http\Controllers\Api\FileCommentsController;
use App\Modules\Files\Http\Controllers\Api\FileAssignmentsController;
use App\Modules\Files\Http\Controllers\Api\FilesController;
use App\Modules\Files\Http\Controllers\Api\FolderAssignmentsController as ApiFolderAssignmentsController;
use App\Modules\Files\Http\Controllers\Api\FoldersController;
use App\Modules\Files\Http\Controllers\Api\FileVersionsController as ApiFileVersionsController;
use App\Modules\Files\Http\Controllers\ChunkedUploadsController;
use App\Modules\Files\Http\Controllers\FileDownloadController;
@@ -160,6 +162,40 @@ Route::middleware(['auth:sanctum', 'api-active', 'staff-token'])->group(function
->name('api.files.version.destroy');
});
/*
|----------------------------------------------------------------------
| Folders
|----------------------------------------------------------------------
|
| Reading is FolderPolicy::view()'s staff branch, the same three keys
| as reading files. Creating is `create_own_folders`, as on the web
| (the controller asks for `upload` with it, as the web does). Renaming,
| moving and sharing are "may edit", deleting is "may delete": both
| keys of each pair appear, and FolderPolicy decides which one applies
| to a given folder.
|
*/
Route::middleware('token-can:upload,edit_files,edit_others_files')->group(function () {
Route::get('folders', [FoldersController::class, 'index'])->name('api.folders.index');
Route::get('folders/{folder}', [FoldersController::class, 'show'])->name('api.folders.show');
});
Route::post('folders', [FoldersController::class, 'store'])
->middleware('token-can:create_own_folders')
->name('api.folders.store');
Route::middleware('token-can:edit_files,edit_others_files')->group(function () {
Route::patch('folders/{folder}', [FoldersController::class, 'update'])->name('api.folders.update');
Route::post('folders/{folder}/assignments', [ApiFolderAssignmentsController::class, 'store'])
->name('api.folders.assignments.store');
Route::delete('folders/{folder}/assignments', [ApiFolderAssignmentsController::class, 'destroy'])
->name('api.folders.assignments.destroy');
});
Route::delete('folders/{folder}', [FoldersController::class, 'destroy'])
->middleware('token-can:delete_files,delete_others_files')
->name('api.folders.destroy');
/*
|----------------------------------------------------------------------
| Comments
+1
View File
@@ -291,6 +291,7 @@ Route::middleware('auth')->group(function () {
Route::get('system/settings/branding', [BrandingController::class, 'edit'])->name('branding.edit');
Route::post('system/settings/branding', [BrandingController::class, 'store'])->name('branding.store');
Route::delete('system/settings/branding', [BrandingController::class, 'destroy'])->name('branding.destroy');
Route::patch('system/settings/branding/site-name', [BrandingController::class, 'updateSiteName'])->name('branding.site-name.update');
// POST rather than PATCH: the form carries a file, so it is
// multipart, and PHP only populates $_FILES for POST.
+15 -5
View File
@@ -151,6 +151,9 @@ Route::middleware(['auth'])->group(function () {
Route::patch('files/bulk-edit', [FilesController::class, 'bulkUpdate'])->middleware('staff')->name('files.bulk-update');
Route::patch('files/{file}', [FilesController::class, 'update'])->middleware('staff')->name('files.update');
Route::patch('files/{file}/move', [FilesController::class, 'move'])->middleware('staff')->name('files.move');
// Before files/{file}, which would otherwise read "bulk-delete" as a
// file id and 404.
Route::delete('files/bulk-delete', [FilesController::class, 'bulkDestroy'])->middleware('staff')->name('files.bulk-destroy');
Route::delete('files/{file}', [FilesController::class, 'destroy'])->middleware('staff')->name('files.destroy');
Route::post('files/{file}/assignments', [FileAssignmentsController::class, 'store'])->middleware('staff')->name('files.assignments.store');
Route::delete('files/{file}/assignments', [FileAssignmentsController::class, 'destroy'])->middleware('staff')->name('files.assignments.destroy');
@@ -222,11 +225,18 @@ Route::middleware(['auth'])->group(function () {
// Named bucket, as every throttle in this app must be: a bare
// `throttle:` keys on sha1(domain|ip) and would share one counter with
// every other bare throttle rather than with this route.
Route::post('zip-downloads', [ZipDownloadsController::class, 'store'])
->middleware('throttle:10,1,zip-downloads')
->name('zip-downloads.store');
Route::get('zip-downloads/{zipDownload}', [ZipDownloadsController::class, 'show'])->name('zip-downloads.show');
Route::get('zip-downloads/{zipDownload}/download', [ZipDownloadsController::class, 'download'])->name('zip-downloads.download');
//
// Behind capability:downloads.zip, all three, so a plan that withholds
// zips answers a hand-made request with 404 rather than only hiding
// the button. ZipDownloadRoutesGuardTest fails on any zip route that
// is missing it.
Route::middleware('capability:downloads.zip')->group(function () {
Route::post('zip-downloads', [ZipDownloadsController::class, 'store'])
->middleware('throttle:10,1,zip-downloads')
->name('zip-downloads.store');
Route::get('zip-downloads/{zipDownload}', [ZipDownloadsController::class, 'show'])->name('zip-downloads.show');
Route::get('zip-downloads/{zipDownload}/download', [ZipDownloadsController::class, 'download'])->name('zip-downloads.download');
});
// Resumable chunked uploads (Uppy aws-s3 multipart contract). Shared
// by staff and clients alike — ChunkedUploadsController's only
+355
View File
@@ -0,0 +1,355 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Models\FolderAssignment;
use App\Modules\Identity\Permissions\Permission;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$this->token = $this->admin->createToken('t', [
Permission::Upload->value,
Permission::EditFiles->value,
Permission::EditOthersFiles->value,
Permission::DeleteFiles->value,
Permission::DeleteOthersFiles->value,
Permission::CreateOwnFolders->value,
Permission::UploadPublic->value,
])->plainTextToken;
});
/** A token for a staff member whose role holds exactly these permissions. */
function folderApiToken(array $permissions): string
{
$user = staffWithPermissions(array_map(fn (Permission $p): string => $p->value, $permissions));
return $user->createToken('t', array_map(fn (Permission $p): string => $p->value, $permissions))->plainTextToken;
}
/** A client manager scoped to one client, and that client. */
function scopedFolderManager(): array
{
$client = User::factory()->client()->create();
$manager = User::factory()->role(SystemRole::ClientManager)->create();
$manager->assignedClients()->sync([$client->id]);
return [$manager, $client];
}
test('folders list with their place in the tree', function () {
$clients = makeFolder('Clients');
$acme = makeFolder('Acme', $clients);
$year = makeFolder('2026', $acme);
$rows = collect($this->withToken($this->token)->getJson('/api/v1/folders')->assertOk()->json('data'))->keyBy('id');
expect($rows[$year->id]['parent_id'])->toBe($acme->id)
->and($rows[$year->id]['path'])->toBe('Clients / Acme / 2026')
->and($rows[$year->id]['ancestors'])->toBe([
['id' => $clients->id, 'name' => 'Clients'],
['id' => $acme->id, 'name' => 'Acme'],
])
->and($rows[$clients->id]['ancestors'])->toBe([])
->and($rows[$clients->id]['path'])->toBe('Clients');
});
test('filters narrow the listing', function () {
$top = makeFolder('Projects');
$child = makeFolder('Invoices', $top);
$other = makeFolder('Archive');
$ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/folders?{$query}")->assertOk()->json('data.*.id');
expect($ids("parent_id={$top->id}"))->toBe([$child->id])
->and($ids('top_level=1'))->toEqualCanonicalizing([$top->id, $other->id])
->and($ids('search=voice'))->toBe([$child->id]);
});
test('polling with updated_since returns what changed, oldest first', function () {
$this->travelTo(now()->subDay());
$old = makeFolder('Old');
$this->travelBack();
$since = now()->subMinute()->toIso8601String();
$new = makeFolder('New');
$ids = $this->withToken($this->token)
->getJson('/api/v1/folders?updated_since='.urlencode($since))
->assertOk()->json('data.*.id');
expect($ids)->toBe([$new->id])->not->toContain($old->id);
});
test('a token without a file ability cannot list folders', function () {
$token = folderApiToken([Permission::ViewNews]);
$this->withToken($token)->getJson('/api/v1/folders')->assertForbidden();
});
/*
* The listing is the library screen's own scope, and the trail above a
* folder must not name folders the caller cannot reach.
*/
test('a client-scoped token sees only its folders, and not the names above them', function () {
[$manager, $client] = scopedFolderManager();
$secret = makeFolder('Board minutes');
$shared = makeFolder('Acme', $secret);
$unrelated = makeFolder('Somebody else');
$this->actingAs($this->admin)->post("/folders/{$shared->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken;
$rows = collect($this->withToken($token)->getJson('/api/v1/folders')->assertOk()->json('data'))->keyBy('id');
expect($rows->keys()->all())->toContain($shared->id)
->not->toContain($unrelated->id)
->not->toContain($secret->id)
->and($rows[$shared->id]['ancestors'])->toBe([])
->and($rows[$shared->id]['path'])->toBe('Acme');
$this->withToken($token)->getJson("/api/v1/folders/{$unrelated->id}")->assertForbidden();
$this->withToken($token)->getJson("/api/v1/folders/{$shared->id}")->assertOk()->assertJsonPath('data.path', 'Acme');
});
test('an unscoped token sees the whole trail of the same folder', function () {
$secret = makeFolder('Board minutes');
$shared = makeFolder('Acme', $secret);
$this->withToken($this->token)->getJson("/api/v1/folders/{$shared->id}")
->assertOk()
->assertJsonPath('data.path', 'Board minutes / Acme');
});
test('a folder can be created at the top or inside another', function () {
$response = $this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Clients'])
->assertStatus(201)
->assertJsonPath('data.name', 'Clients')
->assertJsonPath('data.parent_id', null);
$parentId = $response->json('data.id');
$this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme', 'parent_id' => $parentId])
->assertStatus(201)
->assertJsonPath('data.parent_id', $parentId)
->assertJsonPath('data.path', 'Clients / Acme');
$folder = Folder::query()->where('name', 'Acme')->firstOrFail();
expect($folder->created_by)->toBe($this->admin->id)
->and(ActivityLog::query()->where('action', Action::FolderCreated)->count())->toBe(2);
});
test('creating a folder that already exists returns it instead of a second one', function () {
$parent = makeFolder('Clients');
$existing = makeFolder('Acme', $parent);
$this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme', 'parent_id' => $parent->id])
->assertStatus(200)
->assertJsonPath('data.id', $existing->id);
// Same name somewhere else is a different folder.
$this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Acme'])->assertStatus(201);
expect(Folder::query()->where('name', 'Acme')->count())->toBe(2);
});
test('creating needs upload as well as create_own_folders', function () {
$token = folderApiToken([Permission::CreateOwnFolders]);
$this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Nope'])->assertForbidden();
expect(Folder::query()->where('name', 'Nope')->exists())->toBeFalse();
});
test('a folder cannot be created inside a public folder without upload_public', function () {
$public = makeFolder('Press kit');
$public->update(['public' => true]);
$token = folderApiToken([Permission::CreateOwnFolders, Permission::Upload, Permission::EditOthersFiles]);
$this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Drafts', 'parent_id' => $public->id])->assertForbidden();
expect(Folder::query()->where('name', 'Drafts')->exists())->toBeFalse();
});
test('a client-scoped token cannot create inside a folder it cannot see', function () {
[$manager] = scopedFolderManager();
$hidden = makeFolder('Somebody else');
$token = $manager->createToken('t', [Permission::CreateOwnFolders->value, Permission::Upload->value])->plainTextToken;
$this->withToken($token)->postJson('/api/v1/folders', ['name' => 'Sneaky', 'parent_id' => $hidden->id])->assertNotFound();
expect(Folder::query()->where('name', 'Sneaky')->exists())->toBeFalse();
});
test('the depth cap applies', function () {
$parent = null;
// The deepest folder allowed: one more level is refused.
for ($i = 0; $i < Folder::MAX_DEPTH; $i++) {
$parent = makeFolder("Level {$i}", $parent);
}
$this->withToken($this->token)->postJson('/api/v1/folders', ['name' => 'Too deep', 'parent_id' => $parent?->id])
->assertStatus(422)
->assertJsonValidationErrors('parent_id');
});
test('a folder can be renamed and moved, carrying its subtree', function () {
$from = makeFolder('From');
$to = makeFolder('To');
$folder = makeFolder('Acme', $from);
$child = makeFolder('2026', $folder);
$this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['name' => 'Acme Inc', 'parent_id' => $to->id])
->assertOk()
->assertJsonPath('data.name', 'Acme Inc')
->assertJsonPath('data.parent_id', $to->id)
->assertJsonPath('data.path', 'To / Acme Inc');
$this->withToken($this->token)->getJson("/api/v1/folders/{$child->id}")
->assertJsonPath('data.path', 'To / Acme Inc / 2026');
$this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => null])
->assertOk()
->assertJsonPath('data.parent_id', null);
expect(ActivityLog::query()->where('action', Action::FolderRenamed)->count())->toBe(1)
->and(ActivityLog::query()->where('action', Action::FolderMoved)->count())->toBe(2);
});
test('only the fields sent change', function () {
$parent = makeFolder('Parent');
$folder = makeFolder('Acme', $parent);
$this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['name' => 'Renamed'])
->assertOk()
->assertJsonPath('data.parent_id', $parent->id);
});
test('a folder cannot be moved into itself or below itself', function () {
$folder = makeFolder('Acme');
$child = makeFolder('2026', $folder);
$this->withToken($this->token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => $child->id])
->assertStatus(422)
->assertJsonValidationErrors('parent_id');
});
test('a folder cannot be moved into a public folder without upload_public', function () {
$public = makeFolder('Press kit');
$public->update(['public' => true]);
$folder = makeFolder('Private drafts');
$token = folderApiToken([Permission::Upload, Permission::EditFiles, Permission::EditOthersFiles]);
$this->withToken($token)->patchJson("/api/v1/folders/{$folder->id}", ['parent_id' => $public->id])->assertForbidden();
expect($folder->fresh()?->parent_id)->toBeNull();
});
test('an empty folder is deleted', function () {
$folder = makeFolder('Empty');
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}")->assertNoContent();
expect(Folder::query()->whereKey($folder->id)->exists())->toBeFalse();
});
test('a folder with content is refused unless the cascade is asked for', function () {
$folder = makeFolder('Acme');
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]);
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}")
->assertStatus(409)
->assertJsonPath('type', 'conflict');
expect(Folder::query()->whereKey($folder->id)->exists())->toBeTrue()
->and(File::query()->whereKey($file->id)->exists())->toBeTrue();
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}", ['content_action' => 'cascade_delete'])
->assertNoContent();
expect(Folder::query()->whereKey($folder->id)->exists())->toBeFalse()
->and(File::query()->whereKey($file->id)->exists())->toBeFalse();
});
test('a folder holding only a subfolder counts as not empty', function () {
$folder = makeFolder('Acme');
makeFolder('2026', $folder);
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}")->assertStatus(409);
});
test('the cascade is refused when it would take a file the token may not delete', function () {
$staff = staffWithPermissions([
Permission::Upload->value, Permission::EditFiles->value,
Permission::DeleteFiles->value, Permission::CreateOwnFolders->value,
]);
$token = $staff->createToken('t', [Permission::DeleteFiles->value])->plainTextToken;
$folder = Folder::query()->create(['name' => 'Reports', 'created_by' => $staff->id]);
$foreign = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]);
$this->withToken($token)->deleteJson("/api/v1/folders/{$folder->id}", ['content_action' => 'cascade_delete'])
->assertForbidden();
expect(Folder::query()->whereKey($folder->id)->exists())->toBeTrue()
->and(File::query()->whereKey($foreign->id)->exists())->toBeTrue();
});
test('a folder can be shared with a client and unshared', function () {
$folder = makeFolder('Acme');
$client = User::factory()->client()->create();
$this->withToken($this->token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id])
->assertOk()
->assertJsonPath('data.assignments.0.type', 'client')
->assertJsonPath('data.assignments.0.id', $client->id);
// Again: still one share.
$this->withToken($this->token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id])
->assertOk();
expect(FolderAssignment::query()->where('folder_id', $folder->id)->count())->toBe(1)
->and(ActivityLog::query()->where('action', Action::FolderShared)->exists())->toBeTrue();
$this->withToken($this->token)->deleteJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id])
->assertOk()
->assertJsonPath('data.assignments', []);
expect(FolderAssignment::query()->where('folder_id', $folder->id)->exists())->toBeFalse();
});
test('a client-scoped token cannot share with somebody else\'s client', function () {
[$manager] = scopedFolderManager();
$stranger = User::factory()->client()->create();
$folder = Folder::query()->create(['name' => 'Mine', 'created_by' => $manager->id]);
$token = $manager->createToken('t', [Permission::EditFiles->value])->plainTextToken;
$this->withToken($token)->postJson("/api/v1/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $stranger->id])
->assertStatus(422)
->assertJsonValidationErrors('id');
expect(FolderAssignment::query()->where('folder_id', $folder->id)->exists())->toBeFalse();
});
test('a file reports its folder\'s parent', function () {
$parent = makeFolder('Clients');
$folder = makeFolder('Acme', $parent);
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'folder_id' => $folder->id]);
$this->withToken($this->token)->getJson("/api/v1/files/{$file->id}")
->assertOk()
->assertJsonPath('data.folder.parent_id', $parent->id);
});
+37
View File
@@ -590,3 +590,40 @@ test('the top-clients widget names only clients on the viewer roster', function
->where('top_clients_by_storage.0.name', 'My Own Client'),
);
});
test('system capacity distinguishes local file storage from a configured temporary upload volume', function () {
$tempRoot = storage_path('app/separate-upload-volume');
config(['projectsend.uploads.parts_path' => $tempRoot]);
$capacity = new class(app(App\Modules\Files\Uploads\LocalPartStore::class)) extends App\Modules\Platform\Storage\StorageCapacity
{
protected function freeBytes(string $path): int
{
return $path === config('projectsend.uploads.parts_path') ? 99_000_000_000 : 39_000_000_000;
}
};
app()->instance(App\Modules\Platform\Storage\StorageCapacity::class, $capacity);
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('system.storage_driver', 'local')
->where('system.storage_free_bytes', 39_000_000_000)
->where('system.upload_temp_free_bytes', 99_000_000_000),
);
});
test('system capacity never presents local disk space as available object storage', function () {
config(['filesystems.disks.remote_uploads.driver' => 's3']);
Illuminate\Support\Facades\Event::listen(
App\Modules\Files\Storage\ResolvingUploadDisk::class,
function (App\Modules\Files\Storage\ResolvingUploadDisk $event): void {
$event->disk = 'remote_uploads';
},
);
// No credentials or bucket: reading capacity must not contact S3.
$this->actingAs($this->admin)->get('/dashboard')->assertInertia(
fn (AssertableInertia $page) => $page
->where('system.storage_driver', 's3')
->where('system.storage_free_bytes', -1)
->where('system.upload_temp_free_bytes', fn ($bytes): bool => is_int($bytes) && $bytes >= 0),
);
});

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