45 Commits

Author SHA1 Message Date
ignacionelson f22a346469 Release 2.7.0 2026-10-06 23:12:10 -03:00
ignacionelson 2597f1a53f Translate the first-password email into every locale 2026-10-06 22:55:37 -03:00
ignacionelson b121fb08fa Word a provider account's first-password email as setting, not resetting
Since GHSA-4r8h-mwfm-f5f4, an account that signs in through a provider
gets its first password only from the reset link emailed to it. That
email said "you are receiving this because we received a password reset
request", to somebody who never had a password and may well ignore it.

ResetPasswordNotification now takes firstPassword, which
User::sendPasswordResetNotification() sets for an AuthSource::Social
account: "Set your password", what the link is for, and that nothing
changes if they did not ask. It is not taken from the customisable reset
template, whose text is written about resetting. Accounts with a password
get the reset email exactly as before.
2026-10-06 22:55:37 -03:00
ignacionelson 8537ca58a9 Update shell-quote and source-map-js for their new advisories
shell-quote 1.10.0 -> 1.12.0 (GHSA-pqg4-j6r4-53mv, critical: command
injection through quote()) and source-map-js 1.2.1 -> 1.2.2
(GHSA-68fv-2mgg-jv7q, high: denial of service through section offsets).
Both arrive through build tools only, concurrently and vite's postcss,
so neither runs in what a release ships; updated so the release does not
carry an open critical alert. Within the ranges their parents already
allow, same maintainers, no dependencies or install scripts, and each
lockfile integrity matches the registry. npm audit --omit=dev reports
nothing.
2026-10-06 22:53:38 -03:00
ignacionelson 53c4a4304d Keep the open folder and the chosen sort while browsing My files
Changing the sort (or search, category, owner) inside a folder on the
client portal navigated to the top of My files, because the list query
was built without the folder. And opening a folder, or following a
breadcrumb, dropped the chosen sort, because folderUrl() carried only the
folder id. Every portal theme shares the hook, so all four were affected.

The list query now carries the open folder, and folderUrl() carries the
sort when it is not the default (newest first), so default addresses
stay clean. Search and the filters still show a flat list across every
folder, as MyFilesController intends; clearing them returns to the
folder. The staff library is unchanged: there, dropping the folder for a
search is documented intent.

Checked in a browser on a client account: sorting by name inside a folder
stays in it and reorders it, and a folder link from a sorted list keeps
the sort.

Reported by @cookiebaker (#1806)
2026-10-06 22:31:24 -03:00
ignacionelson 2ff09767c9 Merge pull request #1810 from veenone/feat/ldap-signin-by-username
Feat/ldap signin by username
2026-10-05 16:12:13 -03:00
ignacionelson dd8bf8a657 Translate the strings added this week into every locale
22 strings, all 16 locales: the logo crop, the folder API's non-empty
delete, your own credentials staying behind your profile, a provider
account's emailed first-password link, and the token form's warning
about account-control abilities. Each locale keeps its own register:
formal German, French, Czech, Russian and Turkish; informal Spanish,
Catalan, Dutch, Italian, Portuguese and Polish. Placeholders and the
literal content_action=cascade_delete are untouched. Added at the end of
each file; no existing entry moved or changed.
2026-10-05 02:37:42 -03:00
ignacionelson 4e8150541d Write the orphan item-key separator as \u0000, not a literal NUL byte
The separator in itemKey() was a raw NUL character inside a template
literal. It works, but git reads the file as binary because of it, so
every change to the orphans screen showed as "Binary files differ" and
went unreviewed, #1809's included. The escape is the same string at
runtime and the file is text again.
2026-10-05 02:27:18 -03:00
ignacionelson c77d80309e Harden the background orphan import from #1809
Found in review, none of them reachable in our shipped setups but each
cheap to close:

- Two chunks could adopt the same path when more than one worker runs
  the default queue: a run that stalls unblocks a new one after five
  minutes, and the old chain can resume beside it. Two rows on one set of
  bytes means deleting either deletes the other's file. Each path is now
  claimed under a cache lock and checked for a row inside it, so a path
  another chunk holds is left to it. A lock around the whole chunk was
  tried first and dropped: a chunk queues the next one while it still
  holds the lock, so the next one was discarded and the run died.
- A chunk now checks that the account that started the run is still
  active, still staff and still holds import_orphans. A run can outlast
  that access, and every chunk adopts files in that person's name.
- A failure shows a plain sentence and sends the exception to the log.
  A storage error can name a bucket, an endpoint or a path.
2026-10-05 02:27:18 -03:00
ignacionelson a63fea8a4d Merge pull request #1809 from veenone/feat/orphan-import-all
Import all matching orphans in a background job
2026-10-05 02:24:41 -03:00
ignacionelson 4641393d6e Changelog: the orphan tool refuses a disguised path
GHSA-pv88-7863-5hwq
2026-10-05 01:17:02 -03:00
ignacionelson c384a860c8 Test that no spelling of a tracked file's path makes it an orphan
GHSA-pv88-7863-5hwq
2026-10-05 01:16:52 -03:00
ignacionelson 1e34773ad3 Refuse an orphan path the storage layer would rewrite
The orphan check compared the path it was given with the paths file rows
hold, but Flysystem rewrites a path before it touches storage: "./a/b",
"a/./b", "a//b", "/a/b", "a\b" and "a/x/../b" all become "a/b". Any of
them made a file somebody owns look like an orphan, so deleting it
removed their bytes without delete_others_files, and importing it put a
second row on them. The same spellings walked past the exclusion of
derived-artifact folders.

isOrphan(), which import and delete both go through, now refuses a path
the normalizer would change or rejects outright. The scan only offers
paths as storage lists them, so nothing it sends is affected.

GHSA-pv88-7863-5hwq
2026-10-05 01:16:52 -03:00
ignacionelson c5a547ffc9 Changelog: invitations stop at a scoped staff member's reach
GHSA-phv7-54fm-qh4r
2026-10-05 01:14:23 -03:00
ignacionelson 7165d136e1 Test that the invitation list and revoke stop at a scoped staff member's reach
GHSA-phv7-54fm-qh4r
2026-10-05 01:14:10 -03:00
ignacionelson 2a6f77a02a Keep a scoped staff member's invitation list and revoke inside their reach
A staff member limited to some clients may only invite into the groups
those clients are in, but the invitation list showed every invitation
in the installation, names and addresses included, and revoking took
back any pending one. Both now ask InvitationController::visibleTo(): the
invitations they sent, and those into a group within their reach. Out of
reach reads as 404. Unscoped staff are unaffected.

GHSA-phv7-54fm-qh4r
2026-10-05 01:14:10 -03:00
ignacionelson 34fd0abc4c Changelog: a provider account's first password comes by email
GHSA-4r8h-mwfm-f5f4
2026-10-05 00:45:31 -03:00
ignacionelson 7d1bbb3485 Test that a provider account's first password needs its inbox
GHSA-4r8h-mwfm-f5f4
2026-10-05 00:44:26 -03:00
ignacionelson 717852ff6a A provider account's first password comes from its inbox, not its session
An account that signs in through a provider has no password to prove,
so the password screen let the signed-in session choose one with no
proof at all. A stolen session could then make itself permanent: set a
password, confirm it, enrol its own second factor and remove the owner's
last provider, since the account now read as local.

The screen now refuses to set a provider account's password and offers
to email a link instead: the ordinary reset link, to the account's own
address, so whoever sets the password must read that inbox. The reset
pages accept a signed-in visitor, since the owner opens the link in the
browser they are signed in with; the token, not the session, is the
authority. Using the link signs out every session holding the old
password, the one that asked for it included. Compulsory two-factor lets
the link through, so a provider account still has a way to enrol.

Ordinary accounts are unchanged: they prove their current password.

GHSA-4r8h-mwfm-f5f4
2026-10-05 00:44:26 -03:00
veenone 147fd23507 Translate the strings added for LDAP sign-in by username
Four strings, in all sixteen locales: the username attribute setting and
its hint, and the login field's label and description when username
sign-in is on.
2026-10-05 07:51:21 +07:00
veenone 9c43f9cb9a Let directory clients sign in with their username as well as their address
LDAP sign-in only took an email address. The LDAP settings now have an
optional username attribute (cn, uid, sAMAccountName and so on). Once it
is set, the login field also takes a username. The service account looks
the username up, and the login carries on with the address the directory
holds for it, through the same checks, single user bind, provisioning
and rate limiting as an email login.

Whether the input is an address is decided by the same email rule that
accepted every stored address, so an address such as someone@localhost
is never taken for a username. The username goes through the query
builder, so it is escaped, and it has to match exactly one entry. The
directory is client-only, so a username never signs in a staff account.
With the attribute left empty, nothing changes.

This ports feat/ldap_signin_by_username, which was written against v1
and has no history in common with this codebase.
2026-10-05 07:51:21 +07:00
veenone b8108cdf70 Translate the strings added for "Import all" on the orphans screen
Fourteen strings, in all sixteen locales: the select-all link, the note
about skipped files, the Import all button, the four states of a
background run, and the messages for a queued or already running import.

For the queued message, Russian, Polish and Czech get all three plural
forms Laravel picks from in those languages. With only two, a count such
as 5000 would use the singular.
2026-10-05 06:35:46 +07:00
veenone 4b30849a88 Let "Import all" adopt every orphan the search matches, in a background job
The header checkbox on Import orphan files selected only the 25 rows on
screen, so an install with thousands of stray files had to import them a
page at a time. Once a whole page is ticked, the selection bar now offers
"Select all N matching files", and "Import all" takes every orphan the
search matches, on every page.

The import runs in a queued job because it is too slow for a request.
Each file is hashed in full and written in three commits, so 5,000 files
of 4 MB take about four minutes, and PHP stops a request after 30 s of
CPU, around file 1,100. ImportOrphanFilesJob works on the default queue in
chunks of about 45 s: each chunk rescans, imports what is still orphaned
and queues the next one. That keeps every job inside the worker's 60 s
timeout and the queue's 90 s retry_after, so no extra worker is needed,
and mail queued in the meantime goes out between chunks. If a run dies
part way, the next one picks up what is left.

Only one run can be active at a time. OrphanImportProgress keeps its state
in the cache and starts a run under a lock. While a run is active, every
other import is refused, the per-row button included, so no file is
adopted twice. The page polls files/orphans/import-status every 3 s and
shows the run as running, finished, failed with the reason, or stalled
after 5 minutes without progress, which usually means no worker is
listening.

Bulk delete still works one page at a time. The adoption itself moved to
OrphanFileImporter so the request and the job share it, and the rule for
what can be imported now lives in OrphanFileScanner::importable().
2026-10-05 06:35:46 +07:00
ignacionelson 7a1aa4021b Update the dependencies behind the open security alerts
Composer, each package alone, nothing else in the lock moved:
laravel/framework 12.64.0 -> 12.69.3, league/commonmark 2.10.0 -> 2.10.3,
league/flysystem 3.35.2 -> 3.36.0, phpseclib/phpseclib 3.0.56 -> 3.0.57.
composer audit reports nothing.

npm, within the ranges package.json already allows: axios 1.19.0 ->
1.20.0, and brace-expansion 1.1.18 -> 1.1.21 and 2.1.4 -> 2.1.7 (both
dev-only, under minimatch). No new dependencies or install scripts, and
each lockfile integrity matches the registry. npm audit --omit=dev
reports nothing.
2026-10-04 04:19:11 -03:00
ignacionelson 5ed5719135 Merge branch feat/logo-crop
Crop the logo, keep the upload, and restore it
2026-10-03 23:37:28 -03:00
ignacionelson d3230a4b64 Say what edit_clients and edit_users reach, and document the new refusals
Setting a password and removing a second factor are how an
administrator lets a locked-out person back in, so a token holding
edit_clients or edit_users can sign in as the accounts it may edit. That
stays what those abilities mean; it is now said where it is chosen. The
token form warns when either is ticked, and the API guide says it beside
the abilities, with the three refusals on your own account under "Staff
accounts". The OpenAPI document carries the new 403s, and CHANGELOG.md
an Unreleased entry.

GHSA-j5cp-r8pr-m5cr
2026-10-03 23:09:03 -03:00
ignacionelson 2da341b821 Test that your own credentials stay behind your profile, and resets end tokens
GHSA-j5cp-r8pr-m5cr
2026-10-03 23:05:23 -03:00
ignacionelson db65731c3a Keep your own credentials behind your profile, and end tokens on a reset
Your own email address, password and second factor are changed from your
profile, which asks for your current password. The staff screen and the
API changed the first two with no password at all, and the API removed
the third on your own account without the confirmation the web asks
for. StaffAccounts::ownCredentialChanges is the one rule both now ask:
the staff screen refuses your own email or password with a validation
error and points to the profile, and the API answers 403, as it does for
removing your own second factor.

Changing somebody else's password is unchanged: that is what edit_users
and edit_clients mean, on the screen and over the API. It now also
revokes that account's API tokens. Browser sessions already ended with
the password hash; tokens did not.

GHSA-j5cp-r8pr-m5cr
2026-10-03 23:05:23 -03:00
ignacionelson 2d8562abba Keep a tall logo inside the crop dialog
react-image-crop's stylesheet gives the image max-height: inherit, so the
limit set on the image was overridden: a portrait logo ran past the
dialog and its bottom handles could not be reached. The limit now sits on
the crop wrapper, and the scrolling container is gone.
2026-10-03 12:10:42 -03:00
ignacionelson bbd424a8d1 Crop the logo from the Branding screen, and restore the original
A Crop button opens the uploaded image with a free-shape box, starting
from the last crop; Restore original appears once there is one. The box
is sent in the upload's own pixels and the server cuts the file. The
image is shown with image-orientation: none, the pixels as the server
reads them, since no image here has the exif extension to rotate by an
orientation tag.

Adds react-image-crop 11.1.2: no dependencies, no install scripts, and
its lockfile integrity matches the registry.
2026-10-03 12:07:26 -03:00
ignacionelson ac1093dc8c Test the logo crop: which pixels it keeps, restoring, cleanup and its limits 2026-10-03 12:03:51 -03:00
ignacionelson 4485e36c5c Crop the logo on the server, from the kept upload, and restore it
Cropping is optional: an upload is used whole until somebody crops it.
A crop is a new file cut from the upload with SimpleImage, which the
watermark already uses; the upload is kept (logo_original_path) with
the box (logo_crop), so cropping again starts from the whole picture and
restoring points back at the upload. A box covering the whole image is
a restore. The box must lie inside the image, and an image over 25
million pixels is refused before GD decodes it.

A new upload, or removing the logo, deletes both files.
2026-10-03 12:02:41 -03:00
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
98 changed files with 5356 additions and 334 deletions
+66
View File
@@ -10,6 +10,72 @@ 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.7.0 — 6 October 2026
Four security fixes, folders you can manage over the API, and logo cropping.
### ⚠️ Important — do these yourself
Everything else in this release happens on its own, and nothing here stops the upgrade.
- **If your installation cannot send email, accounts that sign in through Google, Microsoft or
another provider can no longer set their own first password.** The link to set it now arrives by
email. Until mail works, an administrator can set the password from the person's account screen.
- **If an integration changes its own account's email address, password or two-factor
authentication through the API, it now gets a 403.** Do that from the profile screen instead.
Changing somebody else's still works as before.
**Added**
- **Folders in the API**: list, create, rename, move, delete and share them, and see where each one
sits in the tree.
- **Crop your logo** from Branding → Logo, and put the original back at any time.
- **"Import all" on the orphan files screen** adopts every match in the background and shows its
progress.
- **LDAP clients can sign in with their username** as well as their email address, once you name the
username attribute in Settings → LDAP.
**Security**
- An API token can no longer change its own owner's password, email address or two-factor
authentication, and setting somebody else's password now signs them out of the API.
- The staff screen no longer changes your own email address or password without your current
password.
- An account that signs in through a provider now gets its first password from a link sent to its
own email, not from whoever holds its browser session.
- A staff member limited to some clients now sees and revokes only their own invitations and those
into their clients' groups.
- A staff member limited to some clients no longer sees the names of folders above the ones they can
reach.
- The orphan file tool can no longer be pointed at a file that belongs to somebody.
- Creating a folder inside a public folder now needs permission to publish.
**Changed**
- **Your logo is shown larger** on the sign-in and download pages.
- **The API token screen warns you** when "Edit clients" or "Edit users" is chosen: a token with
either can sign in as the accounts it may edit.
- The Docker image no longer fills its log with passing health checks; failing ones still show.
**Fixed**
- In the client portal, changing the sort inside a folder no longer jumps back to the top, and the
chosen sort stays when you open a folder.
- Third-party libraries are updated, including fixes for published security advisories.
Thanks to [@simjiun](https://github.com/simjiun), [@sbouabid-sec](https://github.com/sbouabid-sec),
[@veenone](https://github.com/veenone), [@jiits](https://github.com/jiits),
[@cookiebaker](https://github.com/cookiebaker) and
[@01110111000001](https://github.com/01110111000001) for reporting and fixing.
### Issues closed since 2.6.0
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original report.
- [#1798](https://github.com/projectsend/projectsend/issues/1798) — [ Feature request ] Branding : Site Name, Logo Size and Image Crop
- [#1806](https://github.com/projectsend/projectsend/issues/1806) — Sorting doesn't work with subfolders
## 2.6.0 — 25 September 2026
Mostly fixes: files and folders are easier to tidy, the sign-in pages carry your brand better, and
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Modules\Identity\Ldap\LdapSettings;
use App\Modules\Identity\StartPages;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
@@ -25,6 +26,7 @@ class AuthenticatedSessionController extends Controller
'canResetPassword' => Route::has('password.request'),
'canRegister' => app(Settings::class)->get(Setting::ClientsCanRegister) === true,
'status' => $request->session()->get('status'),
'usernameSignIn' => LdapSettings::current()->allowsUsernameSignIn(),
]);
}
@@ -11,6 +11,8 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password as PasswordBroker;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
@@ -53,32 +55,24 @@ class PasswordController extends Controller
// honest answer is to refuse rather than to appear to work.
abort_if($user->auth_source === AuthSource::Ldap, 403);
$setsFirstPassword = $user->auth_source === AuthSource::Social;
// An account that signs in through a provider has no password to
// prove, so this screen cannot ask it for one, and the session
// alone is not enough: a stolen session that could choose the
// password became the account for good (GHSA-4r8h-mwfm-f5f4). Its
// first password comes from a link emailed to its own address,
// which sendLink() asks for and NewPasswordController completes.
if ($user->auth_source === AuthSource::Social) {
throw ValidationException::withMessages([
'password' => __('Ask for a link by email to set your first password.'),
]);
}
$validated = $request->validate([
// Not asked of an account that has never had one: it signs in
// through a provider, and its stored hash is a generated
// string nobody has seen. Asking anyway left those accounts
// with no way to set a password — and so no way to enrol in
// two-factor, which an installation can make compulsory.
'current_password' => $setsFirstPassword ? ['nullable'] : ['required', 'current_password'],
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$attributes = ['password' => Hash::make($validated['password'])];
// The same line NewPasswordController writes when a provider
// account resets its password, for the same reason: the hash is
// now what signs this account in, and `has_local_password` is read
// off this column all over the settings screens.
if ($setsFirstPassword) {
$attributes['auth_source'] = AuthSource::Local;
}
// forceFill, not update(): `auth_source` is guarded, so a mass
// assignment drops it silently — which left the account still
// reading as passwordless after it had a password.
$user->forceFill($attributes)->save();
$user->forceFill(['password' => Hash::make($validated['password'])])->save();
// Changing a password is how someone reacts to a session they think
// is stolen, so it has to actually end that session. AuthenticateSession
@@ -93,4 +87,35 @@ class PasswordController extends Controller
return back();
}
/**
* Email an account that signs in through a provider a link to set its
* first password.
*
* The ordinary reset link, to the account's own address: whoever sets
* the password has to read that inbox, which a stolen session cannot
* do. Opening it completes through NewPasswordController, which turns
* the account local, and changing the password signs out every session
* holding the old one, the one that asked for the link included.
*/
public function sendLink(Request $request): RedirectResponse
{
$user = $request->user();
assert($user !== null);
// An account with a password uses the form above; a directory
// account's password is not this installation's to set.
abort_unless($user->auth_source === AuthSource::Social, 403);
$status = PasswordBroker::broker()->sendResetLink(['email' => $user->email]);
// Their own account, so being told to wait reveals nothing.
if ($status === PasswordBroker::ResetThrottled) {
throw ValidationException::withMessages([
'link' => __('A link was sent a moment ago. Check your email, or try again in a minute.'),
]);
}
return back()->with('status', 'password-link-sent');
}
}
+34 -7
View File
@@ -4,7 +4,9 @@ namespace App\Http\Requests\Auth;
use App\Models\User;
use App\Modules\Identity\AccountLookup;
use App\Modules\Identity\Ldap\LdapAuthenticator;
use App\Modules\Identity\Ldap\LdapProvisioner;
use App\Modules\Identity\Ldap\LdapSettings;
use App\Modules\Identity\PasswordVerification;
use App\Modules\Identity\SignIn;
use App\Modules\Platform\Captcha\CaptchaForm;
@@ -14,6 +16,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -35,7 +38,12 @@ class LoginRequest extends FormRequest
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
// The field keeps its name either way: with a directory username
// attribute configured it also takes a username. See
// loginEmail().
'email' => LdapSettings::current()->allowsUsernameSignIn()
? ['required', 'string', 'max:255']
: ['required', 'string', 'email'],
'password' => ['required', 'string'],
// Deliberately here rather than inside authenticate(): rules
// run first, so a bot never reaches the credential check, and
@@ -72,23 +80,32 @@ class LoginRequest extends FormRequest
{
$this->ensureIsNotRateLimited();
// Anything that is not an address is a directory username, and from
// here on the login is for the address the directory holds for it.
$login = (string) $this->string('email');
$byUsername = ! $this->isEmail($login);
$email = $byUsername ? app(LdapAuthenticator::class)->emailForUsername($login) : $login;
// Exact, for the reason SocialAuthenticator is: a collation that
// folds accents would otherwise let somebody typing
// admin@éxample.com be *identified* as admin@example.com. A
// password still gates this one, so it was never the takeover the
// social path was — but identifying the wrong account is the bug,
// and the credential check is a second line rather than the rule.
$user = app(AccountLookup::class)->byEmail((string) $this->string('email'));
$user = $email !== null ? app(AccountLookup::class)->byEmail($email) : null;
// A directory identity with no local account yet. Returns null
// unless LDAP is on, auto-provisioning is on, and the bind
// succeeds — so an unknown email costs nothing on an installation
// that does not use a directory.
if ($user === null) {
$user = app(LdapProvisioner::class)->provision(
(string) $this->string('email'),
(string) $this->string('password'),
);
if ($user === null && $email !== null) {
$user = app(LdapProvisioner::class)->provision($email, (string) $this->string('password'));
}
// The directory is client-only. A username is a directory name, so
// it never leads to a staff account, whichever password is typed.
if ($byUsername && $user !== null && ! $user->isClient()) {
$user = null;
}
$verified = $this->verifyCredentials($user);
@@ -118,6 +135,16 @@ class LoginRequest extends FormRequest
return $pendingTwoFactor;
}
/**
* By the same `email` rule that admitted every stored address, so an
* address it accepts and filter_var() does not (a dotless domain, a
* non-ASCII local part) is never mistaken for a username.
*/
private function isEmail(string $login): bool
{
return Validator::make(['email' => $login], ['email' => 'email'])->passes();
}
/**
* The account whose password checks out, or null.
*
+4 -1
View File
@@ -152,7 +152,10 @@ class User extends Authenticatable implements HasLocalePreference
*/
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPasswordNotification($token));
// An account that signs in through a provider has never had a
// password here, so its link sets the first one rather than
// resetting anything (PasswordController::sendLink).
$this->notify(new ResetPasswordNotification($token, firstPassword: $this->auth_source === AuthSource::Social));
}
/**
@@ -70,7 +70,10 @@ class InvitationController extends Controller
// waiting. A screen that showed only what is outstanding cannot
// answer "did we ever invite this person", which is the question
// somebody actually arrives with.
$invitations = Invitation::query()
$viewer = $request->user();
assert($viewer !== null);
$invitations = $this->visibleTo($viewer)
->when($status !== null, fn (Builder $query) => $this->applyStateFilter($query, (string) $status))
->with(['group:id,name', 'invitedBy:id,name'])
// Newest first, the order a history is read in. What is urgent
@@ -101,6 +104,31 @@ class InvitationController extends Controller
]);
}
/**
* The invitations this staff member may see and revoke.
*
* Everyone's, for most staff. For one limited to some clients, the same
* line create() and store() draw when they send one: the invitations
* they sent themselves, and those into a group one of their clients is
* in. Anything else would hand them other invitees' names and addresses
* and let them revoke invitations other people sent
* (GHSA-phv7-54fm-qh4r).
*
* @return Builder<Invitation>
*/
private function visibleTo(User $viewer): Builder
{
$query = Invitation::query();
if (! $viewer->isClientScoped()) {
return $query;
}
return $query->where(fn (Builder $mine) => $mine
->where('invited_by_id', $viewer->id)
->orWhereIn('group_id', $this->scope->groups($viewer)->select('groups.id')));
}
/**
* @param Builder<Invitation> $query
* @return Builder<Invitation>
@@ -197,8 +225,15 @@ class InvitationController extends Controller
* invited this address and when, and that trail should still lead
* somewhere.
*/
public function destroy(Invitation $invitation): RedirectResponse
public function destroy(Request $request, Invitation $invitation): RedirectResponse
{
$viewer = $request->user();
assert($viewer !== null);
// Out of reach reads as not there, like the rest of a scoped
// staff member's surfaces.
abort_unless($this->visibleTo($viewer)->whereKey($invitation->id)->exists(), 404);
// Already spent, already superseded, already revoked: there is
// nothing left to cancel, and saying so is better than reporting a
// success that changed nothing.
@@ -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);
}
}
@@ -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
@@ -5,20 +5,24 @@ declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Jobs\ImportOrphanFilesJob;
use App\Modules\Files\Models\File;
use App\Modules\Files\OrphanFileImporter;
use App\Modules\Files\OrphanFileScanner;
use App\Modules\Files\OrphanImportProgress;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Uploads\StoreUploadedFile;
use App\Support\Pagination;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
@@ -33,7 +37,8 @@ class OrphanFilesController extends Controller
{
public function __construct(
private readonly OrphanFileScanner $scanner,
private readonly StoreUploadedFile $storeFile,
private readonly OrphanFileImporter $importer,
private readonly OrphanImportProgress $progress,
private readonly ActivityLogger $activity,
) {}
@@ -91,6 +96,7 @@ class OrphanFilesController extends Controller
'search' => $search,
'scanned_disks' => $this->scanner->scannedDisks(),
'missing_count' => File::query()->where('scan_status', ScanStatus::Missing)->count(),
'import_run' => $this->progress->current(),
]);
}
@@ -138,14 +144,20 @@ class OrphanFilesController extends Controller
$user = $request->user();
assert($user !== null);
$validated = $this->validateItems($request);
if ($request->boolean('all')) {
return $this->importAll($request, $user);
}
// While a background run is adopting files, a second importer
// could adopt the same path twice.
if ($this->progress->isActive()) {
$this->alreadyRunning();
}
$imported = 0;
$importedFile = null;
foreach ($validated['items'] as $item) {
$disk = Storage::disk($item['disk']);
foreach ($this->validateItems($request)['items'] as $item) {
// Re-validate against a fresh scan — never trust a
// client-supplied disk/path just because an earlier scan
// listed it.
@@ -153,18 +165,7 @@ class OrphanFilesController extends Controller
continue;
}
$importedFile = $this->storeFile->create(
uploader: $user,
originalName: basename($item['path']),
path: $item['path'],
mimeType: $disk->mimeType($item['path']) ?: 'application/octet-stream',
size: $disk->size($item['path']),
checksum: $this->checksumOf($disk, $item['path']),
folderId: null,
disk: $item['disk'],
action: Action::FileImported,
);
$importedFile = $this->importer->import($user, $item['disk'], $item['path']);
$imported++;
}
@@ -182,6 +183,49 @@ class OrphanFilesController extends Controller
));
}
/**
* Every orphan the search matches, on every page. Handed to a queued
* job: thousands of files hashed in full take minutes, and a request
* is cut off after 30s of CPU — part-way through, with an error page
* for an import that was in fact half done. See ImportOrphanFilesJob.
*/
private function importAll(Request $request, User $user): RedirectResponse
{
$search = trim($request->validate(['search' => ['nullable', 'string', 'max:255']])['search'] ?? '');
$search = $search !== '' ? $search : null;
$total = count($this->scanner->importable($user, $search));
if ($total === 0) {
return back()->with('success', trans_choice(':count file imported.|:count files imported.', 0, ['count' => '0']));
}
if (! $this->progress->tryStart($total)) {
$this->alreadyRunning();
}
ImportOrphanFilesJob::dispatch($user->id, $search);
return back()->with('success', trans_choice(
'Importing :count file in the background.|Importing :count files in the background.',
$total,
['count' => (string) $total],
));
}
private function alreadyRunning(): never
{
throw ValidationException::withMessages(['items' => __('An import is already running. Wait for it to finish.')]);
}
/**
* Polled by the orphans screen while a background run is going.
*/
public function importStatus(): JsonResponse
{
return response()->json($this->progress->current());
}
public function destroy(Request $request): RedirectResponse
{
$validated = $this->validateItems($request);
@@ -218,24 +262,4 @@ class OrphanFilesController extends Controller
'items.*.path' => ['required', 'string'],
]);
}
/**
* Streamed rather than hash_file() on a local path — the only way to
* checksum a file that might live on a non-local disk (S3 has no
* local filesystem path to hand hash_file()).
*/
private function checksumOf(Filesystem $disk, string $path): string
{
$stream = $disk->readStream($path);
if ($stream === null) {
return '';
}
$context = hash_init('sha256');
hash_update_stream($context, $stream);
fclose($stream);
return hash_final($context);
}
}
@@ -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;
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Jobs;
use App\Models\User;
use App\Modules\Files\OrphanFileImporter;
use App\Modules\Files\OrphanFileScanner;
use App\Modules\Files\OrphanImportProgress;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* "Import all" on the orphans screen. Thousands of files, each hashed in
* full, run for minutes, which is far past what one web request is
* allowed (see OrphanFilesController::import).
*
* The work is cut into chunks of about $budgetSeconds. Each chunk scans
* the disk again and imports what is still orphaned until its time is up,
* then queues the next chunk. That keeps every job well inside the default
* worker's 60s timeout and the queue's 90s retry_after, so it can share
* the default queue (no extra worker to deploy). Mail queued in the
* meantime goes out between chunks rather than waiting behind the whole
* import. Because each chunk rescans, a file already adopted is never
* offered twice, and a run that dies part-way resumes from what is left.
*/
class ImportOrphanFilesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
/**
* Not retried as such: the next "Import all" (or the next chunk)
* rescans and carries on from where this one stopped. failed()
* records why it stopped.
*/
public int $tries = 1;
public function __construct(
private readonly int $userId,
private readonly ?string $search,
private readonly int $budgetSeconds = 45,
) {}
public function handle(OrphanFileScanner $scanner, OrphanFileImporter $importer, OrphanImportProgress $progress): void
{
$user = User::query()->find($this->userId);
if ($user === null) {
$progress->fail('The account that started the import no longer exists.');
return;
}
// Asked at every chunk, not only when the run was started: a run
// can outlast the access of the person who began it, and each
// chunk adopts files in their name.
if (! $user->active || ! $user->isStaff() || ! $user->can('import_orphans')) {
$progress->fail('The account that started the import can no longer import files.');
return;
}
$deadline = microtime(true) + $this->budgetSeconds;
foreach ($scanner->importable($user, $this->search) as $i => $item) {
// At least one file per chunk, so a run always moves forward.
if ($i > 0 && microtime(true) >= $deadline) {
self::dispatch($this->userId, $this->search, $this->budgetSeconds);
return;
}
if ($this->claimAndImport($importer, $user, $item['disk'], $item['path'])) {
$progress->advance();
}
}
$progress->finish();
}
/**
* Adopt one path, unless another chunk has it or already did.
*
* Chunks normally run one after another, but a run that stalled and a
* new one started after it can both have chunks queued, and with more
* than one worker two chunks scanning at once would both adopt the
* same path: two rows on one set of bytes, where deleting either
* deletes the other's file. The scan alone cannot prevent that, since
* hashing a large file leaves seconds between seeing a path and
* writing its row. So each path is claimed first, and checked for a
* row inside the claim. A path somebody else holds is left to them.
*/
private function claimAndImport(OrphanFileImporter $importer, User $user, string $disk, string $path): bool
{
return (bool) Cache::lock('orphan-files-import:'.sha1($disk.'|'.$path), 600)->get(function () use ($importer, $user, $disk, $path): bool {
if (File::withTrashed()->where('disk', $disk)->where('path', $path)->exists()) {
return false;
}
$importer->import($user, $disk, $path);
return true;
});
}
/**
* The page shows a plain sentence; the exception goes to the log. A
* storage error can name a bucket, an endpoint or a path, which is
* for whoever reads the log rather than for the screen.
*/
public function failed(Throwable $exception): void
{
Log::error('The background orphan import failed.', ['exception' => $exception]);
app(OrphanImportProgress::class)->fail('An error stopped the import. The details are in the application log.');
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files;
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Files\Models\File;
use App\Modules\Files\Uploads\StoreUploadedFile;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
/**
* Adopts an orphan in place: a File row for bytes already on disk. Shared
* by the orphans screen (a ticked selection, in the request) and
* ImportOrphanFilesJob ("import all", in the background). Which paths are
* importable is OrphanFileScanner's call.
*/
class OrphanFileImporter
{
public function __construct(
private readonly StoreUploadedFile $storeFile,
) {}
public function import(User $user, string $diskName, string $path): File
{
$disk = Storage::disk($diskName);
return $this->storeFile->create(
uploader: $user,
originalName: basename($path),
path: $path,
mimeType: $disk->mimeType($path) ?: 'application/octet-stream',
size: $disk->size($path),
checksum: $this->checksumOf($disk, $path),
folderId: null,
disk: $diskName,
action: Action::FileImported,
);
}
/**
* Streamed rather than hash_file() on a local path — the only way to
* checksum a file that might live on a non-local disk (S3 has no
* local filesystem path to hand hash_file()).
*/
private function checksumOf(Filesystem $disk, string $path): string
{
$stream = $disk->readStream($path);
if ($stream === null) {
return '';
}
$context = hash_init('sha256');
hash_update_stream($context, $stream);
fclose($stream);
return hash_final($context);
}
}
+49
View File
@@ -11,6 +11,8 @@ use App\Modules\Files\Uploads\UploadExtensionPolicy;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\WhitespacePathNormalizer;
use Throwable;
/**
* Finds files sitting on disk with no corresponding File row — v1
@@ -100,6 +102,26 @@ class OrphanFileScanner
return $orphans;
}
/**
* Every orphan the search matches that $viewer may import: the same
* rule as isImportable(), applied to a whole scan. Taken from a fresh
* scan, so unlike a client-supplied list it needs no per-path re-check.
*
* @return list<array{disk: string, path: string}>
*/
public function importable(User $viewer, ?string $search = null): array
{
$importable = [];
foreach ($this->scan($viewer, $search) as $orphan) {
if ($orphan['allowed'] && $orphan['size'] > 0) {
$importable[] = ['disk' => $orphan['disk'], 'path' => $orphan['path']];
}
}
return $importable;
}
/**
* Re-validated at import/delete time — never trust a client-supplied
* disk/path just because it was in an earlier scan response. $disk
@@ -112,6 +134,10 @@ class OrphanFileScanner
return false;
}
if (! $this->isCanonical($path)) {
return false;
}
if ($this->isExcluded($path) || ! Storage::disk($disk)->exists($path)) {
return false;
}
@@ -119,6 +145,29 @@ class OrphanFileScanner
return ! in_array($path, $this->knownPaths($disk), true);
}
/**
* Whether the storage layer would act on exactly this spelling.
*
* Flysystem rewrites a path before it touches storage: "./a/b.txt",
* "a/./b.txt", "a//b.txt", "/a/b.txt", "a\b.txt" and "a/x/../b.txt"
* all become "a/b.txt". The checks here compare strings, so any of
* those made a file somebody owns look like an orphan, and deleting or
* adopting it then reached the real file (GHSA-pv88-7863-5hwq). The
* scan only offers paths as storage lists them, already canonical, so
* a path that would be rewritten did not come from the scan and is
* refused rather than repaired.
*/
private function isCanonical(string $path): bool
{
try {
return (new WhitespacePathNormalizer)->normalizePath($path) === $path;
} catch (Throwable) {
// A path climbing out of the root, or carrying control
// characters, is refused by the normalizer itself.
return false;
}
}
public function isAllowedFor(User $viewer, string $path): bool
{
return $this->extensionPolicy->isAllowed($viewer, basename($path));
+131
View File
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files;
use Illuminate\Support\Facades\Cache;
/**
* The one background "import all" run on the orphans screen: how far it
* has got, and whether it is still going. Kept in the cache rather than a
* table: it is a progress readout for whoever is watching, not a record
* (every imported file is already in the activity log).
*
* There is only ever one run. Two at once would each scan the same disk
* and adopt the same paths twice, so a new one is refused while the
* current one is active.
*
* @phpstan-type Run array{status: string, total: int, imported: int, error: ?string, started_at: int, updated_at: int}
*/
class OrphanImportProgress
{
private const KEY = 'orphan-files:import-run';
/** A run kept for a day, so its outcome is still there after lunch. */
private const TTL_SECONDS = 86400;
/**
* Each chunk of the job runs for under a minute, and every imported
* file touches updated_at. A run silent for this long has no worker
* behind it (crashed, or no worker listening), and must not block
* the next one forever.
*/
private const STALL_SECONDS = 300;
/**
* Starts a run unless one is active, under a lock so two people
* clicking "Import all" at the same moment cannot both get one.
*/
public function tryStart(int $total): bool
{
return (bool) Cache::lock(self::KEY.':start', 10)->get(function () use ($total): bool {
if ($this->isActive()) {
return false;
}
$this->put([
'status' => 'running',
'total' => $total,
'imported' => 0,
'error' => null,
'started_at' => now()->getTimestamp(),
]);
return true;
});
}
public function advance(): void
{
$run = $this->raw();
if ($run !== null) {
$run['imported']++;
$this->put($run);
}
}
public function finish(): void
{
$this->end('finished');
}
public function fail(string $error): void
{
$this->end('failed', $error);
}
public function isActive(): bool
{
return ($this->current()['status'] ?? null) === 'running';
}
/**
* The run as the page shows it. A running run that has gone quiet is
* reported as stalled.
*
* @return array{status: string, total: int, imported: int, error: ?string, started_at: int}|null
*/
public function current(): ?array
{
$run = $this->raw();
if ($run === null) {
return null;
}
if ($run['status'] === 'running' && now()->getTimestamp() - $run['updated_at'] > self::STALL_SECONDS) {
$run['status'] = 'stalled';
}
unset($run['updated_at']);
return $run;
}
private function end(string $status, ?string $error = null): void
{
$run = $this->raw();
if ($run !== null) {
$this->put([...$run, 'status' => $status, 'error' => $error]);
}
}
/**
* @return Run|null
*/
private function raw(): ?array
{
return Cache::get(self::KEY);
}
/**
* @param array<string, mixed> $run
*/
private function put(array $run): void
{
Cache::put(self::KEY, [...$run, 'updated_at' => now()->getTimestamp()], self::TTL_SECONDS);
}
}
@@ -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];
}
}
@@ -160,6 +160,11 @@ class UsersController extends Controller
*
* Refused with a 422 if the change would leave the installation with
* no active administrator, or if you would be deactivating yourself.
*
* Your own email address and password cannot be changed here: that is
* a `403`. Change them from your profile in the web interface, which
* asks for your current password. Setting somebody else's password
* signs them out of the API: every token they hold is revoked.
*/
public function update(Request $request, User $user): StaffUserResource
{
@@ -182,6 +187,15 @@ class UsersController extends Controller
'assigned_clients.*' => ['integer', Rule::in($this->accounts->assignableClientIds($actor))],
]);
// Your own email address and password are changed from your
// profile, which asks for your current password. A token cannot be
// asked for one, so here the answer is no.
abort_if(
$this->accounts->ownCredentialChanges($actor, $user, $validated['email'] ?? null, $validated['password'] ?? null) !== [],
403,
__('Change your own email address and password from your profile.'),
);
// Read through Request::boolean() rather than off the validated
// array, for the reason RolesController::guardScopeRemoval spells
// out: the `boolean` rule accepts 0 and "0" as well as false but
@@ -269,12 +283,22 @@ class UsersController extends Controller
* The account holder is emailed that this happened, and the action is
* recorded in the activity log against the caller. Answers 204 whether
* or not a second factor was actually in force.
*
* Not for your own account, which is a `403`: remove your own second
* factor from your profile in the web interface.
*/
public function destroyTwoFactor(Request $request, User $user, TwoFactorAdministration $twoFactor): JsonResponse
{
abort_unless($user->isStaff(), 404);
$this->accounts->guardTarget($this->actor($request), $user);
$actor = $this->actor($request);
$this->accounts->guardTarget($actor, $user);
// The web asks for your password before this (password.confirm),
// and a token cannot give one. On your own account it would let a
// token clear the second factor standing between it and a browser
// session as you.
abort_if($user->is($actor), 403, __('Remove your own two-factor authentication from your profile.'));
$twoFactor->reset($user);
@@ -56,6 +56,7 @@ class LdapSettingsController extends Controller
'user_filter' => $ldap->user_filter,
'email_attribute' => $ldap->email_attribute,
'name_attribute' => $ldap->name_attribute,
'username_attribute' => $ldap->username_attribute,
'auto_provision' => $ldap->auto_provision,
'auto_approve' => $ldap->auto_approve,
],
@@ -93,6 +94,7 @@ class LdapSettingsController extends Controller
'user_filter' => ['nullable', 'string', 'max:255'],
'email_attribute' => ['required', 'string', 'max:64'],
'name_attribute' => ['required', 'string', 'max:64'],
'username_attribute' => ['nullable', 'string', 'max:64'],
'auto_provision' => ['required', 'boolean'],
'auto_approve' => ['required', 'boolean'],
]);
@@ -110,6 +112,7 @@ class LdapSettingsController extends Controller
'user_filter' => $validated['user_filter'] ?? null,
'email_attribute' => $validated['email_attribute'],
'name_attribute' => $validated['name_attribute'],
'username_attribute' => $validated['username_attribute'] ?? null,
'auto_provision' => (bool) $validated['auto_provision'],
'auto_approve' => (bool) $validated['auto_approve'],
]);
@@ -226,6 +226,23 @@ class UsersController extends Controller
]);
}
// Your own email address and password are changed from your
// profile, which asks for your current password first; this screen
// does not, so it does not change them.
$ownCredentials = $this->accounts->ownCredentialChanges(
$this->actor(),
$user,
$validated['email'],
is_string($validated['password'] ?? null) ? $validated['password'] : null,
);
if ($ownCredentials !== []) {
throw ValidationException::withMessages(array_fill_keys(
$ownCredentials,
__('Change your own email address and password from your profile.'),
));
}
$this->accounts->update($user, [
'name' => $validated['name'],
'email' => $validated['email'],
@@ -57,7 +57,12 @@ class EnforceTwoFactor
// too. The loop then had no exit at all, which is how an
// installation that made two-factor compulsory locked out
// everybody who signs in with Microsoft.
if ($request->routeIs('two-factor.*', 'password.confirm*', 'password.edit', 'password.update', 'logout', 'locale.update')) {
//
// password.link, password.reset and password.store complete that
// same exit now that a provider account's first password arrives by
// email: asking for the link, opening it and saving it all happen
// while signed in, before there is a password to enrol with.
if ($request->routeIs('two-factor.*', 'password.confirm*', 'password.edit', 'password.update', 'password.link', 'password.reset', 'password.store', 'logout', 'locale.update')) {
return $next($request);
}
@@ -72,6 +72,20 @@ class LdapAuthenticator
return $identity;
}
/**
* The address a directory username belongs to, so a login typed as a
* username can carry on exactly as if the address had been typed.
* Null whenever username sign-in is off or the breaker is open.
*/
public function emailForUsername(string $username): ?string
{
if (! LdapSettings::current()->allowsUsernameSignIn() || $this->breakerOpen()) {
return null;
}
return $this->directory->emailForUsername($username);
}
/**
* Record what the directory told us about an account that already
* exists, so an administrator can see which entry it corresponds to.
@@ -104,13 +118,18 @@ class LdapAuthenticator
return false;
}
if (Cache::get(self::BREAKER_KEY) === true) {
if ($this->breakerOpen()) {
return false;
}
return $this->enabled();
}
private function breakerOpen(): bool
{
return Cache::get(self::BREAKER_KEY) === true;
}
/**
* Whether this account's password lives in the directory rather than
* here — in which case the local hash is not consulted at all.
@@ -25,6 +25,14 @@ interface LdapDirectory
*/
public function authenticate(string $email, string $password): ?LdapIdentity;
/**
* The address of the one entry whose username attribute matches, found
* with the service account. No bind as the person, so nothing is
* verified here: the caller still signs in by that address, through
* authenticate(). Null for no match, more than one, or any failure.
*/
public function emailForUsername(string $username): ?string;
/**
* Exercise the configuration and report which stage failed, for the
* settings screen's test button. This is the one place that is allowed
@@ -42,7 +42,7 @@ class LdapRecordDirectory implements LdapDirectory
$connection = LdapConnectionFactory::make($settings);
$connection->connect();
$entry = $this->findEntry($connection, $settings, $email);
$entry = $this->findEntry($connection, $settings, $settings->email_attribute, $email);
if ($entry === null) {
return null;
@@ -66,6 +66,28 @@ class LdapRecordDirectory implements LdapDirectory
}
}
public function emailForUsername(string $username): ?string
{
$settings = LdapSettings::current();
if (! $settings->allowsUsernameSignIn()) {
return null;
}
try {
$connection = LdapConnectionFactory::make($settings);
$connection->connect();
$entry = $this->findEntry($connection, $settings, (string) $settings->username_attribute, $username);
return $entry === null ? null : $this->attribute($entry, $settings->email_attribute);
} catch (Throwable $e) {
Log::warning('LDAP username lookup could not be completed.', ['exception' => $e::class]);
return null;
}
}
public function probe(?string $email = null, ?string $password = null): LdapProbeResult
{
$settings = LdapSettings::current();
@@ -95,7 +117,7 @@ class LdapRecordDirectory implements LdapDirectory
}
try {
$entry = $this->findEntry($connection, $settings, $email);
$entry = $this->findEntry($connection, $settings, $settings->email_attribute, $email);
} catch (Throwable $e) {
return LdapProbeResult::failed(
LdapProbeResult::STAGE_SEARCH,
@@ -131,21 +153,22 @@ class LdapRecordDirectory implements LdapDirectory
}
/**
* The one entry matching this address, or null.
* The one entry whose attribute holds this value (an address, or a
* username), or null.
*
* Two results is a misconfiguration — two objects sharing an address —
* and choosing one of them is how you sign the wrong person in, so it
* fails closed.
* Two results is a misconfiguration — two objects sharing an address or
* a username — and choosing one of them is how you sign the wrong person
* in, so it fails closed.
*
* @return array<string, mixed>|null
*/
private function findEntry(Connection $connection, LdapSettings $settings, string $email): ?array
private function findEntry(Connection $connection, LdapSettings $settings, string $attribute, string $value): ?array
{
$query = $connection->query()
->in($settings->base_dn)
// The email goes through the builder, which escapes it. It is
// never concatenated into a filter string.
->whereEquals($settings->email_attribute, $email);
// What the visitor typed goes through the builder, which
// escapes it. It is never concatenated into a filter string.
->whereEquals($attribute, $value);
if (is_string($settings->user_filter) && $settings->user_filter !== '') {
// Admin-supplied, never visitor-supplied.
@@ -25,6 +25,7 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $user_filter
* @property string $email_attribute
* @property string $name_attribute
* @property string|null $username_attribute
* @property bool $auto_provision
* @property bool $auto_approve
*/
@@ -80,4 +81,15 @@ class LdapSettings extends Model
&& is_string($this->host) && $this->host !== ''
&& is_string($this->base_dn) && $this->base_dn !== '';
}
/**
* Whether people may sign in with a directory username as well as an
* address: only once an administrator has named the attribute that
* holds it.
*/
public function allowsUsernameSignIn(): bool
{
return $this->usable()
&& is_string($this->username_attribute) && $this->username_attribute !== '';
}
}
@@ -27,6 +27,7 @@ class ResetPasswordNotification extends Notification implements ShouldQueue
public function __construct(
public readonly string $token,
public readonly bool $firstPassword = false,
) {}
/**
@@ -46,6 +47,20 @@ class ResetPasswordNotification extends Notification implements ShouldQueue
$expireMinutes = (int) config('auth.passwords.'.config('auth.defaults.passwords').'.expire');
// The same link, for an account that signs in through a provider and
// has never had a password: this is now the only way it gets one, so
// an email that speaks of a reset nobody asked for is one people
// ignore. Not taken from the customisable reset template for the
// same reason, since that text is written about resetting.
if ($this->firstPassword) {
return (new MailMessage)
->subject(__('Set your password'))
->line(__('Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.'))
->action(__('Set a password'), $url)
->line(__('This link will expire in :count minutes.', ['count' => $expireMinutes]))
->line(__('If you did not ask for this, no further action is required: you can keep signing in the way you do now.'));
}
if (($override = $this->overrideOrNull(EmailTemplateSlot::PasswordReset)) !== null) {
return $this->mailFromOverride($override, [':count' => (string) $expireMinutes])
->action(__('Reset Password'), $url);
+49 -1
View File
@@ -161,6 +161,42 @@ class StaffAccounts
abort_unless($role === null || $this->mayGrant($actor, $role), 403);
}
/**
* Which of your own credentials this change would replace: a different
* email address, or a new password. Empty when the target is somebody
* else, or when nothing that signs the account in is changing.
*
* Your own are changed from your profile, which asks for your current
* password first (GHSA-f32x-fgmp-q353). The staff screen and the API
* must not be a second door to them. Over the API that door was wider
* still: a token limited to manage_users and edit_users could give its
* own owner a password it chose, and then sign in as the owner with
* every ability the token had been denied.
*
* The email address counts because it is how a password is recovered:
* an address you control is a password you can set.
*
* @return list<'email'|'password'>
*/
public function ownCredentialChanges(User $actor, User $target, ?string $email, ?string $password): array
{
if (! $target->is($actor)) {
return [];
}
$fields = [];
if ($email !== null && mb_strtolower($email) !== mb_strtolower($target->email)) {
$fields[] = 'email';
}
if ($password !== null && $password !== '') {
$fields[] = 'password';
}
return $fields;
}
/**
* Refuse any change that would leave the installation without an
* active administrator.
@@ -297,12 +333,24 @@ class StaffAccounts
$user->fill(array_intersect_key($attributes, array_flip(['name', 'email', 'role_id', 'active'])));
if (is_string($attributes['password'] ?? null) && $attributes['password'] !== '') {
$passwordReplaced = is_string($attributes['password'] ?? null) && $attributes['password'] !== '';
if ($passwordReplaced) {
$user->password = $attributes['password'];
}
$user->save();
// Somebody else gave this account a new password: whatever had been
// holding it, a person or a stolen credential, is ended with it.
// Browser sessions end on their own (AuthenticateSession reads the
// password hash), but API tokens do not, and a reset that left the
// previous holder's token working would not be a reset. Never your
// own password: both callers refuse that (ownCredentialChanges).
if ($passwordReplaced) {
$user->tokens()->delete();
}
if ($assignedClients !== null) {
$this->syncAssignedClients($user, (int) $user->role_id, $assignedClients);
} elseif ($oldRoleId !== $user->role_id) {
@@ -15,6 +15,7 @@ use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use App\Modules\Platform\Branding\LogoCropper;
use App\Modules\Platform\Branding\Models\BrandingSetting;
use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
use App\Modules\Platform\Branding\Watermark\WatermarkSample;
@@ -41,6 +42,12 @@ class BrandingController extends Controller
return Inertia::render('branding/edit', [
'logo_url' => $setting->logoUrl(),
// The upload the logo was cut from, and the box it was cut
// with, so the cropper opens on the whole picture with the
// last crop already drawn.
'logo_source_url' => $setting->logoSourceUrl(),
'logo_crop' => $setting->logo_crop,
'logo_cropped' => $setting->logoIsCropped(),
// Read, never written here. Hiding attribution is the
// white-label half and stays a hosted feature: the switch is
// rendered only where Capability::AttributionHide is held, and
@@ -60,7 +67,7 @@ class BrandingController extends Controller
]);
}
public function store(Request $request): RedirectResponse
public function store(Request $request, LogoCropper $cropper): RedirectResponse
{
$validated = $request->validate([
'logo' => ['required', 'image', 'max:2048'],
@@ -70,16 +77,64 @@ class BrandingController extends Controller
$upload = $validated['logo'];
$setting = BrandingSetting::current();
$previous = $cropper->files($setting);
if ($setting->logo_path !== null) {
Storage::disk('public')->delete($setting->logo_path);
}
// A new upload starts uncropped: the old crop's box describes a
// different picture.
$setting->update([
'logo_path' => $this->storeImage($upload),
'logo_original_path' => null,
'logo_crop' => null,
]);
$setting->update(['logo_path' => $this->storeImage($upload)]);
Storage::disk('public')->delete($previous);
return back()->with('success', __('Logo updated.'));
}
/**
* Cut the logo down to a box drawn on the uploaded image. Optional: an
* uploaded logo is used whole until somebody crops it.
*/
public function cropLogo(Request $request, LogoCropper $cropper): RedirectResponse
{
$validated = $request->validate([
'x' => ['required', 'integer', 'min:0'],
'y' => ['required', 'integer', 'min:0'],
'width' => ['required', 'integer', 'min:1'],
'height' => ['required', 'integer', 'min:1'],
]);
$setting = BrandingSetting::query()->first();
if ($setting === null) {
return back()->withErrors(['logo' => __('Upload a logo before cropping it.')]);
}
$cropper->crop($setting, [
'x' => (int) $validated['x'],
'y' => (int) $validated['y'],
'width' => (int) $validated['width'],
'height' => (int) $validated['height'],
]);
return back()->with('success', __('Logo cropped.'));
}
/**
* Go back to the logo exactly as it was uploaded.
*/
public function restoreLogo(LogoCropper $cropper): RedirectResponse
{
$setting = BrandingSetting::query()->first();
if ($setting !== null) {
$cropper->restore($setting);
}
return back()->with('success', __('Original logo restored.'));
}
/**
* 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
@@ -97,13 +152,14 @@ class BrandingController extends Controller
return back();
}
public function destroy(): RedirectResponse
public function destroy(LogoCropper $cropper): RedirectResponse
{
$setting = BrandingSetting::query()->first();
if ($setting?->logo_path !== null) {
Storage::disk('public')->delete($setting->logo_path);
$setting->update(['logo_path' => null]);
if ($setting !== null && $setting->logo_path !== null) {
$files = $cropper->files($setting);
$setting->update(['logo_path' => null, 'logo_original_path' => null, 'logo_crop' => null]);
Storage::disk('public')->delete($files);
}
return back()->with('success', __('Logo removed.'));
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Branding;
use App\Modules\Platform\Branding\Models\BrandingSetting;
use claviska\SimpleImage;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
/**
* Cutting the logo down to part of itself, and putting the whole of it back.
*
* The uploaded file is never changed. A crop is a new file written from it,
* so cropping again starts from everything that was uploaded rather than
* from the last crop, and restoring is pointing back at the upload.
*
* Coordinates are in the upload's stored pixels, which is what GD reads. The
* cropper shows the image the same way (`image-orientation: none`), because
* the images here have no exif extension to rotate a phone photo by its
* orientation tag: if the browser rotated it and GD did not, the box would
* land on the wrong part of the picture.
*/
class LogoCropper
{
/**
* GD holds four bytes a pixel, and a 2 MB file can still describe a very
* large image if it compresses well. 25 million pixels is about 100 MB of
* memory, and far beyond any logo.
*/
public const MAX_PIXELS = 25_000_000;
/**
* @param array{x: int, y: int, width: int, height: int} $box
*/
public function crop(BrandingSetting $setting, array $box): void
{
$source = $setting->logoSourcePath();
$disk = Storage::disk('public');
if ($source === null || ! $disk->exists($source)) {
throw ValidationException::withMessages(['logo' => __('Upload a logo before cropping it.')]);
}
$absolute = $disk->path($source);
$size = @getimagesize($absolute);
if ($size === false) {
throw ValidationException::withMessages(['logo' => __('This logo cannot be cropped. Upload it again.')]);
}
[$width, $height] = $size;
if ($width * $height > self::MAX_PIXELS) {
throw ValidationException::withMessages(['logo' => __('This image is too large to crop. Upload a smaller one.')]);
}
if ($box['x'] + $box['width'] > $width || $box['y'] + $box['height'] > $height) {
throw ValidationException::withMessages(['width' => __('The crop must stay inside the image.')]);
}
// The whole picture is the upload itself: no second copy of it.
if ($box['x'] === 0 && $box['y'] === 0 && $box['width'] === $width && $box['height'] === $height) {
$this->restore($setting);
return;
}
// Same extension as the upload, which took it from the content
// when it was stored (see BrandingController::storeImage).
$cropped = 'branding/'.Str::uuid().'.'.pathinfo($source, PATHINFO_EXTENSION);
(new SimpleImage($absolute))
->crop($box['x'], $box['y'], $box['x'] + $box['width'], $box['y'] + $box['height'])
->toFile($disk->path($cropped), $size['mime']);
$previous = $setting->logoIsCropped() ? $setting->logo_path : null;
$setting->update([
'logo_original_path' => $source,
'logo_path' => $cropped,
'logo_crop' => $box,
]);
if ($previous !== null) {
$disk->delete($previous);
}
}
public function restore(BrandingSetting $setting): void
{
if (! $setting->logoIsCropped()) {
return;
}
$cropped = $setting->logo_path;
$setting->update([
'logo_path' => $setting->logo_original_path,
'logo_original_path' => null,
'logo_crop' => null,
]);
if ($cropped !== null) {
Storage::disk('public')->delete($cropped);
}
}
/**
* Every file the logo occupies: the one shown, and the upload behind it.
*
* @return list<string>
*/
public function files(BrandingSetting $setting): array
{
return array_values(array_filter([$setting->logo_path, $setting->logo_original_path]));
}
}
@@ -14,6 +14,8 @@ use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
*
* @property int $id
* @property string|null $logo_path
* @property string|null $logo_original_path
* @property array{x: int, y: int, width: int, height: int}|null $logo_crop
* @property bool $watermark_enabled
* @property string|null $watermark_path
* @property WatermarkPosition $watermark_position
@@ -33,6 +35,7 @@ class BrandingSetting extends Model
protected $casts = [
'watermark_enabled' => 'boolean',
'show_site_name' => 'boolean',
'logo_crop' => 'array',
'watermark_position' => WatermarkPosition::class,
'watermark_size' => 'integer',
'watermark_opacity' => 'integer',
@@ -65,6 +68,28 @@ class BrandingSetting extends Model
return $this->logo_path === null ? null : Storage::disk('public')->url($this->logo_path);
}
/**
* The file the logo was cut from: the upload itself when it was never
* cropped, since then `logo_path` is that upload. A crop always starts
* from here, never from a previous crop.
*/
public function logoSourcePath(): ?string
{
return $this->logo_original_path ?? $this->logo_path;
}
public function logoSourceUrl(): ?string
{
$path = $this->logoSourcePath();
return $path === null ? null : Storage::disk('public')->url($path);
}
public function logoIsCropped(): bool
{
return $this->logo_original_path !== null;
}
public function watermarkUrl(): ?string
{
return $this->watermark_path === null ? null : Storage::disk('public')->url($this->watermark_path);
Generated
+22 -22
View File
@@ -2210,16 +2210,16 @@
},
{
"name": "laravel/framework",
"version": "v12.64.0",
"version": "v12.69.3",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0"
"reference": "58ea544a2a80dc03c168e13a5dc9a1d176a88717"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/727a8ea2949c23ca8b5316b86a00984b6017b7a0",
"reference": "727a8ea2949c23ca8b5316b86a00984b6017b7a0",
"url": "https://api.github.com/repos/laravel/framework/zipball/58ea544a2a80dc03c168e13a5dc9a1d176a88717",
"reference": "58ea544a2a80dc03c168e13a5dc9a1d176a88717",
"shasum": ""
},
"require": {
@@ -2428,7 +2428,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2026-07-14T14:25:37+00:00"
"time": "2026-09-29T12:53:25+00:00"
},
{
"name": "laravel/prompts",
@@ -2811,16 +2811,16 @@
},
{
"name": "league/commonmark",
"version": "2.10.0",
"version": "2.10.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4"
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": ""
},
"require": {
@@ -2914,7 +2914,7 @@
"type": "tidelift"
}
],
"time": "2026-08-11T16:06:25+00:00"
"time": "2026-09-21T13:07:34+00:00"
},
{
"name": "league/config",
@@ -3000,16 +3000,16 @@
},
{
"name": "league/flysystem",
"version": "3.35.2",
"version": "3.36.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "b277b5dc3d56650b68904117124e79c851e12376"
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376",
"reference": "b277b5dc3d56650b68904117124e79c851e12376",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25",
"reference": "f7fb152932f30072d573510cbd4dd657d6475b25",
"shasum": ""
},
"require": {
@@ -3077,9 +3077,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.35.2"
"source": "https://github.com/thephpleague/flysystem/tree/3.36.0"
},
"time": "2026-07-06T14:42:07+00:00"
"time": "2026-09-02T08:00:27+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
@@ -4379,16 +4379,16 @@
},
{
"name": "phpseclib/phpseclib",
"version": "3.0.56",
"version": "3.0.57",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305"
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/7adbbe38cde25e2df2116dbf2673c407e24fa305",
"reference": "7adbbe38cde25e2df2116dbf2673c407e24fa305",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"reference": "d17e0ddaeaf6f22f7e007cbb437d78792fe2a0e4",
"shasum": ""
},
"require": {
@@ -4469,7 +4469,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.56"
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.57"
},
"funding": [
{
@@ -4485,7 +4485,7 @@
"type": "tidelift"
}
],
"time": "2026-08-03T04:36:50+00:00"
"time": "2026-08-26T12:13:21+00:00"
},
{
"name": "phpstan/phpdoc-parser",
+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.6.0',
'version' => '2.7.0',
/*
|--------------------------------------------------------------------------
@@ -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');
});
}
};
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Cropping the logo keeps the uploaded file, so the crop can be redone or
* undone. Both columns are null for a logo that was never cropped, which is
* every logo that exists when this runs: `logo_path` keeps meaning "the
* image to show", and nothing about an existing installation changes.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('branding_settings', function (Blueprint $table) {
// The uploaded file, while `logo_path` points at a crop of it.
$table->string('logo_original_path')->nullable()->after('logo_path');
// The box that crop was cut with, in the original's pixels:
// {x, y, width, height}. So the cropper reopens where it was.
$table->json('logo_crop')->nullable()->after('logo_original_path');
});
}
public function down(): void
{
Schema::table('branding_settings', function (Blueprint $table) {
$table->dropColumn(['logo_original_path', 'logo_crop']);
});
}
};
@@ -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
{
// The directory attribute that holds a username (uid, cn,
// sAMAccountName...), so people can sign in with it as well as with
// their address. Empty by default: sign-in stays email-only on every
// existing installation until an administrator names one.
Schema::table('ldap_settings', function (Blueprint $table) {
$table->string('username_attribute')->nullable();
});
}
public function down(): void
{
Schema::table('ldap_settings', function (Blueprint $table) {
$table->dropColumn('username_attribute');
});
}
};
+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
+65 -7
View File
@@ -80,18 +80,22 @@ 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 |
| `upload_public` | set `public` when editing |
| `upload` / `edit_files` / `edit_others_files` | read and write a file's comments |
| `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 |
| `create_clients` / `edit_clients` / `delete_clients` | create, read and edit, delete clients; `edit_clients` also sets a client's password and removes their 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 |
| `create_users` / `edit_users` / `delete_users` | create, read and edit, delete staff accounts; `edit_users` also sets an account's password and removes its two-factor authentication |
There is no ability for *writing* a comment. Who may comment is an installation setting rather than
a per-role permission, so the file abilities are the gate — the same question the web asks, which is
@@ -99,10 +103,17 @@ 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 |
**`edit_clients` and `edit_users` are control of the accounts they reach.** Setting a password and
removing a second factor are what an administrator does for somebody who is locked out, so a token
holding either ability can sign in as any client, or any staff account below its owner, that it may
edit, and do what that account can do. Give these abilities to a token only when you would trust its
holder with those accounts themselves. Your *own* credentials are never reachable this way (see
"Staff accounts").
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 +370,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.
@@ -373,7 +424,7 @@ Two abilities are needed for each call: `manage_users` to reach the area at all,
action (`create_users`, `edit_users`, `delete_users`). That mirrors the web UI, where the whole
section sits behind `manage_users` and each button behind its own key.
### Two rules that will refuse you
### Three rules that will refuse you
**You cannot hand out authority you do not hold.** `role_id` must name a role you could grant
yourself: a caller who is not an administrator may not create one, nor assign any role carrying a
@@ -384,6 +435,11 @@ guess an id.
**The installation always keeps an active administrator.** Demoting, deactivating or deleting the
last one is a `422`. So is deactivating or deleting yourself, from either surface.
**Your own credentials stay behind your profile.** Changing your own email address or password, or
removing your own second factor, is a `403`. Do it from your profile in the web interface, which
asks for your current password; a token cannot be asked for one. Setting *somebody else's* password
revokes every token they hold.
### Changing a role
The assigned role is a field on the account, so `PATCH /users/{user}` with `role_id` is the whole
@@ -443,7 +499,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 +563,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.
+764 -3
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",
@@ -3416,7 +4034,7 @@
},
"patch": {
"operationId": "users.update",
"description": "PATCH semantics: an absent key means \"leave alone\", not \"clear\".\nSending `assigned_clients` replaces the whole list; omitting it\nleaves it, except that moving to a role which is not client-scoped\nclears it either way.\n\nRefused with a 422 if the change would leave the installation with\nno active administrator, or if you would be deactivating yourself.\n\nRequires a token with the ability: `edit_users`.",
"description": "PATCH semantics: an absent key means \"leave alone\", not \"clear\".\nSending `assigned_clients` replaces the whole list; omitting it\nleaves it, except that moving to a role which is not client-scoped\nclears it either way.\n\nRefused with a 422 if the change would leave the installation with\nno active administrator, or if you would be deactivating yourself.\n\nYour own email address and password cannot be changed here: that is\na `403`. Change them from your profile in the web interface, which\nasks for your current password. Setting somebody else's password\nsigns them out of the API: every token they hold is revoked.\n\nRequires a token with the ability: `edit_users`.",
"summary": "Update a staff account, including the role assigned to it",
"tags": [
"Users"
@@ -3490,6 +4108,28 @@
}
}
},
"403": {
"description": "An error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Error overview.",
"examples": [
"Change your own email address and password from your profile."
]
}
},
"required": [
"message"
]
}
}
}
},
"404": {
"$ref": "#/components/responses/ModelNotFoundException"
},
@@ -3543,7 +4183,7 @@
"/users/{user}/two-factor": {
"delete": {
"operationId": "users.two-factor.destroy",
"description": "The remedy for a locked-out account: somebody whose authenticator\napp and recovery codes are both gone cannot sign in, and nobody else\ncan open the account for them either. Afterwards the account signs\nin with its password alone, and \u2014 if this installation enforces\ntwo-factor authentication for staff \u2014 is asked to enrol again on its\nnext request.\n\nThe account holder is emailed that this happened, and the action is\nrecorded in the activity log against the caller. Answers 204 whether\nor not a second factor was actually in force.\n\nRequires a token with the ability: `edit_users`.",
"description": "The remedy for a locked-out account: somebody whose authenticator\napp and recovery codes are both gone cannot sign in, and nobody else\ncan open the account for them either. Afterwards the account signs\nin with its password alone, and \u2014 if this installation enforces\ntwo-factor authentication for staff \u2014 is asked to enrol again on its\nnext request.\n\nThe account holder is emailed that this happened, and the action is\nrecorded in the activity log against the caller. Answers 204 whether\nor not a second factor was actually in force.\n\nNot for your own account, which is a `403`: remove your own second\nfactor from your profile in the web interface.\n\nRequires a token with the ability: `edit_users`.",
"summary": "Remove a staff account's two-factor authentication",
"tags": [
"Users"
@@ -3571,6 +4211,28 @@
}
}
},
"403": {
"description": "An error",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Error overview.",
"examples": [
"Remove your own two-factor authentication from your profile."
]
}
},
"required": [
"message"
]
}
}
}
},
"404": {
"$ref": "#/components/responses/ModelNotFoundException"
},
@@ -4189,17 +4851,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 +4973,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": {
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Es mostra fins a 240 × 80 píxels, de manera que hi caben tant un logotip ample com un de quadrat. PNG, JPG, GIF o WebP, fins a 2 MB.",
"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"
"Uploading into :folder": "Pujant a :folder",
"Select all :count matching files": "Selecciona els :count fitxers coincidents",
"Restricted and empty files are skipped.": "Els fitxers restringits i els buits s’ometen.",
"Import all": "Importa-ho tot",
"Importing in the background": "S’està important en segon pla",
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported de :total fitxers importats. Pots sortir d’aquesta pàgina: la importació continua.",
"Import finished": "Importació acabada",
":imported of :total files imported.": ":imported de :total fitxers importats.",
"The import stopped": "La importació s’ha aturat",
"The import stopped making progress": "La importació ha deixat d’avançar",
":imported of :total files were imported before it failed: :error": "S’han importat :imported de :total fitxers abans de l’error: :error",
":imported of :total files were imported. The queue worker may not be running.": "S’han importat :imported de :total fitxers. Potser el procés en segon pla no s’està executant.",
"Choose Import all again to continue with the rest.": "Torna a triar «Importa-ho tot» per continuar amb la resta.",
"An import is already running. Wait for it to finish.": "Ja hi ha una importació en curs. Espera que acabi.",
"Importing :count file in the background.|Importing :count files in the background.": "S’està important :count fitxer en segon pla.|S’estan important :count fitxers en segon pla.",
":width × :height pixels": ":width × :height píxels",
"A link was sent a moment ago. Check your email, or try again in a minute.": "S'ha enviat un enllaç fa un moment. Revisa el correu o torna-ho a provar d'aquí a un minut.",
"Ask for a link by email to set your first password.": "Demana un enllaç per correu per crear la teva primera contrasenya.",
"Change your own email address and password from your profile.": "Canvia la teva adreça electrònica i la teva contrasenya des del teu perfil.",
"Crop": "Retalla",
"Crop logo": "Retalla el logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Arrossega el requadre i les cantonades per triar quina part de la imatge es mostra. La imatge pujada es conserva, així que ho pots canviar més endavant.",
"Email me a link": "Envia'm un enllaç",
"Go to your profile": "Ves al teu perfil",
"Logo cropped.": "Logo retallat.",
"Original logo restored.": "S'ha restaurat el logo original.",
"Remove your own two-factor authentication from your profile.": "Elimina la teva verificació en dos passos des del teu perfil.",
"Restore original": "Restaura l'original",
"Save crop": "Desa el retall",
"The crop must stay inside the image.": "El retall ha de quedar dins de la imatge.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Aquesta carpeta no és buida. Envia content_action=cascade_delete per esborrar-la amb tot el que conté.",
"This image is too large to crop. Upload a smaller one.": "Aquesta imatge és massa gran per retallar-la. Puja'n una de més petita.",
"This logo cannot be cropped. Upload it again.": "Aquest logo no es pot retallar. Torna'l a pujar.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Aquest token pot crear contrasenyes i eliminar la verificació en dos passos dels comptes que pot editar, així que qui el tingui pot iniciar la sessió com aquests comptes. Tria aquests permisos només per a algú a qui confiaries aquests comptes.",
"Upload a logo before cropping it.": "Puja un logo abans de retallar-lo.",
"We sent a link to your email address. It works for one hour.": "T'hem enviat un enllaç al correu. Funciona durant una hora.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "T'enviarem un enllaç per correu per crear-la. En obrir-lo es tancaran totes les teves sessions, així que després torna a iniciar la sessió amb la nova contrasenya.",
"Username attribute (optional)": "Atribut de nom d'usuari (opcional)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Permet que les persones iniciïn la sessió amb el seu nom d'usuari del directori a més de l'adreça: per exemple uid, cn o sAMAccountName. Deixa-ho buit per iniciar la sessió només amb l'adreça.",
"Email or username": "Correu o nom d'usuari",
"Enter your email or username and password below to log in": "Introdueix a sota el teu correu o nom d'usuari i la teva contrasenya per iniciar la sessió",
"Set your password": "Crea la teva contrasenya",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Fes servir el botó de sota per triar una contrasenya per al teu compte. Fins ara iniciaves la sessió amb un compte connectat, com Google o Microsoft.",
"This link will expire in :count minutes.": "Aquest enllaç caduca d'aquí a :count minuts.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Si no ho has demanat, no cal que facis res: pots continuar iniciant la sessió com fins ara."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 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 240 × 80 pixelů, takže se vejde široké i čtvercové logo. PNG, JPG, GIF nebo WebP, do 2 MB.",
"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"
"Uploading into :folder": "Nahrávání do: :folder",
"Select all :count matching files": "Vybrat všech :count odpovídajících souborů",
"Restricted and empty files are skipped.": "Soubory s omezenou příponou a prázdné soubory se přeskočí.",
"Import all": "Naimportovat vše",
"Importing in the background": "Import probíhá na pozadí",
":imported of :total files imported. You can leave this page — the import keeps going.": "Naimportováno :imported z :total souborů. Tuto stránku můžete opustit — import poběží dál.",
"Import finished": "Import dokončen",
":imported of :total files imported.": "Naimportováno :imported z :total souborů.",
"The import stopped": "Import se zastavil",
"The import stopped making progress": "Import přestal postupovat",
":imported of :total files were imported before it failed: :error": "Před chybou se naimportovalo :imported z :total souborů: :error",
":imported of :total files were imported. The queue worker may not be running.": "Naimportováno :imported z :total souborů. Proces na pozadí možná neběží.",
"Choose Import all again to continue with the rest.": "Pro pokračování se zbytkem zvolte znovu „Naimportovat vše“.",
"An import is already running. Wait for it to finish.": "Import už probíhá. Počkejte, až skončí.",
"Importing :count file in the background.|Importing :count files in the background.": "Na pozadí se importuje :count soubor.|Na pozadí se importují :count soubory.|Na pozadí se importuje :count souborů.",
":width × :height pixels": ":width × :height pixelů",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Odkaz byl odeslán před chvílí. Zkontrolujte e-mail, nebo to zkuste znovu za minutu.",
"Ask for a link by email to set your first password.": "Požádejte o odkaz e-mailem a nastavte si první heslo.",
"Change your own email address and password from your profile.": "Vlastní e-mailovou adresu a heslo změníte ve svém profilu.",
"Crop": "Oříznout",
"Crop logo": "Oříznout logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Tažením rámečku a jeho rohů vyberte, která část obrázku se zobrazí. Nahraný obrázek zůstane uložen, takže to můžete později změnit.",
"Email me a link": "Poslat odkaz e-mailem",
"Go to your profile": "Přejít do profilu",
"Logo cropped.": "Logo bylo oříznuto.",
"Original logo restored.": "Původní logo bylo obnoveno.",
"Remove your own two-factor authentication from your profile.": "Vlastní dvoufaktorové ověření odstraníte ve svém profilu.",
"Restore original": "Obnovit původní",
"Save crop": "Uložit ořez",
"The crop must stay inside the image.": "Ořez musí zůstat uvnitř obrázku.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Tato složka není prázdná. Pošlete content_action=cascade_delete, chcete-li ji smazat i s celým obsahem.",
"This image is too large to crop. Upload a smaller one.": "Tento obrázek je pro oříznutí příliš velký. Nahrajte menší.",
"This logo cannot be cropped. Upload it again.": "Toto logo nelze oříznout. Nahrajte ho znovu.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Tento token může nastavovat hesla a odstraňovat dvoufaktorové ověření u účtů, které smí upravovat, takže kdokoli ho má, se může přihlásit jako tyto účty. Tato oprávnění zvolte jen pro držitele, kterému byste svěřili samotné účty.",
"Upload a logo before cropping it.": "Před oříznutím nahrajte logo.",
"We sent a link to your email address. It works for one hour.": "Na vaši e-mailovou adresu jsme poslali odkaz. Platí jednu hodinu.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Pošleme vám e-mailem odkaz k jeho nastavení. Otevření odkazu vás všude odhlásí, takže se poté znovu přihlaste novým heslem.",
"Username attribute (optional)": "Atribut uživatelského jména (nepovinné)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Umožní lidem přihlásit se kromě adresy i uživatelským jménem z adresáře, například uid, cn nebo sAMAccountName. Ponechte prázdné, pokud se má přihlašovat jen adresou.",
"Email or username": "E-mail nebo uživatelské jméno",
"Enter your email or username and password below to log in": "Pro přihlášení zadejte níže svůj e-mail nebo uživatelské jméno a heslo",
"Set your password": "Nastavte si heslo",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Tlačítkem níže si zvolte heslo ke svému účtu. Dosud jste se přihlašovali propojeným účtem, například Google nebo Microsoft.",
"This link will expire in :count minutes.": "Platnost odkazu (minuty): :count",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Pokud jste o to nežádali, nemusíte nic dělat: můžete se dál přihlašovat jako dosud."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Wird bis zu 240 × 80 Pixel groß angezeigt, sodass breite und quadratische Logos gleichermaßen passen. PNG, JPG, GIF oder WebP, bis 2 MB.",
"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"
"Uploading into :folder": "Hochladen in :folder",
"Select all :count matching files": "Alle :count passenden Dateien auswählen",
"Restricted and empty files are skipped.": "Dateien mit eingeschränkter Endung und leere Dateien werden übersprungen.",
"Import all": "Alle importieren",
"Importing in the background": "Import läuft im Hintergrund",
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported von :total Dateien importiert. Sie können diese Seite verlassen — der Import läuft weiter.",
"Import finished": "Import abgeschlossen",
":imported of :total files imported.": ":imported von :total Dateien importiert.",
"The import stopped": "Der Import wurde abgebrochen",
"The import stopped making progress": "Der Import kommt nicht mehr voran",
":imported of :total files were imported before it failed: :error": ":imported von :total Dateien wurden importiert, bevor ein Fehler auftrat: :error",
":imported of :total files were imported. The queue worker may not be running.": ":imported von :total Dateien wurden importiert. Möglicherweise läuft der Hintergrundprozess nicht.",
"Choose Import all again to continue with the rest.": "Wählen Sie erneut „Alle importieren“, um mit den übrigen fortzufahren.",
"An import is already running. Wait for it to finish.": "Es läuft bereits ein Import. Warten Sie, bis er abgeschlossen ist.",
"Importing :count file in the background.|Importing :count files in the background.": ":count Datei wird im Hintergrund importiert.|:count Dateien werden im Hintergrund importiert.",
":width × :height pixels": ":width × :height Pixel",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Vor einem Moment wurde ein Link gesendet. Prüfen Sie Ihre E-Mails oder versuchen Sie es in einer Minute erneut.",
"Ask for a link by email to set your first password.": "Fordern Sie per E-Mail einen Link an, um Ihr erstes Passwort festzulegen.",
"Change your own email address and password from your profile.": "Ändern Sie Ihre eigene E-Mail-Adresse und Ihr Passwort in Ihrem Profil.",
"Crop": "Zuschneiden",
"Crop logo": "Logo zuschneiden",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Ziehen Sie den Rahmen und seine Ecken, um den sichtbaren Teil des Bildes zu wählen. Das hochgeladene Bild bleibt erhalten, Sie können das also später ändern.",
"Email me a link": "Link per E-Mail senden",
"Go to your profile": "Zu Ihrem Profil",
"Logo cropped.": "Logo zugeschnitten.",
"Original logo restored.": "Ursprüngliches Logo wiederhergestellt.",
"Remove your own two-factor authentication from your profile.": "Entfernen Sie Ihre eigene Zwei-Faktor-Authentifizierung in Ihrem Profil.",
"Restore original": "Original wiederherstellen",
"Save crop": "Zuschnitt speichern",
"The crop must stay inside the image.": "Der Zuschnitt muss innerhalb des Bildes liegen.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Dieser Ordner ist nicht leer. Senden Sie content_action=cascade_delete, um ihn mit allem Inhalt zu löschen.",
"This image is too large to crop. Upload a smaller one.": "Dieses Bild ist zu groß zum Zuschneiden. Laden Sie ein kleineres hoch.",
"This logo cannot be cropped. Upload it again.": "Dieses Logo kann nicht zugeschnitten werden. Laden Sie es erneut hoch.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Dieser Token kann Passwörter festlegen und die Zwei-Faktor-Authentifizierung der Konten entfernen, die er bearbeiten darf. Wer ihn besitzt, kann sich also als diese Konten anmelden. Wählen Sie diese Berechtigungen nur für jemanden, dem Sie diese Konten selbst anvertrauen würden.",
"Upload a logo before cropping it.": "Laden Sie zuerst ein Logo hoch, bevor Sie es zuschneiden.",
"We sent a link to your email address. It works for one hour.": "Wir haben Ihnen einen Link per E-Mail gesendet. Er ist eine Stunde lang gültig.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Wir senden Ihnen einen Link per E-Mail, um es festzulegen. Wenn Sie ihn öffnen, werden Sie überall abgemeldet. Melden Sie sich danach mit Ihrem neuen Passwort wieder an.",
"Username attribute (optional)": "Benutzernamen-Attribut (optional)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Erlaubt die Anmeldung zusätzlich zur Adresse mit dem Benutzernamen aus dem Verzeichnis, zum Beispiel uid, cn oder sAMAccountName. Leer lassen, um nur die Anmeldung per Adresse zu erlauben.",
"Email or username": "E-Mail-Adresse oder Benutzername",
"Enter your email or username and password below to log in": "Geben Sie unten Ihre E-Mail-Adresse oder Ihren Benutzernamen und Ihr Passwort ein, um sich anzumelden",
"Set your password": "Legen Sie Ihr Passwort fest",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Wählen Sie über die Schaltfläche unten ein Passwort für Ihr Konto. Bisher haben Sie sich über ein verknüpftes Konto angemeldet, etwa Google oder Microsoft.",
"This link will expire in :count minutes.": "Dieser Link läuft in :count Minuten ab.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Wenn Sie das nicht angefordert haben, müssen Sie nichts tun: Sie können sich weiterhin wie bisher anmelden."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Se muestra hasta 240 × 80 píxeles, así que entran tanto un logo ancho como uno cuadrado. PNG, JPG, GIF o WebP, hasta 2 MB.",
"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"
"Uploading into :folder": "Subiendo a :folder",
"Select all :count matching files": "Seleccionar los :count archivos coincidentes",
"Restricted and empty files are skipped.": "Se omiten los archivos restringidos y los vacíos.",
"Import all": "Importar todo",
"Importing in the background": "Importando en segundo plano",
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported de :total archivos importados. Puedes salir de esta página: la importación sigue en marcha.",
"Import finished": "Importación terminada",
":imported of :total files imported.": ":imported de :total archivos importados.",
"The import stopped": "La importación se detuvo",
"The import stopped making progress": "La importación dejó de avanzar",
":imported of :total files were imported before it failed: :error": "Se importaron :imported de :total archivos antes del error: :error",
":imported of :total files were imported. The queue worker may not be running.": "Se importaron :imported de :total archivos. Puede que el proceso en segundo plano no esté funcionando.",
"Choose Import all again to continue with the rest.": "Vuelve a elegir «Importar todo» para seguir con el resto.",
"An import is already running. Wait for it to finish.": "Ya hay una importación en curso. Espera a que termine.",
"Importing :count file in the background.|Importing :count files in the background.": "Importando :count archivo en segundo plano.|Importando :count archivos en segundo plano.",
":width × :height pixels": ":width × :height píxeles",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Se envió un enlace hace un momento. Revisa tu correo o vuelve a intentarlo en un minuto.",
"Ask for a link by email to set your first password.": "Pide un enlace por correo para crear tu primera contraseña.",
"Change your own email address and password from your profile.": "Cambia tu propio correo electrónico y tu contraseña desde tu perfil.",
"Crop": "Recortar",
"Crop logo": "Recortar el logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Arrastra el recuadro y sus esquinas para elegir qué parte de la imagen se muestra. La imagen subida se conserva, así que puedes cambiarlo más adelante.",
"Email me a link": "Envíame un enlace",
"Go to your profile": "Ir a tu perfil",
"Logo cropped.": "Logo recortado.",
"Original logo restored.": "Logo original restaurado.",
"Remove your own two-factor authentication from your profile.": "Quita tu propia verificación en dos pasos desde tu perfil.",
"Restore original": "Restaurar el original",
"Save crop": "Guardar el recorte",
"The crop must stay inside the image.": "El recorte tiene que quedar dentro de la imagen.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Esta carpeta no está vacía. Envía content_action=cascade_delete para borrarla con todo lo que contiene.",
"This image is too large to crop. Upload a smaller one.": "Esta imagen es demasiado grande para recortarla. Sube una más pequeña.",
"This logo cannot be cropped. Upload it again.": "Este logo no se puede recortar. Vuelve a subirlo.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Este token puede crear contraseñas y quitar la verificación en dos pasos en las cuentas que puede editar, así que quien lo tenga puede iniciar sesión como esas cuentas. Elige estos permisos solo para alguien a quien confiarías esas cuentas.",
"Upload a logo before cropping it.": "Sube un logo antes de recortarlo.",
"We sent a link to your email address. It works for one hour.": "Te enviamos un enlace a tu correo. Sirve durante una hora.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Te enviaremos un enlace por correo para crearla. Al abrirlo se cierran todas tus sesiones, así que después inicia sesión de nuevo con tu nueva contraseña.",
"Username attribute (optional)": "Atributo de nombre de usuario (opcional)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Permite que las personas inicien sesión con su nombre de usuario del directorio además de con su dirección: por ejemplo uid, cn o sAMAccountName. Déjalo vacío para iniciar sesión solo con la dirección.",
"Email or username": "Correo o nombre de usuario",
"Enter your email or username and password below to log in": "Ingresa tu correo electrónico o nombre de usuario y tu contraseña para iniciar sesión",
"Set your password": "Crea tu contraseña",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Usa el botón de abajo para elegir una contraseña para tu cuenta. Hasta ahora iniciabas sesión con una cuenta conectada, como Google o Microsoft.",
"This link will expire in :count minutes.": "Este enlace caduca en :count minutos.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Si no lo pediste, no tienes que hacer nada: puedes seguir iniciando sesión como hasta ahora."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Affiché jusqu'à 240 × 80 pixels : un logo large comme un logo carré y trouvent leur place. PNG, JPG, GIF ou WebP, jusqu'à 2 Mo.",
"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"
"Uploading into :folder": "Envoi dans :folder",
"Select all :count matching files": "Sélectionner les :count fichiers correspondants",
"Restricted and empty files are skipped.": "Les fichiers à extension restreinte et les fichiers vides sont ignorés.",
"Import all": "Tout importer",
"Importing in the background": "Importation en arrière-plan",
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported fichiers importés sur :total. Vous pouvez quitter cette page — l’importation continue.",
"Import finished": "Importation terminée",
":imported of :total files imported.": ":imported fichiers importés sur :total.",
"The import stopped": "L’importation s’est arrêtée",
"The import stopped making progress": "L’importation n’avance plus",
":imported of :total files were imported before it failed: :error": ":imported fichiers sur :total ont été importés avant l’échec : :error",
":imported of :total files were imported. The queue worker may not be running.": ":imported fichiers sur :total ont été importés. Le processus d’arrière-plan ne tourne peut-être pas.",
"Choose Import all again to continue with the rest.": "Choisissez à nouveau « Tout importer » pour continuer avec le reste.",
"An import is already running. Wait for it to finish.": "Une importation est déjà en cours. Attendez qu’elle se termine.",
"Importing :count file in the background.|Importing :count files in the background.": "Importation de :count fichier en arrière-plan.|Importation de :count fichiers en arrière-plan.",
":width × :height pixels": ":width × :height pixels",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Un lien vient d'être envoyé. Consultez vos e-mails ou réessayez dans une minute.",
"Ask for a link by email to set your first password.": "Demandez un lien par e-mail pour définir votre premier mot de passe.",
"Change your own email address and password from your profile.": "Modifiez votre propre adresse e-mail et votre mot de passe depuis votre profil.",
"Crop": "Recadrer",
"Crop logo": "Recadrer le logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Faites glisser le cadre et ses coins pour choisir la partie de l'image à afficher. L'image téléversée est conservée : vous pourrez modifier ce choix plus tard.",
"Email me a link": "M'envoyer un lien",
"Go to your profile": "Aller à votre profil",
"Logo cropped.": "Logo recadré.",
"Original logo restored.": "Logo d'origine restauré.",
"Remove your own two-factor authentication from your profile.": "Supprimez votre propre authentification à deux facteurs depuis votre profil.",
"Restore original": "Restaurer l'original",
"Save crop": "Enregistrer le recadrage",
"The crop must stay inside the image.": "Le recadrage doit rester à l'intérieur de l'image.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Ce dossier n'est pas vide. Envoyez content_action=cascade_delete pour le supprimer avec tout son contenu.",
"This image is too large to crop. Upload a smaller one.": "Cette image est trop grande pour être recadrée. Téléversez-en une plus petite.",
"This logo cannot be cropped. Upload it again.": "Ce logo ne peut pas être recadré. Téléversez-le à nouveau.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Ce jeton peut définir des mots de passe et supprimer l'authentification à deux facteurs des comptes qu'il peut modifier : quiconque le détient peut donc se connecter avec ces comptes. Ne choisissez ces autorisations que pour une personne à qui vous confieriez ces comptes eux-mêmes.",
"Upload a logo before cropping it.": "Téléversez un logo avant de le recadrer.",
"We sent a link to your email address. It works for one hour.": "Nous avons envoyé un lien à votre adresse e-mail. Il est valable une heure.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Nous vous enverrons un lien par e-mail pour le définir. L'ouvrir vous déconnecte partout : reconnectez-vous ensuite avec votre nouveau mot de passe.",
"Username attribute (optional)": "Attribut nom d'utilisateur (facultatif)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Permet de se connecter avec son nom d'utilisateur de l'annuaire en plus de son adresse : uid, cn ou sAMAccountName, par exemple. Laissez vide pour ne se connecter qu'avec l'adresse.",
"Email or username": "Adresse e-mail ou nom d'utilisateur",
"Enter your email or username and password below to log in": "Saisissez ci-dessous votre adresse e-mail ou votre nom d'utilisateur et votre mot de passe pour vous connecter",
"Set your password": "Définissez votre mot de passe",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Utilisez le bouton ci-dessous pour choisir un mot de passe pour votre compte. Jusqu'ici, vous vous connectiez avec un compte associé, comme Google ou Microsoft.",
"This link will expire in :count minutes.": "Ce lien expirera dans :count minutes.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Si vous n'avez rien demandé, vous n'avez rien à faire : vous pouvez continuer à vous connecter comme avant."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Ditampilkan hingga 240 × 80 piksel, jadi logo lebar maupun persegi sama-sama muat. PNG, JPG, GIF, atau WebP, hingga 2 MB.",
"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"
"Uploading into :folder": "Mengunggah ke :folder",
"Select all :count matching files": "Pilih semua :count berkas yang cocok",
"Restricted and empty files are skipped.": "Berkas dengan ekstensi yang dibatasi dan berkas kosong dilewati.",
"Import all": "Impor semua",
"Importing in the background": "Mengimpor di latar belakang",
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported dari :total berkas diimpor. Anda bisa meninggalkan halaman ini — impor tetap berjalan.",
"Import finished": "Impor selesai",
":imported of :total files imported.": ":imported dari :total berkas diimpor.",
"The import stopped": "Impor berhenti",
"The import stopped making progress": "Impor tidak lagi berjalan maju",
":imported of :total files were imported before it failed: :error": ":imported dari :total berkas diimpor sebelum gagal: :error",
":imported of :total files were imported. The queue worker may not be running.": ":imported dari :total berkas diimpor. Proses latar mungkin tidak berjalan.",
"Choose Import all again to continue with the rest.": "Pilih “Impor semua” lagi untuk melanjutkan sisanya.",
"An import is already running. Wait for it to finish.": "Impor lain sedang berjalan. Tunggu sampai selesai.",
"Importing :count file in the background.|Importing :count files in the background.": "Mengimpor :count berkas di latar belakang.|Mengimpor :count berkas di latar belakang.",
":width × :height pixels": ":width × :height piksel",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Tautan baru saja dikirim. Periksa email Anda, atau coba lagi dalam satu menit.",
"Ask for a link by email to set your first password.": "Minta tautan lewat email untuk membuat kata sandi pertama Anda.",
"Change your own email address and password from your profile.": "Ubah alamat email dan kata sandi Anda sendiri dari profil Anda.",
"Crop": "Pangkas",
"Crop logo": "Pangkas logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Seret kotak dan sudut-sudutnya untuk memilih bagian gambar yang ditampilkan. Gambar yang diunggah tetap disimpan, jadi Anda bisa mengubahnya nanti.",
"Email me a link": "Kirimi saya tautan",
"Go to your profile": "Buka profil Anda",
"Logo cropped.": "Logo dipangkas.",
"Original logo restored.": "Logo asli dipulihkan.",
"Remove your own two-factor authentication from your profile.": "Hapus autentikasi dua faktor Anda sendiri dari profil Anda.",
"Restore original": "Pulihkan yang asli",
"Save crop": "Simpan pangkasan",
"The crop must stay inside the image.": "Pangkasan harus tetap berada di dalam gambar.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Folder ini tidak kosong. Kirim content_action=cascade_delete untuk menghapusnya beserta seluruh isinya.",
"This image is too large to crop. Upload a smaller one.": "Gambar ini terlalu besar untuk dipangkas. Unggah yang lebih kecil.",
"This logo cannot be cropped. Upload it again.": "Logo ini tidak dapat dipangkas. Unggah ulang.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Token ini dapat menetapkan kata sandi dan menghapus autentikasi dua faktor pada akun yang boleh diubahnya, jadi siapa pun yang memegangnya dapat masuk sebagai akun-akun tersebut. Pilih izin ini hanya untuk pemegang yang Anda percayai dengan akun-akun itu sendiri.",
"Upload a logo before cropping it.": "Unggah logo sebelum memangkasnya.",
"We sent a link to your email address. It works for one hour.": "Kami telah mengirim tautan ke alamat email Anda. Tautan berlaku selama satu jam.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Kami akan mengirimkan tautan lewat email untuk membuatnya. Membuka tautan itu akan mengeluarkan Anda dari semua sesi, jadi masuklah lagi dengan kata sandi baru Anda setelahnya.",
"Username attribute (optional)": "Atribut nama pengguna (opsional)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Memungkinkan orang masuk dengan nama pengguna direktori selain alamatnya, misalnya uid, cn, atau sAMAccountName. Biarkan kosong untuk masuk hanya dengan alamat.",
"Email or username": "Email atau nama pengguna",
"Enter your email or username and password below to log in": "Masukkan email atau nama pengguna dan kata sandi Anda di bawah ini untuk masuk",
"Set your password": "Buat kata sandi Anda",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Gunakan tombol di bawah untuk memilih kata sandi akun Anda. Selama ini Anda masuk melalui akun yang terhubung, seperti Google atau Microsoft.",
"This link will expire in :count minutes.": "Tautan ini akan kedaluwarsa dalam :count menit.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Jika Anda tidak memintanya, Anda tidak perlu melakukan apa pun: Anda tetap bisa masuk seperti biasa."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Mostrato fino a 240 × 80 pixel, così ci stanno sia un logo largo sia uno quadrato. PNG, JPG, GIF o WebP, fino a 2 MB.",
"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"
"Uploading into :folder": "Caricamento in :folder",
"Select all :count matching files": "Seleziona tutti i :count file corrispondenti",
"Restricted and empty files are skipped.": "I file con estensione limitata e quelli vuoti vengono saltati.",
"Import all": "Importa tutto",
"Importing in the background": "Importazione in background",
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported di :total file importati. Puoi lasciare questa pagina: l’importazione continua.",
"Import finished": "Importazione completata",
":imported of :total files imported.": ":imported di :total file importati.",
"The import stopped": "L’importazione si è interrotta",
"The import stopped making progress": "L’importazione non avanza più",
":imported of :total files were imported before it failed: :error": "Sono stati importati :imported di :total file prima dell’errore: :error",
":imported of :total files were imported. The queue worker may not be running.": "Sono stati importati :imported di :total file. Forse il processo in background non è in esecuzione.",
"Choose Import all again to continue with the rest.": "Scegli di nuovo «Importa tutto» per continuare con il resto.",
"An import is already running. Wait for it to finish.": "C’è già un’importazione in corso. Aspetta che finisca.",
"Importing :count file in the background.|Importing :count files in the background.": "Importazione di :count file in background.|Importazione di :count file in background.",
":width × :height pixels": ":width × :height pixel",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Un link è stato inviato poco fa. Controlla la tua email o riprova tra un minuto.",
"Ask for a link by email to set your first password.": "Chiedi un link via email per impostare la tua prima password.",
"Change your own email address and password from your profile.": "Cambia il tuo indirizzo email e la tua password dal tuo profilo.",
"Crop": "Ritaglia",
"Crop logo": "Ritaglia il logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Trascina il riquadro e i suoi angoli per scegliere quale parte dell'immagine mostrare. L'immagine caricata viene conservata, quindi puoi cambiarlo più avanti.",
"Email me a link": "Inviami un link",
"Go to your profile": "Vai al tuo profilo",
"Logo cropped.": "Logo ritagliato.",
"Original logo restored.": "Logo originale ripristinato.",
"Remove your own two-factor authentication from your profile.": "Rimuovi la tua autenticazione a due fattori dal tuo profilo.",
"Restore original": "Ripristina l'originale",
"Save crop": "Salva il ritaglio",
"The crop must stay inside the image.": "Il ritaglio deve restare all'interno dell'immagine.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Questa cartella non è vuota. Invia content_action=cascade_delete per eliminarla con tutto il suo contenuto.",
"This image is too large to crop. Upload a smaller one.": "Questa immagine è troppo grande per essere ritagliata. Caricane una più piccola.",
"This logo cannot be cropped. Upload it again.": "Questo logo non può essere ritagliato. Caricalo di nuovo.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Questo token può impostare password e rimuovere l'autenticazione a due fattori sugli account che può modificare, quindi chi lo possiede può accedere come quegli account. Scegli questi permessi solo per qualcuno a cui affideresti quegli account.",
"Upload a logo before cropping it.": "Carica un logo prima di ritagliarlo.",
"We sent a link to your email address. It works for one hour.": "Ti abbiamo inviato un link al tuo indirizzo email. Vale per un'ora.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Ti invieremo un link via email per impostarla. Aprendo il link verrai disconnesso ovunque, quindi poi accedi di nuovo con la tua nuova password.",
"Username attribute (optional)": "Attributo nome utente (facoltativo)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Consente di accedere con il nome utente della directory oltre che con l'indirizzo: per esempio uid, cn o sAMAccountName. Lascialo vuoto per accedere solo con l'indirizzo.",
"Email or username": "E-mail o nome utente",
"Enter your email or username and password below to log in": "Inserisci qui sotto la tua e-mail o il tuo nome utente e la tua password per accedere",
"Set your password": "Imposta la tua password",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Usa il pulsante qui sotto per scegliere una password per il tuo account. Finora accedevi con un account collegato, come Google o Microsoft.",
"This link will expire in :count minutes.": "Questo link scade tra :count minuti.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Se non l'hai chiesto tu, non devi fare nulla: puoi continuare ad accedere come sempre."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大 240 × 80 ピクセルで表示されるため、横長のロゴも正方形のロゴも収まります。PNG、JPG、GIF、WebP、2 MB まで。",
"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"
"Uploading into :folder": "アップロード先: :folder",
"Select all :count matching files": "該当する :count 件のファイルをすべて選択",
"Restricted and empty files are skipped.": "制限された拡張子のファイルと空のファイルはスキップされます。",
"Import all": "すべてインポート",
"Importing in the background": "バックグラウンドでインポート中",
":imported of :total files imported. You can leave this page — the import keeps going.": ":total 件中 :imported 件をインポートしました。このページを離れても、インポートは続行されます。",
"Import finished": "インポートが完了しました",
":imported of :total files imported.": ":total 件中 :imported 件をインポートしました。",
"The import stopped": "インポートが停止しました",
"The import stopped making progress": "インポートが進まなくなりました",
":imported of :total files were imported before it failed: :error": "エラーで停止するまでに :total 件中 :imported 件をインポートしました: :error",
":imported of :total files were imported. The queue worker may not be running.": ":total 件中 :imported 件をインポートしました。バックグラウンド処理が動いていない可能性があります。",
"Choose Import all again to continue with the rest.": "残りを続けるには、もう一度「すべてインポート」を選んでください。",
"An import is already running. Wait for it to finish.": "すでにインポートを実行中です。終わるまでお待ちください。",
"Importing :count file in the background.|Importing :count files in the background.": ":count 件のファイルをバックグラウンドでインポートしています。|:count 件のファイルをバックグラウンドでインポートしています。",
":width × :height pixels": ":width × :height ピクセル",
"A link was sent a moment ago. Check your email, or try again in a minute.": "少し前にリンクを送信しました。メールを確認するか、1分後にもう一度お試しください。",
"Ask for a link by email to set your first password.": "最初のパスワードを設定するには、メールでリンクをリクエストしてください。",
"Change your own email address and password from your profile.": "ご自身のメールアドレスとパスワードはプロフィールから変更してください。",
"Crop": "切り抜き",
"Crop logo": "ロゴを切り抜く",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "枠とその角をドラッグして、表示する部分を選んでください。アップロードした画像は保持されるので、後から変更できます。",
"Email me a link": "リンクをメールで送る",
"Go to your profile": "プロフィールへ",
"Logo cropped.": "ロゴを切り抜きました。",
"Original logo restored.": "元のロゴを復元しました。",
"Remove your own two-factor authentication from your profile.": "ご自身の二要素認証はプロフィールから削除してください。",
"Restore original": "元に戻す",
"Save crop": "切り抜きを保存",
"The crop must stay inside the image.": "切り抜き範囲は画像の内側に収める必要があります。",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "このフォルダーは空ではありません。中身ごと削除するには content_action=cascade_delete を送信してください。",
"This image is too large to crop. Upload a smaller one.": "この画像は大きすぎて切り抜けません。より小さい画像をアップロードしてください。",
"This logo cannot be cropped. Upload it again.": "このロゴは切り抜けません。もう一度アップロードしてください。",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "このトークンは、編集できるアカウントのパスワードを設定し、二要素認証を削除できます。つまり、トークンを持つ人はそれらのアカウントとしてサインインできます。これらの権限は、アカウントそのものを任せられる相手にだけ付与してください。",
"Upload a logo before cropping it.": "切り抜く前にロゴをアップロードしてください。",
"We sent a link to your email address. It works for one hour.": "メールアドレスにリンクを送信しました。有効期限は1時間です。",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "設定用のリンクをメールでお送りします。リンクを開くとすべての場所からサインアウトされるので、その後、新しいパスワードでもう一度サインインしてください。",
"Username attribute (optional)": "ユーザー名属性 (任意)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "アドレスに加えて、ディレクトリのユーザー名でもログインできるようにします。例: uid、cn、sAMAccountName。空のままにすると、アドレスでのみログインできます。",
"Email or username": "メールアドレスまたはユーザー名",
"Enter your email or username and password below to log in": "ログインするには、以下にメールアドレスまたはユーザー名とパスワードを入力してください",
"Set your password": "パスワードを設定してください",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "下のボタンからアカウントのパスワードを設定してください。これまでは Google や Microsoft などの連携アカウントでサインインしていました。",
"This link will expire in :count minutes.": "このリンクの有効期限は :count 分です。",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "心当たりがない場合は、何もする必要はありません。これまでどおりサインインできます。"
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Getoond tot 240 × 80 pixels, dus zowel een breed als een vierkant logo past. PNG, JPG, GIF of WebP, tot 2 MB.",
"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"
"Uploading into :folder": "Uploaden naar :folder",
"Select all :count matching files": "Alle :count overeenkomende bestanden selecteren",
"Restricted and empty files are skipped.": "Bestanden met een beperkte extensie en lege bestanden worden overgeslagen.",
"Import all": "Alles importeren",
"Importing in the background": "Importeren op de achtergrond",
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported van :total bestanden geïmporteerd. Je kunt deze pagina verlaten — het importeren gaat door.",
"Import finished": "Importeren voltooid",
":imported of :total files imported.": ":imported van :total bestanden geïmporteerd.",
"The import stopped": "Het importeren is gestopt",
"The import stopped making progress": "Het importeren schiet niet meer op",
":imported of :total files were imported before it failed: :error": ":imported van :total bestanden zijn geïmporteerd voordat het misging: :error",
":imported of :total files were imported. The queue worker may not be running.": ":imported van :total bestanden zijn geïmporteerd. Mogelijk draait het achtergrondproces niet.",
"Choose Import all again to continue with the rest.": "Kies opnieuw ‘Alles importeren’ om met de rest verder te gaan.",
"An import is already running. Wait for it to finish.": "Er loopt al een import. Wacht tot die klaar is.",
"Importing :count file in the background.|Importing :count files in the background.": ":count bestand wordt op de achtergrond geïmporteerd.|:count bestanden worden op de achtergrond geïmporteerd.",
":width × :height pixels": ":width × :height pixels",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Er is zojuist een link verstuurd. Kijk in je e-mail of probeer het over een minuut opnieuw.",
"Ask for a link by email to set your first password.": "Vraag per e-mail een link aan om je eerste wachtwoord in te stellen.",
"Change your own email address and password from your profile.": "Wijzig je eigen e-mailadres en wachtwoord via je profiel.",
"Crop": "Bijsnijden",
"Crop logo": "Logo bijsnijden",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Sleep het kader en de hoeken om te kiezen welk deel van de afbeelding wordt getoond. De geüploade afbeelding blijft bewaard, dus je kunt dit later nog wijzigen.",
"Email me a link": "Stuur mij een link",
"Go to your profile": "Naar je profiel",
"Logo cropped.": "Logo bijgesneden.",
"Original logo restored.": "Oorspronkelijk logo hersteld.",
"Remove your own two-factor authentication from your profile.": "Verwijder je eigen tweefactorauthenticatie via je profiel.",
"Restore original": "Origineel herstellen",
"Save crop": "Bijsnijden opslaan",
"The crop must stay inside the image.": "De uitsnede moet binnen de afbeelding blijven.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Deze map is niet leeg. Stuur content_action=cascade_delete om hem met alles erin te verwijderen.",
"This image is too large to crop. Upload a smaller one.": "Deze afbeelding is te groot om bij te snijden. Upload een kleinere.",
"This logo cannot be cropped. Upload it again.": "Dit logo kan niet worden bijgesneden. Upload het opnieuw.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Dit token kan wachtwoorden instellen en tweefactorauthenticatie verwijderen bij de accounts die het mag bewerken, dus wie het heeft, kan als die accounts inloggen. Kies deze rechten alleen voor iemand aan wie je die accounts zelf zou toevertrouwen.",
"Upload a logo before cropping it.": "Upload een logo voordat je het bijsnijdt.",
"We sent a link to your email address. It works for one hour.": "We hebben een link naar je e-mailadres gestuurd. Hij werkt een uur lang.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "We sturen je per e-mail een link om het in te stellen. Als je de link opent, word je overal afgemeld, dus log daarna opnieuw in met je nieuwe wachtwoord.",
"Username attribute (optional)": "Gebruikersnaamattribuut (optioneel)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Laat mensen naast hun adres ook inloggen met hun gebruikersnaam uit de directory, bijvoorbeeld uid, cn of sAMAccountName. Laat leeg om alleen met het adres in te loggen.",
"Email or username": "E-mailadres of gebruikersnaam",
"Enter your email or username and password below to log in": "Voer hieronder je e-mailadres of gebruikersnaam en wachtwoord in om in te loggen",
"Set your password": "Stel je wachtwoord in",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Gebruik de knop hieronder om een wachtwoord voor je account te kiezen. Tot nu toe meldde je je aan met een gekoppeld account, zoals Google of Microsoft.",
"This link will expire in :count minutes.": "Deze link verloopt over :count minuten.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Heb je dit niet aangevraagd, dan hoef je niets te doen: je kunt je blijven aanmelden zoals je gewend bent."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 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 240 × 80 pikseli, więc zmieści się zarówno szerokie, jak i kwadratowe logo. PNG, JPG, GIF lub WebP, do 2 MB.",
"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"
"Uploading into :folder": "Przesyłanie do: :folder",
"Select all :count matching files": "Zaznacz wszystkie pasujące pliki (:count)",
"Restricted and empty files are skipped.": "Pliki z ograniczonym rozszerzeniem i puste pliki są pomijane.",
"Import all": "Importuj wszystko",
"Importing in the background": "Importowanie w tle",
":imported of :total files imported. You can leave this page — the import keeps going.": "Zaimportowano :imported z :total plików. Możesz opuścić tę stronę — import będzie trwał dalej.",
"Import finished": "Import zakończony",
":imported of :total files imported.": "Zaimportowano :imported z :total plików.",
"The import stopped": "Import został przerwany",
"The import stopped making progress": "Import przestał postępować",
":imported of :total files were imported before it failed: :error": "Przed błędem zaimportowano :imported z :total plików: :error",
":imported of :total files were imported. The queue worker may not be running.": "Zaimportowano :imported z :total plików. Być może proces w tle nie działa.",
"Choose Import all again to continue with the rest.": "Wybierz ponownie „Importuj wszystko”, aby kontynuować z resztą.",
"An import is already running. Wait for it to finish.": "Import już trwa. Poczekaj, aż się zakończy.",
"Importing :count file in the background.|Importing :count files in the background.": "Importowanie :count pliku w tle.|Importowanie :count plików w tle.|Importowanie :count plików w tle.",
":width × :height pixels": ":width × :height px",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Link został wysłany przed chwilą. Sprawdź pocztę albo spróbuj ponownie za minutę.",
"Ask for a link by email to set your first password.": "Poproś o link e-mailem, aby ustawić swoje pierwsze hasło.",
"Change your own email address and password from your profile.": "Swój adres e-mail i hasło zmienisz w swoim profilu.",
"Crop": "Przytnij",
"Crop logo": "Przytnij logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Przeciągnij ramkę i jej rogi, aby wybrać widoczną część obrazu. Przesłany obraz zostaje zachowany, więc możesz to później zmienić.",
"Email me a link": "Wyślij mi link",
"Go to your profile": "Przejdź do profilu",
"Logo cropped.": "Logo zostało przycięte.",
"Original logo restored.": "Przywrócono oryginalne logo.",
"Remove your own two-factor authentication from your profile.": "Swoje uwierzytelnianie dwuskładnikowe usuniesz w swoim profilu.",
"Restore original": "Przywróć oryginał",
"Save crop": "Zapisz przycięcie",
"The crop must stay inside the image.": "Przycięcie musi mieścić się w obrazie.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Ten folder nie jest pusty. Wyślij content_action=cascade_delete, aby usunąć go razem z całą zawartością.",
"This image is too large to crop. Upload a smaller one.": "Ten obraz jest zbyt duży, aby go przyciąć. Prześlij mniejszy.",
"This logo cannot be cropped. Upload it again.": "Tego logo nie da się przyciąć. Prześlij je ponownie.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Ten token może ustawiać hasła i usuwać uwierzytelnianie dwuskładnikowe na kontach, które może edytować, więc każdy, kto go ma, może zalogować się jako te konta. Wybieraj te uprawnienia tylko dla osoby, której można by powierzyć same te konta.",
"Upload a logo before cropping it.": "Prześlij logo, zanim je przytniesz.",
"We sent a link to your email address. It works for one hour.": "Wysłaliśmy link na Twój adres e-mail. Działa przez godzinę.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Wyślemy Ci e-mailem link do jego ustawienia. Otwarcie linku wyloguje Cię wszędzie, więc potem zaloguj się ponownie nowym hasłem.",
"Username attribute (optional)": "Atrybut nazwy użytkownika (opcjonalnie)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Pozwala logować się oprócz adresu także nazwą użytkownika z katalogu, na przykład uid, cn lub sAMAccountName. Zostaw puste, aby logować się tylko adresem.",
"Email or username": "Adres e-mail lub nazwa użytkownika",
"Enter your email or username and password below to log in": "Wpisz poniżej swój adres e-mail lub nazwę użytkownika i hasło, aby się zalogować",
"Set your password": "Ustaw swoje hasło",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Użyj przycisku poniżej, aby wybrać hasło do swojego konta. Do tej pory logowanie odbywało się przez połączone konto, np. Google lub Microsoft.",
"This link will expire in :count minutes.": "Ważność linku (minuty): :count",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Jeśli to nie była Twoja prośba, nie musisz nic robić: możesz dalej logować się tak jak dotąd."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Exibido em até 240 × 80 pixels, então cabem tanto um logo largo quanto um quadrado. PNG, JPG, GIF ou WebP, até 2 MB.",
"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"
"Uploading into :folder": "Enviando para :folder",
"Select all :count matching files": "Selecionar todos os :count arquivos correspondentes",
"Restricted and empty files are skipped.": "Arquivos com extensão restrita e arquivos vazios são ignorados.",
"Import all": "Importar todos",
"Importing in the background": "Importando em segundo plano",
":imported of :total files imported. You can leave this page — the import keeps going.": ":imported de :total arquivos importados. Você pode sair desta página — a importação continua.",
"Import finished": "Importação concluída",
":imported of :total files imported.": ":imported de :total arquivos importados.",
"The import stopped": "A importação parou",
"The import stopped making progress": "A importação parou de avançar",
":imported of :total files were imported before it failed: :error": ":imported de :total arquivos foram importados antes da falha: :error",
":imported of :total files were imported. The queue worker may not be running.": ":imported de :total arquivos foram importados. Talvez o processo em segundo plano não esteja rodando.",
"Choose Import all again to continue with the rest.": "Escolha “Importar todos” de novo para continuar com o restante.",
"An import is already running. Wait for it to finish.": "Já existe uma importação em andamento. Espere ela terminar.",
"Importing :count file in the background.|Importing :count files in the background.": "Importando :count arquivo em segundo plano.|Importando :count arquivos em segundo plano.",
":width × :height pixels": ":width × :height pixels",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Um link foi enviado agora há pouco. Confira seu e-mail ou tente de novo em um minuto.",
"Ask for a link by email to set your first password.": "Peça um link por e-mail para criar sua primeira senha.",
"Change your own email address and password from your profile.": "Altere seu próprio e-mail e sua senha pelo seu perfil.",
"Crop": "Recortar",
"Crop logo": "Recortar o logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Arraste a moldura e seus cantos para escolher a parte da imagem que aparece. A imagem enviada é mantida, então você pode mudar isso depois.",
"Email me a link": "Me envie um link",
"Go to your profile": "Ir para o seu perfil",
"Logo cropped.": "Logo recortado.",
"Original logo restored.": "Logo original restaurado.",
"Remove your own two-factor authentication from your profile.": "Remova sua própria verificação em duas etapas pelo seu perfil.",
"Restore original": "Restaurar o original",
"Save crop": "Salvar o recorte",
"The crop must stay inside the image.": "O recorte precisa ficar dentro da imagem.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Esta pasta não está vazia. Envie content_action=cascade_delete para excluí-la com tudo o que ela contém.",
"This image is too large to crop. Upload a smaller one.": "Esta imagem é grande demais para recortar. Envie uma menor.",
"This logo cannot be cropped. Upload it again.": "Este logo não pode ser recortado. Envie-o de novo.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Este token pode definir senhas e remover a verificação em duas etapas das contas que ele pode editar, então quem o tiver pode entrar como essas contas. Escolha essas permissões só para alguém a quem você confiaria essas próprias contas.",
"Upload a logo before cropping it.": "Envie um logo antes de recortá-lo.",
"We sent a link to your email address. It works for one hour.": "Enviamos um link para o seu e-mail. Ele vale por uma hora.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Vamos enviar um link por e-mail para criá-la. Abrir o link encerra todas as suas sessões, então depois entre de novo com a sua nova senha.",
"Username attribute (optional)": "Atributo de nome de usuário (opcional)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Permite entrar com o nome de usuário do diretório além do endereço: por exemplo uid, cn ou sAMAccountName. Deixe em branco para entrar só com o endereço.",
"Email or username": "E-mail ou nome de usuário",
"Enter your email or username and password below to log in": "Informe abaixo seu e-mail ou nome de usuário e sua senha para entrar",
"Set your password": "Crie sua senha",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Use o botão abaixo para escolher uma senha para sua conta. Até agora você entrava com uma conta conectada, como Google ou Microsoft.",
"This link will expire in :count minutes.": "Este link expira em :count minutos.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Se você não pediu isso, não precisa fazer nada: pode continuar entrando como antes."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Показывается размером до 240 × 80 пикселей, поэтому поместится и широкий, и квадратный логотип. PNG, JPG, GIF или WebP, до 2 МБ.",
"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"
"Uploading into :folder": "Загрузка в: :folder",
"Select all :count matching files": "Выбрать все подходящие файлы (:count)",
"Restricted and empty files are skipped.": "Файлы с ограниченным расширением и пустые файлы пропускаются.",
"Import all": "Импортировать все",
"Importing in the background": "Импорт в фоновом режиме",
":imported of :total files imported. You can leave this page — the import keeps going.": "Импортировано файлов: :imported из :total. Можно уйти с этой страницы — импорт продолжится.",
"Import finished": "Импорт завершён",
":imported of :total files imported.": "Импортировано файлов: :imported из :total.",
"The import stopped": "Импорт остановлен",
"The import stopped making progress": "Импорт перестал продвигаться",
":imported of :total files were imported before it failed: :error": "До ошибки импортировано файлов: :imported из :total. Ошибка: :error",
":imported of :total files were imported. The queue worker may not be running.": "Импортировано файлов: :imported из :total. Возможно, фоновый обработчик не запущен.",
"Choose Import all again to continue with the rest.": "Чтобы продолжить с оставшимися, снова выберите «Импортировать все».",
"An import is already running. Wait for it to finish.": "Импорт уже идёт. Дождитесь его завершения.",
"Importing :count file in the background.|Importing :count files in the background.": "Импорт :count файла в фоновом режиме.|Импорт :count файлов в фоновом режиме.|Импорт :count файлов в фоновом режиме.",
":width × :height pixels": ":width × :height пикс.",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Ссылка была отправлена только что. Проверьте почту или повторите попытку через минуту.",
"Ask for a link by email to set your first password.": "Запросите ссылку по электронной почте, чтобы задать свой первый пароль.",
"Change your own email address and password from your profile.": "Свой адрес электронной почты и пароль меняйте в своём профиле.",
"Crop": "Обрезать",
"Crop logo": "Обрезать логотип",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Перетаскивайте рамку и её углы, чтобы выбрать, какая часть изображения будет показана. Загруженное изображение сохраняется, поэтому это можно изменить позже.",
"Email me a link": "Отправить мне ссылку",
"Go to your profile": "Перейти в профиль",
"Logo cropped.": "Логотип обрезан.",
"Original logo restored.": "Исходный логотип восстановлен.",
"Remove your own two-factor authentication from your profile.": "Удаляйте свою двухфакторную аутентификацию в своём профиле.",
"Restore original": "Восстановить исходный",
"Save crop": "Сохранить обрезку",
"The crop must stay inside the image.": "Область обрезки должна оставаться внутри изображения.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Эта папка не пуста. Отправьте content_action=cascade_delete, чтобы удалить её вместе со всем содержимым.",
"This image is too large to crop. Upload a smaller one.": "Это изображение слишком большое для обрезки. Загрузите изображение поменьше.",
"This logo cannot be cropped. Upload it again.": "Этот логотип нельзя обрезать. Загрузите его заново.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Этот токен может задавать пароли и удалять двухфакторную аутентификацию у учётных записей, которые ему разрешено изменять, поэтому любой его владелец может войти под этими учётными записями. Выбирайте эти права только для того, кому доверили бы сами эти учётные записи.",
"Upload a logo before cropping it.": "Загрузите логотип, прежде чем обрезать его.",
"We sent a link to your email address. It works for one hour.": "Мы отправили ссылку на ваш адрес электронной почты. Она действует один час.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Мы отправим вам ссылку по электронной почте, чтобы его задать. Открыв ссылку, вы выйдете из всех сеансов, поэтому затем войдите снова с новым паролем.",
"Username attribute (optional)": "Атрибут имени пользователя (необязательно)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Позволяет входить не только по адресу, но и по имени пользователя из каталога, например uid, cn или sAMAccountName. Оставьте пустым, чтобы входить только по адресу.",
"Email or username": "Адрес эл. почты или имя пользователя",
"Enter your email or username and password below to log in": "Введите ниже адрес эл. почты или имя пользователя и пароль, чтобы войти",
"Set your password": "Задайте пароль",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Нажмите кнопку ниже, чтобы выбрать пароль для своей учётной записи. До сих пор вы входили через связанную учётную запись, например Google или Microsoft.",
"This link will expire in :count minutes.": "Срок действия ссылки (мин.): :count",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Если вы этого не запрашивали, ничего делать не нужно: вы можете входить, как и раньше."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "Huonyeshwa hadi pikseli 240 × 80, kwa hivyo nembo pana na ya mraba zote zinatosha. PNG, JPG, GIF au WebP, hadi MB 2.",
"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"
"Uploading into :folder": "Inapakia kwenye :folder",
"Select all :count matching files": "Chagua mafaili yote :count yanayolingana",
"Restricted and empty files are skipped.": "Mafaili yenye kiendelezi kilichozuiwa na mafaili matupu yanarukwa.",
"Import all": "Ingiza yote",
"Importing in the background": "Inaingiza chinichini",
":imported of :total files imported. You can leave this page — the import keeps going.": "Mafaili :imported kati ya :total yameingizwa. Unaweza kuondoka kwenye ukurasa huu — uingizaji unaendelea.",
"Import finished": "Uingizaji umekamilika",
":imported of :total files imported.": "Mafaili :imported kati ya :total yameingizwa.",
"The import stopped": "Uingizaji umesimama",
"The import stopped making progress": "Uingizaji umeacha kusonga mbele",
":imported of :total files were imported before it failed: :error": "Mafaili :imported kati ya :total yaliingizwa kabla ya hitilafu: :error",
":imported of :total files were imported. The queue worker may not be running.": "Mafaili :imported kati ya :total yameingizwa. Huenda mchakato wa nyuma hauendeshwi.",
"Choose Import all again to continue with the rest.": "Chagua “Ingiza yote” tena ili kuendelea na yaliyosalia.",
"An import is already running. Wait for it to finish.": "Uingizaji mwingine tayari unaendelea. Subiri umalizike.",
"Importing :count file in the background.|Importing :count files in the background.": "Inaingiza faili :count chinichini.|Inaingiza mafaili :count chinichini.",
":width × :height pixels": "Pikseli :width × :height",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Kiungo kimetumwa hivi punde. Angalia barua pepe yako, au ujaribu tena baada ya dakika moja.",
"Ask for a link by email to set your first password.": "Omba kiungo kwa barua pepe ili kuweka nenosiri lako la kwanza.",
"Change your own email address and password from your profile.": "Badilisha anwani yako ya barua pepe na nenosiri lako kutoka kwenye wasifu wako.",
"Crop": "Punguza",
"Crop logo": "Punguza nembo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Buruta kisanduku na pembe zake ili kuchagua sehemu ya picha itakayoonyeshwa. Picha uliyopakia inahifadhiwa, kwa hivyo unaweza kubadilisha hili baadaye.",
"Email me a link": "Nitumie kiungo",
"Go to your profile": "Nenda kwenye wasifu wako",
"Logo cropped.": "Nembo imepunguzwa.",
"Original logo restored.": "Nembo ya asili imerejeshwa.",
"Remove your own two-factor authentication from your profile.": "Ondoa uthibitishaji wako wa hatua mbili kutoka kwenye wasifu wako.",
"Restore original": "Rejesha ya asili",
"Save crop": "Hifadhi upunguzaji",
"The crop must stay inside the image.": "Upunguzaji lazima ubaki ndani ya picha.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Folda hii haiko tupu. Tuma content_action=cascade_delete ili kuifuta pamoja na kila kitu kilichomo.",
"This image is too large to crop. Upload a smaller one.": "Picha hii ni kubwa mno kupunguzwa. Pakia ndogo zaidi.",
"This logo cannot be cropped. Upload it again.": "Nembo hii haiwezi kupunguzwa. Ipakie tena.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Tokeni hii inaweza kuweka manenosiri na kuondoa uthibitishaji wa hatua mbili kwenye akaunti inazoruhusiwa kuhariri, kwa hivyo yeyote aliye nayo anaweza kuingia kama akaunti hizo. Chagua ruhusa hizi tu kwa mtu ambaye ungemwamini na akaunti hizo zenyewe.",
"Upload a logo before cropping it.": "Pakia nembo kabla ya kuipunguza.",
"We sent a link to your email address. It works for one hour.": "Tumetuma kiungo kwenye anwani yako ya barua pepe. Kinafanya kazi kwa saa moja.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Tutakutumia kiungo kwa barua pepe ili kuliweka. Kufungua kiungo kutakutoa kila mahali, kwa hivyo baadaye ingia tena kwa nenosiri lako jipya.",
"Username attribute (optional)": "Sifa ya jina la mtumiaji (si lazima)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Huwaruhusu watu kuingia kwa jina la mtumiaji la saraka pamoja na anwani yao, kwa mfano uid, cn au sAMAccountName. Acha tupu ili kuingia kwa anwani pekee.",
"Email or username": "Barua pepe au jina la mtumiaji",
"Enter your email or username and password below to log in": "Weka barua pepe au jina la mtumiaji na nenosiri lako hapa chini ili kuingia",
"Set your password": "Weka nenosiri lako",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Tumia kitufe kilicho hapa chini kuchagua nenosiri la akaunti yako. Hadi sasa umekuwa ukiingia kupitia akaunti iliyounganishwa, kama Google au Microsoft.",
"This link will expire in :count minutes.": "Kiungo hiki kitaisha muda baada ya dakika :count.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Ikiwa hukuomba hili, huhitaji kufanya chochote: unaweza kuendelea kuingia kama kawaida."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "En fazla 240 × 80 piksel olarak gösterilir; böylece hem geniş hem kare logolar sığar. PNG, JPG, GIF veya WebP, en fazla 2 MB.",
"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"
"Uploading into :folder": "Yükleme hedefi: :folder",
"Select all :count matching files": "Eşleşen :count dosyanın tümünü seç",
"Restricted and empty files are skipped.": "Kısıtlı uzantılı ve boş dosyalar atlanır.",
"Import all": "Tümünü içe aktar",
"Importing in the background": "Arka planda içe aktarılıyor",
":imported of :total files imported. You can leave this page — the import keeps going.": ":total dosyadan :imported tanesi içe aktarıldı. Bu sayfadan ayrılabilirsiniz — içe aktarma devam eder.",
"Import finished": "İçe aktarma tamamlandı",
":imported of :total files imported.": ":total dosyadan :imported tanesi içe aktarıldı.",
"The import stopped": "İçe aktarma durdu",
"The import stopped making progress": "İçe aktarma ilerlemiyor",
":imported of :total files were imported before it failed: :error": "Hata oluşmadan önce :total dosyadan :imported tanesi içe aktarıldı: :error",
":imported of :total files were imported. The queue worker may not be running.": ":total dosyadan :imported tanesi içe aktarıldı. Arka plan işleyicisi çalışmıyor olabilir.",
"Choose Import all again to continue with the rest.": "Kalanlarla devam etmek için yeniden “Tümünü içe aktar”ı seçin.",
"An import is already running. Wait for it to finish.": "Zaten bir içe aktarma sürüyor. Bitmesini bekleyin.",
"Importing :count file in the background.|Importing :count files in the background.": ":count dosya arka planda içe aktarılıyor.|:count dosya arka planda içe aktarılıyor.",
":width × :height pixels": ":width × :height piksel",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Az önce bir bağlantı gönderildi. E-postanızı kontrol edin ya da bir dakika sonra tekrar deneyin.",
"Ask for a link by email to set your first password.": "İlk parolanızı oluşturmak için e-postayla bir bağlantı isteyin.",
"Change your own email address and password from your profile.": "Kendi e-posta adresinizi ve parolanızı profilinizden değiştirin.",
"Crop": "Kırp",
"Crop logo": "Logoyu kırp",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Görüntünün hangi kısmının gösterileceğini seçmek için kutuyu ve köşelerini sürükleyin. Yüklenen görüntü saklanır, böylece bunu daha sonra değiştirebilirsiniz.",
"Email me a link": "Bana bir bağlantı gönder",
"Go to your profile": "Profilinize gidin",
"Logo cropped.": "Logo kırpıldı.",
"Original logo restored.": "Orijinal logo geri yüklendi.",
"Remove your own two-factor authentication from your profile.": "Kendi iki adımlı doğrulamanızı profilinizden kaldırın.",
"Restore original": "Orijinali geri yükle",
"Save crop": "Kırpmayı kaydet",
"The crop must stay inside the image.": "Kırpma alanı görüntünün içinde kalmalıdır.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Bu klasör boş değil. İçindeki her şeyle birlikte silmek için content_action=cascade_delete gönderin.",
"This image is too large to crop. Upload a smaller one.": "Bu görüntü kırpmak için çok büyük. Daha küçük bir görüntü yükleyin.",
"This logo cannot be cropped. Upload it again.": "Bu logo kırpılamıyor. Tekrar yükleyin.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Bu token, düzenleyebildiği hesaplarda parola belirleyebilir ve iki adımlı doğrulamayı kaldırabilir; dolayısıyla tokene sahip olan herkes bu hesaplar olarak oturum açabilir. Bu yetkileri yalnızca bu hesapların kendisini emanet edeceğiniz biri için seçin.",
"Upload a logo before cropping it.": "Kırpmadan önce bir logo yükleyin.",
"We sent a link to your email address. It works for one hour.": "E-posta adresinize bir bağlantı gönderdik. Bir saat geçerlidir.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Parolayı oluşturmanız için size e-postayla bir bağlantı göndereceğiz. Bağlantıyı açtığınızda her yerden oturumunuz kapanır; ardından yeni parolanızla tekrar oturum açın.",
"Username attribute (optional)": "Kullanıcı adı özniteliği (isteğe bağlı)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Kişilerin adreslerinin yanı sıra dizindeki kullanıcı adlarıyla da giriş yapmasını sağlar; örneğin uid, cn veya sAMAccountName. Yalnızca adresle giriş için boş bırakın.",
"Email or username": "E-posta veya kullanıcı adı",
"Enter your email or username and password below to log in": "Giriş yapmak için e-posta adresinizi veya kullanıcı adınızı ve parolanızı aşağıya girin",
"Set your password": "Parolanızı oluşturun",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Hesabınız için bir parola seçmek üzere aşağıdaki düğmeyi kullanın. Şimdiye kadar Google veya Microsoft gibi bağlı bir hesapla oturum açıyordunuz.",
"This link will expire in :count minutes.": "Bu bağlantının süresi :count dakika içinde dolacak.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Bunu siz istemediyseniz bir şey yapmanıza gerek yok: her zamanki gibi oturum açmaya devam edebilirsiniz."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 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 240 × 80 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.",
"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"
"Uploading into :folder": "Đang tải lên vào :folder",
"Select all :count matching files": "Chọn tất cả :count tệp khớp",
"Restricted and empty files are skipped.": "Các tệp có phần mở rộng bị hạn chế và tệp rỗng sẽ được bỏ qua.",
"Import all": "Nhập tất cả",
"Importing in the background": "Đang nhập trong nền",
":imported of :total files imported. You can leave this page — the import keeps going.": "Đã nhập :imported/:total tệp. Bạn có thể rời trang này — việc nhập vẫn tiếp tục.",
"Import finished": "Đã nhập xong",
":imported of :total files imported.": "Đã nhập :imported/:total tệp.",
"The import stopped": "Việc nhập đã dừng",
"The import stopped making progress": "Việc nhập không còn tiến triển",
":imported of :total files were imported before it failed: :error": "Đã nhập :imported/:total tệp trước khi gặp lỗi: :error",
":imported of :total files were imported. The queue worker may not be running.": "Đã nhập :imported/:total tệp. Có thể tiến trình nền không chạy.",
"Choose Import all again to continue with the rest.": "Hãy chọn lại “Nhập tất cả” để tiếp tục với phần còn lại.",
"An import is already running. Wait for it to finish.": "Đang có một lượt nhập chạy. Hãy đợi nó xong.",
"Importing :count file in the background.|Importing :count files in the background.": "Đang nhập :count tệp trong nền.|Đang nhập :count tệp trong nền.",
":width × :height pixels": ":width × :height pixel",
"A link was sent a moment ago. Check your email, or try again in a minute.": "Một liên kết vừa được gửi. Hãy kiểm tra email của bạn, hoặc thử lại sau một phút.",
"Ask for a link by email to set your first password.": "Yêu cầu một liên kết qua email để đặt mật khẩu đầu tiên của bạn.",
"Change your own email address and password from your profile.": "Hãy đổi địa chỉ email và mật khẩu của chính bạn trong hồ sơ.",
"Crop": "Cắt",
"Crop logo": "Cắt logo",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "Kéo khung và các góc của nó để chọn phần ảnh được hiển thị. Ảnh đã tải lên vẫn được giữ lại, nên bạn có thể thay đổi sau.",
"Email me a link": "Gửi liên kết cho tôi",
"Go to your profile": "Đến hồ sơ của bạn",
"Logo cropped.": "Đã cắt logo.",
"Original logo restored.": "Đã khôi phục logo gốc.",
"Remove your own two-factor authentication from your profile.": "Hãy gỡ xác thực hai yếu tố của chính bạn trong hồ sơ.",
"Restore original": "Khôi phục bản gốc",
"Save crop": "Lưu vùng cắt",
"The crop must stay inside the image.": "Vùng cắt phải nằm trong ảnh.",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "Thư mục này không trống. Gửi content_action=cascade_delete để xóa nó cùng mọi thứ bên trong.",
"This image is too large to crop. Upload a smaller one.": "Ảnh này quá lớn để cắt. Hãy tải lên ảnh nhỏ hơn.",
"This logo cannot be cropped. Upload it again.": "Không thể cắt logo này. Hãy tải lên lại.",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "Token này có thể đặt mật khẩu và gỡ xác thực hai yếu tố trên các tài khoản mà nó được phép chỉnh sửa, nên bất kỳ ai giữ nó đều có thể đăng nhập bằng các tài khoản đó. Chỉ chọn các quyền này cho người mà bạn tin tưởng giao chính các tài khoản đó.",
"Upload a logo before cropping it.": "Hãy tải logo lên trước khi cắt.",
"We sent a link to your email address. It works for one hour.": "Chúng tôi đã gửi một liên kết đến địa chỉ email của bạn. Liên kết có hiệu lực trong một giờ.",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "Chúng tôi sẽ gửi email cho bạn một liên kết để đặt mật khẩu. Mở liên kết sẽ đăng xuất bạn ở mọi nơi, vì vậy sau đó hãy đăng nhập lại bằng mật khẩu mới.",
"Username attribute (optional)": "Thuộc tính tên người dùng (không bắt buộc)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "Cho phép đăng nhập bằng tên người dùng trong thư mục ngoài địa chỉ, ví dụ uid, cn hoặc sAMAccountName. Để trống nếu chỉ đăng nhập bằng địa chỉ.",
"Email or username": "Email hoặc tên người dùng",
"Enter your email or username and password below to log in": "Nhập email hoặc tên người dùng và mật khẩu bên dưới để đăng nhập",
"Set your password": "Đặt mật khẩu của bạn",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "Dùng nút bên dưới để chọn mật khẩu cho tài khoản của bạn. Trước giờ bạn đăng nhập bằng một tài khoản liên kết, như Google hoặc Microsoft.",
"This link will expire in :count minutes.": "Liên kết này sẽ hết hạn sau :count phút.",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "Nếu bạn không yêu cầu việc này, bạn không cần làm gì: bạn vẫn có thể đăng nhập như trước."
}
+46 -2
View File
@@ -2280,8 +2280,52 @@
"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 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.": "最大按 240 × 80 像素显示,宽标志和方形标志都能放下。PNG、JPG、GIF 或 WebP,最大 2 MB。",
"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"
"Uploading into :folder": "上传到 :folder",
"Select all :count matching files": "选择全部 :count 个匹配的文件",
"Restricted and empty files are skipped.": "受限扩展名的文件和空文件会被跳过。",
"Import all": "全部导入",
"Importing in the background": "正在后台导入",
":imported of :total files imported. You can leave this page — the import keeps going.": "已导入 :imported / :total 个文件。你可以离开此页面——导入会继续进行。",
"Import finished": "导入完成",
":imported of :total files imported.": "已导入 :imported / :total 个文件。",
"The import stopped": "导入已停止",
"The import stopped making progress": "导入不再有进展",
":imported of :total files were imported before it failed: :error": "失败前已导入 :imported / :total 个文件::error",
":imported of :total files were imported. The queue worker may not be running.": "已导入 :imported / :total 个文件。后台进程可能没有在运行。",
"Choose Import all again to continue with the rest.": "再次选择“全部导入”即可继续导入剩余文件。",
"An import is already running. Wait for it to finish.": "已有一个导入正在进行。请等它完成。",
"Importing :count file in the background.|Importing :count files in the background.": "正在后台导入 :count 个文件。|正在后台导入 :count 个文件。",
":width × :height pixels": ":width × :height 像素",
"A link was sent a moment ago. Check your email, or try again in a minute.": "刚刚已发送过链接。请查看你的邮箱,或一分钟后再试。",
"Ask for a link by email to set your first password.": "请通过邮件获取链接来设置你的第一个密码。",
"Change your own email address and password from your profile.": "请在你的个人资料中修改自己的邮箱地址和密码。",
"Crop": "裁剪",
"Crop logo": "裁剪标志",
"Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.": "拖动方框及其四角,选择要显示的图片部分。上传的图片会保留,你之后可以随时更改。",
"Email me a link": "给我发送链接",
"Go to your profile": "前往个人资料",
"Logo cropped.": "标志已裁剪。",
"Original logo restored.": "已恢复原始标志。",
"Remove your own two-factor authentication from your profile.": "请在你的个人资料中移除自己的两步验证。",
"Restore original": "恢复原图",
"Save crop": "保存裁剪",
"The crop must stay inside the image.": "裁剪区域必须在图片之内。",
"This folder is not empty. Send content_action=cascade_delete to delete it with everything inside it.": "此文件夹不是空的。发送 content_action=cascade_delete 可将其连同所有内容一并删除。",
"This image is too large to crop. Upload a smaller one.": "这张图片太大,无法裁剪。请上传一张更小的图片。",
"This logo cannot be cropped. Upload it again.": "无法裁剪此标志。请重新上传。",
"This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.": "此令牌可以为它有权编辑的账户设置密码并移除两步验证,因此持有它的任何人都能以这些账户的身份登录。只把这些权限授予你愿意把这些账户本身托付给的人。",
"Upload a logo before cropping it.": "请先上传标志再裁剪。",
"We sent a link to your email address. It works for one hour.": "我们已向你的邮箱发送了链接,有效期为一小时。",
"We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.": "我们会通过邮件给你发送设置密码的链接。打开链接后,你在所有地方都会被登出,之后请用新密码重新登录。",
"Username attribute (optional)": "用户名属性(可选)",
"Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.": "除邮箱地址外,还允许使用目录中的用户名登录,例如 uid、cn 或 sAMAccountName。留空则只能用邮箱地址登录。",
"Email or username": "邮箱或用户名",
"Enter your email or username and password below to log in": "在下方输入邮箱或用户名和密码即可登录",
"Set your password": "设置你的密码",
"Use the button below to choose a password for your account. Until now you have signed in through a connected account, such as Google or Microsoft.": "请点击下方按钮为你的账户设置密码。此前你一直通过 Google 或 Microsoft 等关联账户登录。",
"This link will expire in :count minutes.": "此链接将在 :count 分钟后失效。",
"If you did not ask for this, no further action is required: you can keep signing in the way you do now.": "如果这不是你本人的请求,无需任何操作:你可以继续像现在这样登录。"
}
+25 -15
View File
@@ -47,6 +47,7 @@
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-image-crop": "^11.1.2",
"recharts": "^3.10.1",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
@@ -3376,9 +3377,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4027,9 +4028,9 @@
}
},
"node_modules/axios": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@@ -4058,9 +4059,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7278,6 +7279,15 @@
"react": "^19.0.0"
}
},
"node_modules/react-image-crop": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz",
"integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==",
"license": "ISC",
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -7777,9 +7787,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
"integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -7861,9 +7871,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
+1
View File
@@ -65,6 +65,7 @@
"lucide-react": "^0.475.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-image-crop": "^11.1.2",
"recharts": "^3.10.1",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.0",
+5
View File
@@ -1,5 +1,10 @@
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';
@@ -1,4 +1,5 @@
import InputError from '@/components/input-error';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
@@ -129,6 +130,20 @@ export function ApiTokenForm({ values, setValue, errors, availableAbilities, max
})}
</div>
{/* Both reach other people's sign-in: setting a password and
removing a second factor are how an administrator lets a
locked-out person back in, so a token holding either can
become the accounts it may edit. */}
{(values.abilities.includes('edit_clients') || values.abilities.includes('edit_users')) && (
<Alert>
<AlertDescription>
{t(
'This token can set passwords and remove two-factor authentication on the accounts it may edit, so whoever holds it can sign in as those accounts. Choose these abilities only for a holder you would trust with the accounts themselves.',
)}
</AlertDescription>
</Alert>
)}
<InputError message={errors.abilities ?? errors['abilities.0']} />
</div>
+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} />
@@ -0,0 +1,154 @@
import { router } from '@inertiajs/react';
import { useState } from 'react';
import ReactCrop, { type PercentCrop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useTranslation } from '@/hooks/use-translation';
export interface LogoCropBox {
x: number;
y: number;
width: number;
height: number;
}
interface LogoCropDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The uploaded image, never a previous crop of it. */
sourceUrl: string;
/** The last crop, in the upload's pixels, or null to start from the whole image. */
savedCrop: LogoCropBox | null;
}
const WHOLE: PercentCrop = { unit: '%', x: 0, y: 0, width: 100, height: 100 };
/**
* Draw a box on the uploaded logo and keep only that part of it.
*
* The box is held in percent while it is being drawn, so it survives the
* dialog resizing, and turned into the upload's own pixels only when it is
* saved. The server cuts the new file from the upload: nothing is cropped
* in the browser.
*
* `image-orientation: none` shows the stored pixels exactly as the server
* reads them. A browser would otherwise rotate a phone photo by its
* orientation tag, the server cannot, and the box would land on the wrong
* part of the picture.
*/
export default function LogoCropDialog({ open, onOpenChange, sourceUrl, savedCrop }: LogoCropDialogProps) {
const { t } = useTranslation();
const [crop, setCrop] = useState<PercentCrop>(WHOLE);
const [natural, setNatural] = useState<{ width: number; height: number } | null>(null);
const [error, setError] = useState<string | undefined>();
const [saving, setSaving] = useState(false);
const onImageLoad = (image: HTMLImageElement) => {
const width = image.naturalWidth;
const height = image.naturalHeight;
setNatural({ width, height });
setError(undefined);
setCrop(
savedCrop === null
? WHOLE
: {
unit: '%',
x: (savedCrop.x / width) * 100,
y: (savedCrop.y / height) * 100,
width: (savedCrop.width / width) * 100,
height: (savedCrop.height / height) * 100,
},
);
};
const toPixels = (box: PercentCrop, size: { width: number; height: number }): LogoCropBox => {
const x = Math.max(0, Math.round((box.x / 100) * size.width));
const y = Math.max(0, Math.round((box.y / 100) * size.height));
return {
x,
y,
width: Math.max(1, Math.min(size.width - x, Math.round((box.width / 100) * size.width))),
height: Math.max(1, Math.min(size.height - y, Math.round((box.height / 100) * size.height))),
};
};
const save = () => {
if (natural === null || crop.width === 0 || crop.height === 0) {
return;
}
setSaving(true);
router.patch(
route('branding.logo.crop'),
{ ...toPixels(crop, natural) },
{
preserveScroll: true,
onSuccess: () => onOpenChange(false),
onError: (errors) => setError(errors.logo ?? errors.width ?? errors.x ?? Object.values(errors)[0]),
onFinish: () => setSaving(false),
},
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('Crop logo')}</DialogTitle>
<DialogDescription>
{t(
'Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.',
)}
</DialogDescription>
</DialogHeader>
<div className="bg-muted/40 flex justify-center rounded border p-2">
{/* The height limit goes on the crop wrapper: the library's
stylesheet gives the image `max-height: inherit`, so a
limit on the image itself is overridden, and a tall logo
would push the bottom handles out of reach. */}
<ReactCrop
crop={crop}
onChange={(_, percent) => setCrop(percent)}
keepSelection
minWidth={8}
minHeight={8}
style={{ maxHeight: '56vh' }}
>
<img src={sourceUrl} alt="" onLoad={(e) => onImageLoad(e.currentTarget)} style={{ imageOrientation: 'none' }} />
</ReactCrop>
</div>
{natural !== null && (
<p className="text-muted-foreground text-sm">
{(() => {
const box = toPixels(crop, natural);
return t(':width × :height pixels', { width: String(box.width), height: String(box.height) });
})()}
</p>
)}
<InputError message={error} />
<DialogFooter>
<Button variant="outline" onClick={() => setCrop(WHOLE)} disabled={saving}>
{t('Select all')}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{t('Cancel')}
</Button>
<Button onClick={save} disabled={saving || natural === null}>
{t('Save crop')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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">
+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}>
+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';
+56 -27
View File
@@ -1,10 +1,11 @@
import { Link } from '@inertiajs/react';
import { X } from 'lucide-react';
import InputError from '@/components/input-error';
import { PasswordRequirements } from '@/components/password-requirements';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { PasswordRequirements } from '@/components/password-requirements';
import { useTranslation } from '@/hooks/use-translation';
export interface AssignableRole {
@@ -31,6 +32,12 @@ interface UserFormProps {
assignedClients: number[];
onAssignedClientsChange: (ids: number[]) => void;
passwordOptional: boolean;
/**
* Editing your own account: the email address and password are changed
* from your profile, which asks for your current password first, so
* this form shows the address and leaves both alone.
*/
ownAccount?: boolean;
errors: Partial<Record<string, string>>;
}
@@ -46,6 +53,7 @@ export function UserForm({
assignedClients,
onAssignedClientsChange,
passwordOptional,
ownAccount = false,
errors,
}: UserFormProps) {
const { t } = useTranslation();
@@ -62,7 +70,23 @@ export function UserForm({
<div className="grid gap-2">
<Label htmlFor="email">{t('Email address')}</Label>
<Input id="email" type="email" value={email} onChange={(e) => onChange('email', e.target.value)} required autoComplete="off" />
<Input
id="email"
type="email"
value={email}
onChange={(e) => onChange('email', e.target.value)}
required
readOnly={ownAccount}
autoComplete="off"
/>
{ownAccount && (
<p className="text-muted-foreground text-sm">
{t('Change your own email address and password from your profile.')}{' '}
<Link href={route('profile.edit')} className="underline underline-offset-4">
{t('Go to your profile')}
</Link>
</p>
)}
<InputError message={errors.email} />
</div>
@@ -92,32 +116,37 @@ export function UserForm({
/>
)}
<div className="grid gap-2">
<Label htmlFor="password">{passwordOptional ? t('New password (leave blank to keep current)') : t('Password')}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => onChange('password', e.target.value)}
required={!passwordOptional}
autoComplete="new-password"
/>
<PasswordRequirements />
<InputError message={errors.password} />
</div>
{!ownAccount && (
<>
<div className="grid gap-2">
<Label htmlFor="password">{passwordOptional ? t('New password (leave blank to keep current)') : t('Password')}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => onChange('password', e.target.value)}
required={!passwordOptional}
autoComplete="new-password"
/>
<PasswordRequirements />
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">{t('Confirm password')}</Label>
<Input
id="password_confirmation"
type="password"
value={passwordConfirmation}
onChange={(e) => onChange('password_confirmation', e.target.value)}
required={!passwordOptional || password !== ''}
autoComplete="new-password"
/>
<InputError message={errors.password_confirmation} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">{t('Confirm password')}</Label>
<Input
id="password_confirmation"
type="password"
value={passwordConfirmation}
onChange={(e) => onChange('password_confirmation', e.target.value)}
required={!passwordOptional || password !== ''}
autoComplete="new-password"
/>
<InputError message={errors.password_confirmation} />
</div>
</>
)}
{ownAccount && <InputError message={errors.password} />}
</div>
);
}
+10 -1
View File
@@ -52,17 +52,26 @@ export function usePortalFiles({ folder, search, category, owner, sort, directio
};
const downloadSelectionAsZip = () => zip.start({ file_ids: [...selectedFileIds], folder_ids: [...selectedFolderIds] });
// The open folder goes along with every change, or re-sorting inside a
// folder navigated back to the top of My files. Search and the filters
// still show a flat list across every folder (MyFilesController), and
// clearing them comes back to the folder.
const { values, set, setMany, reset } = useListQuery(
'my-files.index',
{ search, category: category === null ? ALL : String(category), owner: owner ?? ALL, sort, direction },
{ search: '', category: ALL, owner: ALL, sort: 'date', direction: 'desc' },
folder !== null ? { folder: folder.id } : undefined,
);
// Sort/direction always have a concrete value (there's no "unset" sort),
// so they're excluded here — only search/category/owner count as active
// filters worth surfacing a "Clear" button for.
const hasFilters = values.search !== '' || values.category !== ALL || values.owner !== ALL;
const folderUrl = (id: number | null) => (id === null ? route('my-files.index') : route('my-files.index', { folder: id }));
// Opening a folder keeps the chosen sort. Only the sort: opening a folder
// means leaving a search, so the filters are not carried. The default
// (newest first) is left out to keep the address clean.
const sortParams = values.sort === 'date' && values.direction === 'desc' ? {} : { sort: values.sort, direction: values.direction };
const folderUrl = (id: number | null) => route('my-files.index', id === null ? sortParams : { folder: id, ...sortParams });
return {
zip,
@@ -35,12 +35,13 @@ 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: 80px tall and up to
240px wide, so a square logo is shown at a size
that reads (it was 48px) and a wide one still
{/* 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-20 w-auto max-w-60 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" />
)}
+12 -5
View File
@@ -26,9 +26,11 @@ interface LoginProps {
status?: string;
canResetPassword: boolean;
canRegister: boolean;
/** A directory username attribute is configured, so the field also takes a username. */
usernameSignIn: boolean;
}
export default function Login({ status, canResetPassword, canRegister }: LoginProps) {
export default function Login({ status, canResetPassword, canRegister, usernameSignIn }: LoginProps) {
const { t } = useTranslation();
const { flash } = usePage<SharedData>().props;
const captcha = useRef<CaptchaHandle>(null);
@@ -59,7 +61,12 @@ export default function Login({ status, canResetPassword, canRegister }: LoginPr
};
return (
<AuthLayout title={t('Log in to your account')} description={t('Enter your email and password below to log in')}>
<AuthLayout
title={t('Log in to your account')}
description={
usernameSignIn ? t('Enter your email or username and password below to log in') : t('Enter your email and password below to log in')
}
>
<Head title={t('Log in')} />
{/* The app-wide Toaster lives in the authenticated layout, so a
@@ -75,14 +82,14 @@ export default function Login({ status, canResetPassword, canRegister }: LoginPr
<form className="flex flex-col gap-6" onSubmit={submit}>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">{t('Email address')}</Label>
<Label htmlFor="email">{usernameSignIn ? t('Email or username') : t('Email address')}</Label>
<Input
id="email"
type="email"
type={usernameSignIn ? 'text' : 'email'}
required
autoFocus
tabIndex={1}
autoComplete="email"
autoComplete={usernameSignIn ? 'username' : 'email'}
value={data.email}
onChange={(e) => setData('email', e.target.value)}
placeholder="email@example.com"
+39 -6
View File
@@ -2,6 +2,7 @@ import { type BreadcrumbItem, type SharedData } from '@/types';
import { Head, useForm, usePage } from '@inertiajs/react';
import { FormEventHandler, useEffect, useRef, useState } from 'react';
import LogoCropDialog, { type LogoCropBox } from '@/components/branding/logo-crop-dialog';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
@@ -22,6 +23,9 @@ interface Watermark {
interface BrandingEditProps {
logo_url: string | null;
logo_source_url: string | null;
logo_crop: LogoCropBox | null;
logo_cropped: boolean;
hide_attribution: boolean;
show_site_name: boolean;
watermark: Watermark;
@@ -30,7 +34,7 @@ interface BrandingEditProps {
type Tab = 'logo' | 'watermark' | 'attribution';
export default function BrandingEdit({ logo_url, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) {
export default function BrandingEdit({ logo_url, logo_source_url, logo_crop, logo_cropped, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) {
const { t } = useTranslation();
const { capabilities } = usePage<SharedData>().props;
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -135,6 +139,14 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
removeForm.delete(route('branding.destroy'), { preserveScroll: true, preserveState: true });
};
// Cropping is optional: an upload is used whole until somebody crops it.
const [cropping, setCropping] = useState(false);
const restoreForm = useForm({});
const restore = () => {
restoreForm.delete(route('branding.logo.restore'), { preserveScroll: true, preserveState: true });
};
const submitWatermark: FormEventHandler = (e) => {
e.preventDefault();
watermarkForm.post(route('branding.watermark.update'), {
@@ -204,7 +216,7 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
onChange={(e) => uploadForm.setData('logo', e.target.files?.[0] ?? null)}
/>
<p className="text-muted-foreground text-sm">
{t('Shown up to 240 × 80 pixels, so a wide logo and a square one both fit. PNG, JPG, GIF or WebP, up to 2 MB.')}
{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}>
@@ -212,10 +224,31 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam
</Button>
</form>
{logo_url && (
<Button variant="outline" onClick={remove} disabled={removeForm.processing}>
{t('Remove logo')}
</Button>
{logo_url && logo_source_url && (
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" onClick={() => setCropping(true)}>
{t('Crop')}
</Button>
{logo_cropped && (
<Button variant="outline" onClick={restore} disabled={restoreForm.processing}>
{t('Restore original')}
</Button>
)}
<Button variant="outline" onClick={remove} disabled={removeForm.processing}>
{t('Remove logo')}
</Button>
</div>
)}
{logo_source_url && (
// Keyed by the upload so a new logo opens on a fresh box.
<LogoCropDialog
key={logo_source_url}
open={cropping}
onOpenChange={setCropping}
sourceUrl={logo_source_url}
savedCrop={logo_crop}
/>
)}
<form onSubmit={submitSiteName} className="space-y-4 border-t pt-6">
Binary file not shown.
+31 -2
View File
@@ -10,6 +10,7 @@ import { SaveButton } from '@/components/save-button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PasswordRequirements } from '@/components/password-requirements';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/use-translation';
interface PasswordProps {
@@ -17,9 +18,11 @@ interface PasswordProps {
has_local_password: boolean;
/** True for an account whose password lives in a directory, which this screen cannot change. */
managed_elsewhere: boolean;
/** 'password-link-sent' once the link for a first password has been emailed. */
status?: string;
}
export default function Password({ has_local_password, managed_elsewhere }: PasswordProps) {
export default function Password({ has_local_password, managed_elsewhere, status }: PasswordProps) {
const { t } = useTranslation();
const breadcrumbs: BreadcrumbItem[] = [
@@ -38,6 +41,17 @@ export default function Password({ has_local_password, managed_elsewhere }: Pass
password_confirmation: '',
});
// An account that signs in through a provider gets its first password
// from a link emailed to its own address, not from this form: the
// session alone must not be able to choose it.
const needsLink = !has_local_password && !managed_elsewhere;
const linkForm = useForm({});
const sendLink: FormEventHandler = (e) => {
e.preventDefault();
linkForm.post(route('password.link'), { preserveScroll: true });
};
const updatePassword: FormEventHandler = (e) => {
e.preventDefault();
@@ -79,7 +93,22 @@ export default function Password({ has_local_password, managed_elsewhere }: Pass
</p>
)}
<form onSubmit={updatePassword} className="space-y-6" hidden={managed_elsewhere}>
{needsLink && (
<form onSubmit={sendLink} className="space-y-4">
<p className="text-muted-foreground text-sm">
{t('We will email you a link to set it. Opening the link signs you out everywhere, so sign in again with your new password afterwards.')}
</p>
<Button type="submit" disabled={linkForm.processing}>
{t('Email me a link')}
</Button>
{status === 'password-link-sent' && (
<p className="text-sm font-medium text-green-600">{t('We sent a link to your email address. It works for one hour.')}</p>
)}
<InputError message={(linkForm.errors as Partial<Record<'link', string>>).link} />
</form>
)}
<form onSubmit={updatePassword} className="space-y-6" hidden={managed_elsewhere || needsLink}>
<div className="grid gap-2" hidden={!has_local_password}>
<Label htmlFor="current_password">{t('Current password')}</Label>
@@ -33,6 +33,7 @@ interface LdapSettings {
user_filter: string | null;
email_attribute: string;
name_attribute: string;
username_attribute: string | null;
auto_provision: boolean;
auto_approve: boolean;
}
@@ -68,6 +69,7 @@ export default function LdapSettingsPage({ ldap, encryptions, extension_availabl
user_filter: ldap.user_filter ?? '',
email_attribute: ldap.email_attribute,
name_attribute: ldap.name_attribute,
username_attribute: ldap.username_attribute ?? '',
auto_provision: ldap.auto_provision,
auto_approve: ldap.auto_approve,
});
@@ -268,6 +270,23 @@ export default function LdapSettingsPage({ ldap, encryptions, extension_availabl
<InputError message={form.errors.name_attribute} />
</div>
<div className="grid gap-2">
<Label htmlFor="username_attribute">{t('Username attribute (optional)')}</Label>
<Input
id="username_attribute"
className="max-w-64"
value={form.data.username_attribute}
placeholder="uid"
onChange={(e) => form.setData('username_attribute', e.target.value)}
/>
<p className="text-muted-foreground text-sm">
{t(
'Lets people sign in with their directory username as well as their address: uid, cn or sAMAccountName, for example. Leave it empty to sign in by address only.',
)}
</p>
<InputError message={form.errors.username_attribute} />
</div>
<div className="grid gap-2">
<Label htmlFor="user_filter">{t('Additional filter (optional)')}</Label>
<Input
+1
View File
@@ -128,6 +128,7 @@ export default function UsersEdit({
assignedClients={data.assigned_clients}
onAssignedClientsChange={(ids) => setData('assigned_clients', ids)}
passwordOptional
ownAccount={is_self}
errors={errors}
/>
+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
+13 -6
View File
@@ -73,12 +73,6 @@ Route::middleware('guest')->group(function () {
->middleware('throttle:6,1,password-email')
->name('password.email');
Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('reset-password', [NewPasswordController::class, 'store'])
->middleware('throttle:6,1,password-reset')
->name('password.store');
Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'create'])
->name('two-factor.challenge');
@@ -87,6 +81,19 @@ Route::middleware('guest')->group(function () {
->middleware('throttle:6,1,two-factor');
});
// In neither group too. A reset link is also how an account that signs in
// through a provider sets its first password (PasswordController::sendLink),
// and its owner opens that link in the browser they are signed in with. The
// token is the authority here, not the session: it was emailed to the
// account's own address, so a signed-in visitor gains nothing a stranger
// holding the same link would not.
Route::get('reset-password/{token}', [NewPasswordController::class, 'create'])
->name('password.reset');
Route::post('reset-password', [NewPasswordController::class, 'store'])
->middleware('throttle:6,1,password-reset')
->name('password.store');
// Deliberately in neither group. Signing in through a provider must not
// require a session, and connecting one to an existing account requires
// exactly that — so the guard is the intent written into the session
+8
View File
@@ -62,6 +62,12 @@ Route::middleware('auth')->group(function () {
Route::put('settings/password', [PasswordController::class, 'update'])
->middleware('throttle:6,1,password-update')
->name('password.update');
// An account without a password asks for one by email instead: see
// PasswordController::sendLink. Its own bucket, and a small one, since
// each request sends an email.
Route::post('settings/password/link', [PasswordController::class, 'sendLink'])
->middleware('throttle:3,1,password-link')
->name('password.link');
Route::get('settings/two-factor', [TwoFactorEnrollmentController::class, 'show'])->name('two-factor.show');
@@ -292,6 +298,8 @@ Route::middleware('auth')->group(function () {
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');
Route::patch('system/settings/branding/logo/crop', [BrandingController::class, 'cropLogo'])->name('branding.logo.crop');
Route::delete('system/settings/branding/logo/crop', [BrandingController::class, 'restoreLogo'])->name('branding.logo.restore');
// POST rather than PATCH: the form carries a file, so it is
// multipart, and PHP only populates $_FILES for POST.
+1
View File
@@ -126,6 +126,7 @@ Route::middleware(['auth'])->group(function () {
Route::middleware(['staff', 'can:import_orphans'])->group(function () {
Route::get('files/orphans', [OrphanFilesController::class, 'index'])->name('orphan-files.index');
Route::post('files/orphans/import', [OrphanFilesController::class, 'import'])->name('orphan-files.import');
Route::get('files/orphans/import-status', [OrphanFilesController::class, 'importStatus'])->name('orphan-files.import-status');
Route::post('files/orphans/delete', [OrphanFilesController::class, 'destroy'])->name('orphan-files.delete');
});
+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);
});
+113 -2
View File
@@ -25,7 +25,7 @@ beforeEach(function () {
});
/**
* @param array<string, array{password: string, name?: string, dn?: string}> $entries
* @param array<string, array{password: string, name?: string, dn?: string, username?: string}> $entries
*/
function fakeDirectory(array $entries = []): FakeLdapDirectory
{
@@ -35,7 +35,7 @@ function fakeDirectory(array $entries = []): FakeLdapDirectory
return $fake;
}
function enableLdap(bool $autoProvision = false, bool $autoApprove = false): LdapSettings
function enableLdap(bool $autoProvision = false, bool $autoApprove = false, ?string $usernameAttribute = null): LdapSettings
{
$settings = LdapSettings::current();
$settings->forceFill([
@@ -44,6 +44,7 @@ function enableLdap(bool $autoProvision = false, bool $autoApprove = false): Lda
'base_dn' => 'dc=example,dc=test',
'auto_provision' => $autoProvision,
'auto_approve' => $autoApprove,
'username_attribute' => $usernameAttribute,
])->save();
return $settings;
@@ -512,3 +513,113 @@ test('a local account still confirms against its own hash, with LDAP on', functi
->assertRedirect()
->assertSessionHasNoErrors();
});
/*
|--------------------------------------------------------------------------
| Signing in with a directory username
|--------------------------------------------------------------------------
*/
test('a client signs in with their directory username', function () {
enableLdap(usernameAttribute: 'uid');
$fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass', 'username' => 'someone']]);
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
$this->post('/login', ['email' => 'someone', 'password' => 'directory-pass'])->assertRedirect();
$this->assertAuthenticatedAs($client);
expect($fake->lookedUpUsernames)->toBe(['someone'])
->and($fake->attemptedEmails)->toBe(['someone@example.test']);
});
test('a username signs in a client the directory has not met yet, when auto-provisioning is on', function () {
enableLdap(autoProvision: true, autoApprove: true, usernameAttribute: 'uid');
fakeDirectory(['newcomer@example.test' => ['password' => 'directory-pass', 'username' => 'newcomer', 'name' => 'New Comer']]);
$this->post('/login', ['email' => 'newcomer', 'password' => 'directory-pass'])->assertRedirect();
$user = User::query()->where('email', 'newcomer@example.test')->sole();
expect($user->isClient())->toBeTrue()
->and($user->auth_source)->toBe(AuthSource::Ldap);
$this->assertAuthenticatedAs($user);
});
test('a username never signs in a staff account, even with its own password', function () {
enableLdap(usernameAttribute: 'uid');
fakeDirectory(['admin@example.test' => ['password' => 'directory-pass', 'username' => 'admin']]);
User::factory()->create(['email' => 'admin@example.test']);
$this->post('/login', ['email' => 'admin', 'password' => 'password'])->assertSessionHasErrors('email');
$this->post('/login', ['email' => 'admin', 'password' => 'directory-pass'])->assertSessionHasErrors('email');
$this->assertGuest();
});
test('a wrong password with a valid username is refused', function () {
enableLdap(usernameAttribute: 'uid');
fakeDirectory(['someone@example.test' => ['password' => 'directory-pass', 'username' => 'someone']]);
User::factory()->client()->create(['email' => 'someone@example.test']);
$this->post('/login', ['email' => 'someone', 'password' => 'wrong'])
->assertSessionHasErrors(['email' => __('auth.failed')]);
$this->assertGuest();
});
test('an unknown username gets the same failure as a wrong password, and counts toward the limit', function () {
enableLdap(usernameAttribute: 'uid');
fakeDirectory();
foreach (range(1, 5) as $_) {
$this->post('/login', ['email' => 'nobody', 'password' => 'whatever'])
->assertSessionHasErrors(['email' => __('auth.failed')]);
}
$this->post('/login', ['email' => 'nobody', 'password' => 'whatever'])
->assertSessionHasErrors('email');
expect(session('errors')->first('email'))->not->toBe(__('auth.failed'));
});
test('a username is only accepted once a username attribute is set', function () {
enableLdap();
$fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass', 'username' => 'someone']]);
User::factory()->client()->create(['email' => 'someone@example.test']);
$this->post('/login', ['email' => 'someone', 'password' => 'directory-pass'])->assertSessionHasErrors('email');
expect($fake->calls)->toBe(0);
$this->assertGuest();
});
test('an email address still signs in by email with username sign-in on', function () {
enableLdap(usernameAttribute: 'uid');
$fake = fakeDirectory(['someone@example.test' => ['password' => 'directory-pass', 'username' => 'someone']]);
$client = User::factory()->client()->create(['email' => 'someone@example.test']);
$this->post('/login', ['email' => 'someone@example.test', 'password' => 'directory-pass'])->assertRedirect();
$this->assertAuthenticatedAs($client);
expect($fake->lookedUpUsernames)->toBe([]);
});
test('the login page offers username sign-in only when it is configured', function () {
$this->get('/login')->assertInertia(fn ($page) => $page->where('usernameSignIn', false));
enableLdap(usernameAttribute: 'uid');
$this->get('/login')->assertInertia(fn ($page) => $page->where('usernameSignIn', true));
});
// Decided by the same rule that admitted the address, or an account whose
// address the `email` rule accepts and filter_var does not (a dotless
// domain, say) would be taken for a username and locked out.
test('an address the email rule accepts is still an address with username sign-in on', function () {
enableLdap(usernameAttribute: 'uid');
$fake = fakeDirectory();
$client = User::factory()->client()->create(['email' => 'someone@localhost']);
$this->post('/login', ['email' => 'someone@localhost', 'password' => 'password'])->assertRedirect();
$this->assertAuthenticatedAs($client);
expect($fake->lookedUpUsernames)->toBe([]);
});
+234
View File
@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
use App\Modules\Platform\Branding\Models\BrandingSetting;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
Storage::fake('public');
$this->actingAs(staffWithPermissions(['edit_settings']));
});
/**
* A 200 × 100 image, red on the left half and blue on the right, so a test
* can tell which part of it a crop actually kept.
*/
function twoColourLogo(string $format = 'png'): UploadedFile
{
$image = imagecreatetruecolor(200, 100);
imagefilledrectangle($image, 0, 0, 99, 99, imagecolorallocate($image, 255, 0, 0));
imagefilledrectangle($image, 100, 0, 199, 99, imagecolorallocate($image, 0, 0, 255));
$temp = tempnam(sys_get_temp_dir(), 'logo');
$format === 'jpg' ? imagejpeg($image, $temp, 100) : imagepng($image, $temp);
return new UploadedFile($temp, "logo.{$format}", $format === 'jpg' ? 'image/jpeg' : 'image/png', null, true);
}
/** The colour of one pixel of a stored logo, as [r, g, b]. */
function logoPixel(string $path, int $x, int $y): array
{
$image = imagecreatefromstring(Storage::disk('public')->get($path));
$rgb = imagecolorsforindex($image, imagecolorat($image, $x, $y));
return [$rgb['red'], $rgb['green'], $rgb['blue']];
}
function uploadTwoColourLogo(string $format = 'png'): string
{
test()->post(route('branding.store'), ['logo' => twoColourLogo($format)])->assertRedirect();
return BrandingSetting::query()->sole()->logo_path;
}
test('an uploaded logo is used whole until somebody crops it', function () {
$upload = uploadTwoColourLogo();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_original_path)->toBeNull()
->and($setting->logo_crop)->toBeNull()
->and($setting->logoSourcePath())->toBe($upload);
});
test('cropping keeps the part of the picture the box was drawn on', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
->assertRedirect()
->assertSessionHasNoErrors();
$setting = BrandingSetting::query()->sole();
$size = getimagesizefromstring(Storage::disk('public')->get($setting->logo_path));
expect($setting->logo_path)->not->toBe($upload)
->and($setting->logo_original_path)->toBe($upload)
->and($setting->logo_crop)->toBe(['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
->and([$size[0], $size[1]])->toBe([100, 100])
->and(logoPixel($setting->logo_path, 5, 50))->toBe([0, 0, 255])
->and(logoPixel($setting->logo_path, 95, 50))->toBe([0, 0, 255]);
// The upload itself is untouched.
Storage::disk('public')->assertExists($upload);
expect(logoPixel($upload, 5, 50))->toBe([255, 0, 0]);
});
test('cropping again starts from the upload, not from the last crop', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$firstCrop = BrandingSetting::query()->sole()->logo_path;
// The red half is not in the first crop at all; it is in the upload.
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 50, 'height' => 50])->assertSessionHasNoErrors();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_original_path)->toBe($upload)
->and(logoPixel($setting->logo_path, 10, 10))->toBe([255, 0, 0]);
Storage::disk('public')->assertMissing($firstCrop);
});
test('restoring puts the upload back and deletes the crop', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$crop = BrandingSetting::query()->sole()->logo_path;
$this->delete(route('branding.logo.restore'))->assertRedirect();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_path)->toBe($upload)
->and($setting->logo_original_path)->toBeNull()
->and($setting->logo_crop)->toBeNull();
Storage::disk('public')->assertMissing($crop);
Storage::disk('public')->assertExists($upload);
});
test('a box covering the whole picture is the same as restoring', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 200, 'height' => 100])->assertSessionHasNoErrors();
expect(BrandingSetting::query()->sole()->logo_path)->toBe($upload)
->and(Storage::disk('public')->allFiles('branding'))->toBe([$upload]);
});
test('a box reaching outside the picture is refused and changes nothing', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 150, 'y' => 0, 'width' => 100, 'height' => 100])
->assertSessionHasErrors('width');
$this->patch(route('branding.logo.crop'), ['x' => -1, 'y' => 0, 'width' => 10, 'height' => 10])
->assertSessionHasErrors('x');
expect(BrandingSetting::query()->sole()->logo_path)->toBe($upload)
->and(Storage::disk('public')->allFiles('branding'))->toBe([$upload]);
});
test('there is nothing to crop before a logo is uploaded', function () {
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])
->assertSessionHasErrors('logo');
});
test('a crop keeps the format of the upload', function () {
uploadTwoColourLogo('jpg');
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 100])->assertSessionHasNoErrors();
$path = BrandingSetting::query()->sole()->logo_path;
expect(pathinfo($path, PATHINFO_EXTENSION))->toBe('jpg')
->and(getimagesizefromstring(Storage::disk('public')->get($path))['mime'])->toBe('image/jpeg');
});
/*
* A small file can declare a huge picture. The size is read from the header
* and refused before GD is asked to hold the pixels.
*/
test('an image too large to hold in memory is refused before it is decoded', function () {
$ihdr = pack('NNCCCCC', 6000, 5000, 8, 2, 0, 0, 0);
$png = "\x89PNG\r\n\x1a\n"
.pack('N', 13).'IHDR'.$ihdr.pack('N', crc32('IHDR'.$ihdr))
.pack('N', 0).'IEND'.pack('N', crc32('IEND'));
Storage::disk('public')->put('branding/huge.png', $png);
BrandingSetting::current()->update(['logo_path' => 'branding/huge.png']);
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 100])
->assertSessionHasErrors('logo');
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
});
test('a new upload after a crop deletes both files and starts uncropped', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$crop = BrandingSetting::query()->sole()->logo_path;
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('new.png', 80, 80)]);
$setting = BrandingSetting::query()->sole();
expect($setting->logo_original_path)->toBeNull()
->and($setting->logo_crop)->toBeNull()
->and(Storage::disk('public')->allFiles('branding'))->toBe([$setting->logo_path]);
Storage::disk('public')->assertMissing($upload);
Storage::disk('public')->assertMissing($crop);
});
test('removing a cropped logo deletes both files', function () {
uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$this->delete(route('branding.destroy'))->assertRedirect();
$setting = BrandingSetting::query()->sole();
expect($setting->logo_path)->toBeNull()
->and($setting->logo_original_path)->toBeNull()
->and(Storage::disk('public')->allFiles('branding'))->toBe([]);
});
test('the screen opens the cropper on the upload, with the last box drawn', function () {
$upload = uploadTwoColourLogo();
$this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]);
$this->get(route('branding.edit'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('logo_source_url', Storage::disk('public')->url($upload))
->where('logo_crop', ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100])
->where('logo_cropped', true));
});
test('cropping and restoring need what the rest of the screen needs', function () {
uploadTwoColourLogo();
$this->actingAs(staffWithPermissions([]));
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])->assertForbidden();
$this->delete(route('branding.logo.restore'))->assertForbidden();
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
});
test('cropping and restoring 404 when the capability has been taken away', function () {
// Before any request: the capability set is worked out once, on the
// first one, as the other branding tests rely on.
config(['projectsend.capabilities_disabled' => 'branding.customize']);
Storage::disk('public')->put('branding/logo.png', twoColourLogo()->getContent());
BrandingSetting::current()->update(['logo_path' => 'branding/logo.png']);
$this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])->assertNotFound();
$this->delete(route('branding.logo.restore'))->assertNotFound();
expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull();
});
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Clients\Models\Invitation;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use Inertia\Testing\AssertableInertia;
/*
* A staff member limited to some clients may only invite into the groups
* those clients are in (InvitationController::create and store). The list
* of invitations and revoking one must stop at the same line: otherwise the
* list hands them every invitee's name and address, and revoking takes back
* invitations other people sent (GHSA-phv7-54fm-qh4r).
*
* Theirs are the invitations they sent, and the ones into a group within
* their reach. An unscoped staff member still sees and manages every one.
*/
beforeEach(function () {
$this->admin = User::factory()->create();
$role = Role::query()->create(['name' => 'Scoped inviter', 'client_scoped' => true]);
RolePermission::query()->create(['role_id' => $role->id, 'permission' => 'create_clients']);
$this->scoped = User::factory()->create(['role_id' => $role->id]);
$mine = User::factory()->client()->create();
$this->scoped->assignedClients()->sync([$mine->id]);
$this->myGroup = Group::query()->create(['name' => 'My clients']);
$this->myGroup->members()->attach($mine->id);
$this->otherGroup = Group::query()->create(['name' => 'Board']);
$this->sentByMe = Invitation::issue('mine@example.test', 'Mine', null, $this->scoped, now()->addDay());
$this->intoMyGroup = Invitation::issue('colleague@example.test', 'Into my group', $this->myGroup, $this->admin, now()->addDay());
$this->notMine = Invitation::issue('board@example.test', 'Board member', $this->otherGroup, $this->admin, now()->addDay());
$this->noGroup = Invitation::issue('nogroup@example.test', 'No group', null, $this->admin, now()->addDay());
});
test('a client-scoped staff member lists only the invitations within their reach', function () {
$this->actingAs($this->scoped)->get('/clients/invitations')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where(
'invitations',
fn ($rows) => collect($rows)->pluck('email')->sort()->values()->all() === ['colleague@example.test', 'mine@example.test'],
));
});
test('a client-scoped staff member cannot revoke an invitation outside their reach', function () {
$this->actingAs($this->scoped)->delete("/clients/invitations/{$this->notMine->id}")->assertNotFound();
$this->actingAs($this->scoped)->delete("/clients/invitations/{$this->noGroup->id}")->assertNotFound();
expect($this->notMine->fresh()->status)->toBe(Invitation::STATUS_PENDING)
->and($this->noGroup->fresh()->status)->toBe(Invitation::STATUS_PENDING);
});
test('a client-scoped staff member can still revoke their own, and one into their group', function () {
$this->actingAs($this->scoped)->delete("/clients/invitations/{$this->sentByMe->id}")->assertRedirect();
$this->actingAs($this->scoped)->delete("/clients/invitations/{$this->intoMyGroup->id}")->assertRedirect();
expect($this->sentByMe->fresh()->status)->toBe(Invitation::STATUS_REVOKED)
->and($this->intoMyGroup->fresh()->status)->toBe(Invitation::STATUS_REVOKED);
});
test('an unscoped staff member still sees and revokes every invitation', function () {
$this->actingAs($this->admin)->get('/clients/invitations')
->assertInertia(fn (AssertableInertia $page) => $page->has('invitations', 4));
$this->actingAs($this->admin)->delete("/clients/invitations/{$this->notMine->id}")->assertRedirect();
expect($this->notMine->fresh()->status)->toBe(Invitation::STATUS_REVOKED);
});
+102
View File
@@ -5,9 +5,12 @@ declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Jobs\ImportOrphanFilesJob;
use App\Modules\Files\Models\File;
use App\Modules\Files\OrphanImportProgress;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Testing\TestResponse;
use Inertia\Testing\AssertableInertia;
@@ -31,6 +34,23 @@ function makeAdoptedFile(User $uploader, string $path, string $disk = 'files'):
]);
}
/**
* 30 importable .txt files under 2026/07/batch, plus one empty and one
* restricted file there and one importable file outside it.
*/
function makeBatchOrphans(): void
{
app(Settings::class)->set(Setting::UploadTypeRestriction, 'all');
app(Settings::class)->set(Setting::AllowedUploadExtensions, ['txt']);
foreach (range(1, 30) as $i) {
makeOrphanFile(sprintf('2026/07/batch/%02d.txt', $i));
}
makeOrphanFile('2026/07/batch/empty.txt', '');
makeOrphanFile('2026/07/batch/shell.php');
makeOrphanFile('2026/07/elsewhere.txt');
}
function orphanImport(array $items): TestResponse
{
return test()->postJson('/files/orphans/import', ['items' => $items]);
@@ -215,6 +235,88 @@ test('importing multiple orphans at once stays on the list rather than picking o
}
});
test('import all queues a background run for every importable match instead of importing in the request', function () {
Queue::fake();
makeBatchOrphans();
$this->actingAs($this->admin)
->postJson('/files/orphans/import', ['all' => true, 'search' => 'batch'])
->assertRedirect();
expect(File::query()->count())->toBe(0);
Queue::assertPushed(ImportOrphanFilesJob::class, 1);
$this->getJson('/files/orphans/import-status')
->assertOk()
->assertJson(['status' => 'running', 'total' => 30, 'imported' => 0]);
});
test('the background run imports every match across as many chunks as it takes, then reports finished', function () {
makeBatchOrphans();
app(OrphanImportProgress::class)->tryStart(30);
// A zero budget makes every chunk stop after one file, so the run has
// to hand over to the next chunk 29 times to finish.
ImportOrphanFilesJob::dispatch($this->admin->id, 'batch', budgetSeconds: 0);
expect(File::query()->count())->toBe(30)
->and(File::query()->where('path', '2026/07/elsewhere.txt')->exists())->toBeFalse()
->and(File::query()->where('uploaded_by', $this->admin->id)->count())->toBe(30);
$this->actingAs($this->admin)->getJson('/files/orphans/import-status')
->assertJson(['status' => 'finished', 'total' => 30, 'imported' => 30]);
});
test('while a run is in progress no other import is accepted, so nothing is adopted twice', function () {
Queue::fake();
makeOrphanFile('2026/07/one.txt');
app(OrphanImportProgress::class)->tryStart(1);
$this->actingAs($this->admin);
orphanImport([['disk' => 'files', 'path' => '2026/07/one.txt']])->assertUnprocessable();
$this->postJson('/files/orphans/import', ['all' => true])->assertUnprocessable();
expect(File::query()->count())->toBe(0);
Queue::assertNothingPushed();
});
test('a run that stops making progress is reported as stalled and no longer blocks a new one', function () {
Queue::fake();
makeOrphanFile('2026/07/one.txt');
app(OrphanImportProgress::class)->tryStart(5);
$this->travel(6)->minutes();
$this->actingAs($this->admin)->getJson('/files/orphans/import-status')
->assertJson(['status' => 'stalled', 'total' => 5, 'imported' => 0]);
$this->postJson('/files/orphans/import', ['all' => true])->assertRedirect();
Queue::assertPushed(ImportOrphanFilesJob::class, 1);
});
test('a run whose job fails is reported as failed', function () {
app(OrphanImportProgress::class)->tryStart(5);
(new ImportOrphanFilesJob($this->admin->id, null))->failed(new RuntimeException('Disk unreachable'));
// The reason itself goes to the log, not the screen: see
// OrphanImportJobHardeningTest.
$this->actingAs($this->admin)->getJson('/files/orphans/import-status')
->assertJson(['status' => 'failed']);
});
test('import all with nothing importable queues nothing', function () {
Queue::fake();
makeOrphanFile('2026/07/empty.txt', '');
$this->actingAs($this->admin)->postJson('/files/orphans/import', ['all' => true])->assertRedirect();
Queue::assertNothingPushed();
$this->getJson('/files/orphans/import-status')->assertOk()->assertExactJson([]);
});
test('a 0-byte orphan cannot be imported but can still be deleted', function () {
makeOrphanFile('2026/07/empty.txt', '');
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Jobs\ImportOrphanFilesJob;
use App\Modules\Files\Models\File;
use App\Modules\Files\OrphanImportProgress;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/*
* The background "import all" runs as the staff member who started it, in
* chunks, for as long as it takes. Three edges of that, from the review of
* #1809.
*/
beforeEach(function () {
Storage::fake('files');
$this->staff = staffWithPermissions(['upload', 'import_orphans']);
makeOrphanFile('2026/10/one.txt');
makeOrphanFile('2026/10/two.txt');
app(OrphanImportProgress::class)->tryStart(2);
});
test('a chunk does not go on for an account that has lost the permission', function () {
$this->staff->role->permissions()->where('permission', 'import_orphans')->delete();
forgetRequestState();
(new ImportOrphanFilesJob($this->staff->id, null))->handle(
app(App\Modules\Files\OrphanFileScanner::class),
app(App\Modules\Files\OrphanFileImporter::class),
app(OrphanImportProgress::class),
);
expect(File::query()->count())->toBe(0)
->and(app(OrphanImportProgress::class)->current()['status'])->toBe('failed');
});
test('a chunk does not go on for an account that was deactivated', function () {
$this->staff->forceFill(['active' => false])->save();
ImportOrphanFilesJob::dispatch($this->staff->id, null);
expect(File::query()->count())->toBe(0)
->and(app(OrphanImportProgress::class)->current()['status'])->toBe('failed');
});
test('an account that keeps the permission still imports everything', function () {
ImportOrphanFilesJob::dispatch($this->staff->id, null);
expect(File::query()->count())->toBe(2)
->and(app(OrphanImportProgress::class)->current()['status'])->toBe('finished');
});
test('a path another chunk is adopting is left to it, and never adopted twice', function () {
// Another chunk holds 2026/10/one.txt right now.
$held = Cache::lock('orphan-files-import:'.sha1('files|2026/10/one.txt'), 600);
expect($held->get())->toBeTrue();
ImportOrphanFilesJob::dispatch($this->staff->id, null);
expect(File::query()->pluck('path')->all())->toBe(['2026/10/two.txt']);
$held->release();
});
test('a path that gained a row after the scan is skipped', function () {
// Adopted by someone else between this chunk's scan and its import.
$scanner = Mockery::mock(App\Modules\Files\OrphanFileScanner::class);
$scanner->shouldReceive('importable')->andReturn([['disk' => 'files', 'path' => '2026/10/one.txt']]);
File::factory()->create(['disk' => 'files', 'path' => '2026/10/one.txt', 'uploaded_by' => $this->staff->id]);
(new ImportOrphanFilesJob($this->staff->id, null))->handle(
$scanner,
app(App\Modules\Files\OrphanFileImporter::class),
app(OrphanImportProgress::class),
);
expect(File::query()->where('path', '2026/10/one.txt')->count())->toBe(1);
});
test('a failure shows a plain message and keeps the details for the log', function () {
Log::spy();
(new ImportOrphanFilesJob($this->staff->id, null))
->failed(new RuntimeException('Error executing "PutObject" on "https://bucket.s3.example/secret-path"'));
$error = app(OrphanImportProgress::class)->current()['error'];
expect($error)->not->toContain('bucket')->not->toContain('PutObject');
Log::shouldHaveReceived('error')->once();
});
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Storage;
/*
* The storage layer reads several spellings as one file: "./a/b.txt",
* "a/./b.txt", "a//b.txt", "/a/b.txt", "a\b.txt" and "a/x/../b.txt" all
* resolve to "a/b.txt". The orphan check compared the spelling it was
* given against the paths file rows hold, so any of them made a tracked
* file look like an orphan: deleting it removed another person's bytes
* without delete_others_files, and importing it put a second row on them
* (GHSA-pv88-7863-5hwq). The same spellings walked past the exclusion of
* derived-artifact folders.
*
* A spelling the storage layer would rewrite is refused. The scan only
* ever offers paths as storage lists them, so nothing honest sends one.
*/
beforeEach(function () {
Storage::fake('files');
$this->owner = User::factory()->client()->create();
$this->tracked = File::factory()->create([
'uploaded_by' => $this->owner->id,
'path' => 'audit/client-b.txt',
'disk' => 'files',
'mime_type' => 'text/plain',
'size' => 11,
]);
Storage::disk('files')->put('audit/client-b.txt', 'hello-world');
$this->staff = staffWithPermissions(['upload', 'import_orphans']);
});
dataset('aliases of a tracked file', [
'leading dot' => './audit/client-b.txt',
'inner dot' => 'audit/./client-b.txt',
'double slash' => 'audit//client-b.txt',
'leading slash' => '/audit/client-b.txt',
'backslash' => 'audit\\client-b.txt',
'climb back' => 'audit/x/../client-b.txt',
]);
test('an alias of a tracked file cannot delete its bytes', function (string $alias) {
$this->actingAs($this->staff)->postJson('/files/orphans/delete', ['items' => [['disk' => 'files', 'path' => $alias]]]);
Storage::disk('files')->assertExists('audit/client-b.txt');
})->with('aliases of a tracked file');
test('an alias of a tracked file cannot be adopted as a second file', function (string $alias) {
$this->actingAs($this->staff)->postJson('/files/orphans/import', ['items' => [['disk' => 'files', 'path' => $alias]]]);
expect(File::query()->count())->toBe(1);
})->with('aliases of a tracked file');
test('an alias cannot reach into a derived-artifact folder either', function () {
Storage::disk('files')->put('thumbnails/2026/some.jpg', 'x');
$this->actingAs($this->staff)->postJson('/files/orphans/delete', ['items' => [['disk' => 'files', 'path' => './thumbnails/2026/some.jpg']]]);
Storage::disk('files')->assertExists('thumbnails/2026/some.jpg');
});
test('a real orphan is still deleted and imported', function () {
makeOrphanFile('audit/stray-a.txt');
makeOrphanFile('audit/stray-b.txt');
$this->actingAs($this->staff)->postJson('/files/orphans/delete', ['items' => [['disk' => 'files', 'path' => 'audit/stray-a.txt']]]);
$this->actingAs($this->staff)->postJson('/files/orphans/import', ['items' => [['disk' => 'files', 'path' => 'audit/stray-b.txt']]]);
Storage::disk('files')->assertMissing('audit/stray-a.txt');
expect(File::query()->where('path', 'audit/stray-b.txt')->exists())->toBeTrue();
});
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\Folder;
use App\Modules\Identity\Permissions\SystemRole;
use Inertia\Testing\AssertableInertia;
/**
* Two edges of the staff folder screens that the folder API made visible,
* because the API had to answer the same questions and answered them more
* strictly.
*/
beforeEach(function () {
$this->admin = User::factory()->create();
});
/*
* A client-scoped staff member can hold a client's folder that sits inside
* somebody else's tree. The trail above it named every folder on the way,
* including the ones their library does not show them. The client portal
* already trims the same trail (BreadcrumbBuilder::visible).
*/
test('a client-scoped staff member is not told the names of folders above their reach', function () {
$client = User::factory()->client()->create();
$manager = User::factory()->role(SystemRole::ClientManager)->create();
$manager->assignedClients()->sync([$client->id]);
$secret = makeFolder('Board minutes');
$acme = makeFolder('Acme', $secret);
$year = makeFolder('2026', $acme);
$this->actingAs($this->admin)->post("/folders/{$acme->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$this->actingAs($manager)->get("/files?folder={$year->id}")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('breadcrumb', [
['id' => $acme->id, 'name' => 'Acme'],
['id' => $year->id, 'name' => '2026'],
]));
$this->actingAs($manager)->get("/folders/{$acme->id}")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('breadcrumb', [
['id' => $acme->id, 'name' => 'Acme'],
]));
});
test('an unscoped staff member still sees the whole trail', function () {
$secret = makeFolder('Board minutes');
$acme = makeFolder('Acme', $secret);
$this->actingAs($this->admin)->get("/files?folder={$acme->id}")
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->where('breadcrumb', [
['id' => $secret->id, 'name' => 'Board minutes'],
['id' => $acme->id, 'name' => 'Acme'],
]));
});
/*
* A folder inside a public folder is public, so creating one there is
* placing something into a public folder — the question
* Folder::uploadableBy answers for every other write of a parent_id.
* Creation was the one write that did not ask it.
*/
test('a folder cannot be created inside a public folder without permission to publish', function () {
$public = makeFolder('Press kit');
$public->update(['public' => true]);
$staff = staffWithPermissions(['create_own_folders', 'upload', 'edit_others_files']);
$this->actingAs($staff)->post('/folders', ['name' => 'Drafts', 'parent_id' => $public->id])->assertForbidden();
expect(Folder::query()->where('name', 'Drafts')->exists())->toBeFalse();
});
test('with upload_public, creating inside a public folder still works', function () {
$public = makeFolder('Press kit');
$public->update(['public' => true]);
$staff = staffWithPermissions(['create_own_folders', 'upload', 'edit_others_files', 'upload_public']);
$this->actingAs($staff)->post('/folders', ['name' => 'Drafts', 'parent_id' => $public->id])->assertRedirect();
expect(Folder::query()->where('name', 'Drafts')->value('parent_id'))->toBe($public->id);
});
test('creating inside a private folder needs nothing extra', function () {
$private = makeFolder('Internal');
$staff = staffWithPermissions(['create_own_folders', 'upload', 'edit_others_files']);
$this->actingAs($staff)->post('/folders', ['name' => 'Drafts', 'parent_id' => $private->id])->assertRedirect();
expect(Folder::query()->where('name', 'Drafts')->value('parent_id'))->toBe($private->id);
});
@@ -190,3 +190,16 @@ test('settings are only usable once they are complete', function () {
$settings->forceFill(['active' => true, 'host' => 'h', 'base_dn' => 'b'])->save();
expect($settings->refresh()->usable())->toBe(extension_loaded('ldap'));
});
test('the username attribute is optional, saved, and cleared when left empty', function () {
expect(LdapSettings::current()->username_attribute)->toBeNull();
$this->actingAs($this->admin)->patch('/system/settings/ldap', ldapPayload(['username_attribute' => 'uid']))->assertRedirect();
expect(LdapSettings::current()->username_attribute)->toBe('uid');
$this->actingAs($this->admin)->get('/system/settings/ldap')
->assertInertia(fn (AssertableInertia $page) => $page->where('ldap.username_attribute', 'uid'));
$this->actingAs($this->admin)->patch('/system/settings/ldap', ldapPayload(['username_attribute' => '']))->assertRedirect();
expect(LdapSettings::current()->username_attribute)->toBeNull();
});
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\Permissions\SystemRole;
use Illuminate\Support\Facades\Hash;
/*
* Your own password, email address and second factor are changed from your
* profile, which asks for your current password first. The staff screens
* and the API must not be a second door to them: a token holding only
* manage_users and edit_users could otherwise reset its own owner and
* become a full browser session with abilities the token was never given,
* and a borrowed browser session could take the account for good without
* the password the profile asks for.
*
* Changing somebody *else's* credentials is what edit_users and
* edit_clients mean, on the web and over the API alike, and stays so.
*/
beforeEach(function () {
$this->admin = User::factory()->role(SystemRole::SystemAdministrator)->create(['password' => 'Original-pass-1!']);
$this->token = $this->admin->createToken('t', ['manage_users', 'edit_users'])->plainTextToken;
});
test('a token cannot set a new password for its own owner', function () {
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['password' => 'Picked-by-token-9!'])
->assertForbidden();
expect(Hash::check('Original-pass-1!', $this->admin->refresh()->password))->toBeTrue();
});
test('a token cannot change its own owner\'s email address', function () {
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['email' => 'elsewhere@example.test'])
->assertForbidden();
expect($this->admin->refresh()->email)->not->toBe('elsewhere@example.test');
});
test('a token cannot remove its own owner\'s second factor', function () {
// The factory's own password: enableTwoFactor() confirms with it.
$owner = User::factory()->role(SystemRole::SystemAdministrator)->create();
enableTwoFactor($owner);
forgetRequestState();
auth()->logout();
$token = $owner->createToken('t', ['manage_users', 'edit_users'])->plainTextToken;
$this->withToken($token)->deleteJson("/api/v1/users/{$owner->id}/two-factor")->assertForbidden();
expect($owner->refresh()->hasTwoFactorEnabled())->toBeTrue();
});
test('a token can still rename its own owner, and resend the same email address', function () {
$this->withToken($this->token)->patchJson("/api/v1/users/{$this->admin->id}", ['name' => 'Renamed', 'email' => $this->admin->email])
->assertOk()
->assertJsonPath('data.name', 'Renamed');
});
test('a token can still set another staff member\'s password, and that ends their tokens', function () {
$colleague = User::factory()->role(SystemRole::Uploader)->create();
$colleague->createToken('theirs', ['upload']);
$this->withToken($this->token)->patchJson("/api/v1/users/{$colleague->id}", ['password' => 'Reset-by-admin-9!'])
->assertOk();
expect(Hash::check('Reset-by-admin-9!', $colleague->refresh()->password))->toBeTrue()
->and($colleague->tokens()->count())->toBe(0);
});
test('renaming another staff member leaves their tokens alone', function () {
$colleague = User::factory()->role(SystemRole::Uploader)->create();
$colleague->createToken('theirs', ['upload']);
$this->withToken($this->token)->patchJson("/api/v1/users/{$colleague->id}", ['name' => 'New name'])->assertOk();
expect($colleague->tokens()->count())->toBe(1);
});
test('a token holding edit_clients can still reset a client it manages', function () {
$staff = staffWithPermissions(['edit_clients']);
$token = $staff->createToken('t', ['edit_clients'])->plainTextToken;
$client = User::factory()->client()->create();
$this->withToken($token)->patchJson("/api/v1/clients/{$client->id}", ['password' => 'Reset-by-staff-9!'])->assertOk();
expect(Hash::check('Reset-by-staff-9!', $client->refresh()->password))->toBeTrue();
});
/*
* The staff screen, editing yourself.
*/
function ownAccountPayload(User $user, array $overrides = []): array
{
return array_merge([
'name' => $user->name,
'email' => $user->email,
'role_id' => $user->role_id,
'active' => true,
'assigned_clients' => [],
], $overrides);
}
test('the staff screen does not change your own email address', function () {
$this->actingAs($this->admin)
->patch("/users/{$this->admin->id}", ownAccountPayload($this->admin, ['email' => 'elsewhere@example.test']))
->assertSessionHasErrors('email');
expect($this->admin->refresh()->email)->not->toBe('elsewhere@example.test');
});
test('the staff screen does not change your own password', function () {
$this->actingAs($this->admin)
->patch("/users/{$this->admin->id}", ownAccountPayload($this->admin, ['password' => 'Picked-here-9!', 'password_confirmation' => 'Picked-here-9!']))
->assertSessionHasErrors('password');
expect(Hash::check('Original-pass-1!', $this->admin->refresh()->password))->toBeTrue();
});
test('the staff screen still saves the rest of your own account', function () {
$this->actingAs($this->admin)
->patch("/users/{$this->admin->id}", ownAccountPayload($this->admin, ['name' => 'Renamed']))
->assertSessionHasNoErrors();
expect($this->admin->refresh()->name)->toBe('Renamed');
});
test('setting another staff member\'s password on the screen ends their tokens', function () {
$colleague = User::factory()->role(SystemRole::Uploader)->create();
$colleague->createToken('theirs', ['upload']);
$this->actingAs($this->admin)
->patch("/users/{$colleague->id}", ownAccountPayload($colleague, ['password' => 'Reset-by-admin-9!', 'password_confirmation' => 'Reset-by-admin-9!']))
->assertSessionHasNoErrors();
expect($colleague->tokens()->count())->toBe(0);
});
@@ -5,8 +5,12 @@ declare(strict_types=1);
use App\Models\User;
use App\Modules\Identity\AuthSource;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Identity\Notifications\ResetPasswordNotification;
use App\Modules\Identity\Social\SocialAccount;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use Inertia\Testing\AssertableInertia;
/**
@@ -21,32 +25,144 @@ use Inertia\Testing\AssertableInertia;
* confirm a password they do not have, and every other screen, including
* the one that would have given them one, redirected back. Reported by
* Ricardo Cazati.
*
* The first answer let the signed-in session choose the password with no
* proof at all, which made a stolen session permanent: the thief set a
* password, confirmed it, enrolled their own second factor and removed the
* owner's provider (GHSA-4r8h-mwfm-f5f4). The password now comes from a
* link emailed to the account's own address, through the same reset flow
* every account already has. The session asks for the link; the inbox
* answers it.
*/
function providerAccount(array $overrides = []): User
{
return User::factory()->create(array_merge(['auth_source' => AuthSource::Social], $overrides));
}
test('an account that signs in through a provider can set its first password', function () {
test('a provider account cannot set its first password from the session alone', function () {
$user = providerAccount();
$this->actingAs($user)
->from('/settings/password')
->put('/settings/password', [
'password' => 'a-password-of-my-own',
'password_confirmation' => 'a-password-of-my-own',
'password' => 'chosen-by-whoever-holds-the-session',
'password_confirmation' => 'chosen-by-whoever-holds-the-session',
])
->assertSessionHasErrors('password');
$user->refresh();
expect(Hash::check('chosen-by-whoever-holds-the-session', $user->password))->toBeFalse()
->and($user->auth_source)->toBe(AuthSource::Social);
});
test('a provider account asks for a link, and it goes to its own address', function () {
Notification::fake();
$user = providerAccount();
$this->actingAs($user)->from('/settings/password')->post('/settings/password/link')
->assertSessionHasNoErrors()
->assertRedirect('/settings/password');
Notification::assertSentTo($user, ResetPasswordNotification::class);
});
test('the link sets the first password, signed in or not, and makes the account local', function () {
$user = providerAccount();
$token = Password::createToken($user);
// Opened in the same browser the person is signed in with.
$this->actingAs($user)->get("/reset-password/{$token}?email=".urlencode($user->email))->assertOk();
$this->actingAs($user)->post('/reset-password', [
'token' => $token,
'email' => $user->email,
'password' => 'a-password-of-my-own',
'password_confirmation' => 'a-password-of-my-own',
])->assertSessionHasNoErrors();
$user->refresh();
expect(Hash::check('a-password-of-my-own', $user->password))->toBeTrue()
// The hash is now what signs this account in, and the settings
// screens read that off this column.
->and($user->auth_source)->toBe(AuthSource::Local);
});
test('using the link signs out the session that asked for it', function () {
$user = providerAccount();
$token = Password::createToken($user);
$this->actingAs($user)->post('/reset-password', [
'token' => $token,
'email' => $user->email,
'password' => 'a-password-of-my-own',
'password_confirmation' => 'a-password-of-my-own',
]);
// Whoever held the session before has the password no longer: the
// next thing it asks for sends it to sign in. forgetRequestState() so
// that request reads the account afresh, as a real one would.
forgetRequestState();
$this->post('/confirm-password', ['password' => 'a-password-of-my-own'])->assertRedirect(route('login'));
$this->assertGuest();
});
test('the email to a provider account is about setting a first password, not a reset', function () {
Notification::fake();
$user = providerAccount();
$this->actingAs($user)->post('/settings/password/link');
Notification::assertSentTo($user, ResetPasswordNotification::class, function (ResetPasswordNotification $notification) use ($user): bool {
$mail = $notification->toMail($user);
return $mail->subject === __('Set your password')
&& $mail->actionText === __('Set a password')
&& ! str_contains(implode(' ', $mail->introLines), 'reset');
});
});
test('an account with a password still gets the reset email', function () {
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPasswordNotification::class, function (ResetPasswordNotification $notification) use ($user): bool {
return $notification->toMail($user)->subject === __('Reset Password Notification');
});
});
test('an account that already has a password uses the form, not a link', function () {
Notification::fake();
$user = User::factory()->create();
$this->actingAs($user)->post('/settings/password/link')->assertForbidden();
Notification::assertNothingSent();
});
test('a directory account is not sent a link either', function () {
Notification::fake();
$this->actingAs(providerAccount(['auth_source' => AuthSource::Ldap]))->post('/settings/password/link')->assertForbidden();
Notification::assertNothingSent();
});
test('without a password, the last provider still cannot be removed', function () {
$user = providerAccount();
SocialAccount::query()->create(['user_id' => $user->id, 'provider' => 'google', 'provider_user_id' => 'g-1']);
$this->actingAs($user)->put('/settings/password', [
'password' => 'chosen-by-whoever-holds-the-session',
'password_confirmation' => 'chosen-by-whoever-holds-the-session',
]);
$this->actingAs($user->refresh())->delete('/settings/connected-accounts/google')->assertSessionHasErrors('provider');
expect(SocialAccount::query()->where('user_id', $user->id)->exists())->toBeTrue();
});
test('an ordinary account still has to prove the password it is replacing', function () {
$user = User::factory()->create();
@@ -106,20 +222,33 @@ test('compulsory two-factor leaves a provider account a way to get a password',
// But not on the one screen that can end the loop.
$this->actingAs($user)->get('/settings/password')->assertOk();
$this->actingAs($user)
->put('/settings/password', [
'password' => 'a-password-of-my-own',
'password_confirmation' => 'a-password-of-my-own',
])
->assertSessionHasNoErrors();
// The link is asked for from that screen, and opened past the
// enforcement too: both are part of ending the loop.
Notification::fake();
$this->actingAs($user)->post('/settings/password/link')->assertSessionHasNoErrors();
Notification::assertSentTo($user, ResetPasswordNotification::class);
// And with one, the password confirmation in front of enrolling is
$token = Password::createToken($user);
$this->actingAs($user)->get("/reset-password/{$token}?email=".urlencode($user->email))->assertOk();
$this->actingAs($user)->post('/reset-password', [
'token' => $token,
'email' => $user->email,
'password' => 'a-password-of-my-own',
'password_confirmation' => 'a-password-of-my-own',
])->assertSessionHasNoErrors();
// Setting it ends every session holding the old password, the one that
// asked for the link included, so the way on is to sign in again.
auth()->logout();
forgetRequestState();
$this->post('/login', ['email' => $user->email, 'password' => 'a-password-of-my-own']);
$this->assertAuthenticatedAs($user);
// And with a password, the confirmation in front of enrolling is
// answerable, so two-factor can actually be turned on.
$this->actingAs($user->refresh())
->post('/confirm-password', ['password' => 'a-password-of-my-own'])
->assertSessionHasNoErrors();
$this->post('/confirm-password', ['password' => 'a-password-of-my-own'])->assertSessionHasNoErrors();
$this->actingAs($user)->post('/settings/two-factor')->assertSessionHasNoErrors();
$this->post('/settings/two-factor')->assertSessionHasNoErrors();
expect($user->refresh()->two_factor_secret)->not->toBeNull();
});
+17 -1
View File
@@ -25,8 +25,11 @@ class FakeLdapDirectory implements LdapDirectory
/** @var list<string> */
public array $attemptedEmails = [];
/** @var list<string> */
public array $lookedUpUsernames = [];
/**
* @param array<string, array{password: string, name?: string, dn?: string}> $entries keyed by email
* @param array<string, array{password: string, name?: string, dn?: string, username?: string}> $entries keyed by email
*/
public function __construct(private readonly array $entries = []) {}
@@ -48,6 +51,19 @@ class FakeLdapDirectory implements LdapDirectory
);
}
public function emailForUsername(string $username): ?string
{
$this->calls++;
$this->lookedUpUsernames[] = $username;
$matches = array_keys(array_filter(
$this->entries,
fn (array $entry): bool => ($entry['username'] ?? null) === $username,
));
return count($matches) === 1 ? $matches[0] : null;
}
public function probe(?string $email = null, ?string $password = null): LdapProbeResult
{
return LdapProbeResult::ok(LdapProbeResult::STAGE_SERVICE_BIND, 'Fake directory reachable.');