mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8050b36ca | |||
| da5aadd1f3 | |||
| 386cb32ebb | |||
| 38400956bc | |||
| 8aef6e5b5a | |||
| 8372f42525 | |||
| 50f8b578df | |||
| 6ad26bb61e | |||
| 83a8fe2288 | |||
| 62c763d04e | |||
| 0671848bfa | |||
| 469297893b | |||
| eaba7ff633 | |||
| 6339ae1514 | |||
| 7c4b582d25 | |||
| b96d060ad8 | |||
| 6d7d80f62f | |||
| bc559ade3f | |||
| df44c46a12 | |||
| a1f59e133b | |||
| 0a3410140d | |||
| 80cf99d80e | |||
| cbc6760a93 | |||
| d8ca41ae0c | |||
| 1149df277b | |||
| ab5fa2da8b | |||
| 3244be6bac | |||
| 5fb17388cd | |||
| 2be423d685 | |||
| 6560346280 | |||
| e187513cdd | |||
| 896675d631 | |||
| 92bb807849 | |||
| 0a28e239d6 | |||
| 3d923188d9 | |||
| b128b114b5 | |||
| 757fba19ca | |||
| 763e7b0e2e | |||
| a5496d24cd | |||
| fba5f30436 | |||
| 0f66f9030c | |||
| 7c16733c16 | |||
| d7d7acce85 | |||
| 334b11d562 | |||
| da1f432d87 | |||
| 82dd475f8f | |||
| b758fca19c | |||
| 02946abf85 | |||
| b7ac44e77b | |||
| 5a9133bb07 | |||
| f2b705beee |
@@ -4,6 +4,11 @@ PROJECTSEND_EDITION=community
|
||||
# Emergency off switch for the CAPTCHA on public forms, for an operator who
|
||||
# has a shell but no working login. Everything else about the feature is
|
||||
# configured at /system/settings/captcha.
|
||||
#
|
||||
# Only "true" or "1" switches it off. Anything else -- including "no",
|
||||
# "off", and a misspelling -- leaves the CAPTCHA on, deliberately: a flag
|
||||
# that takes a protection away should not do so because a value was typed
|
||||
# wrong.
|
||||
# PROJECTSEND_CAPTCHA_DISABLED=true
|
||||
|
||||
# How downloads leave the server. Left unset (or "auto"), ProjectSend hands
|
||||
|
||||
+107
-20
@@ -6,34 +6,121 @@ Versions follow [SemVer](https://semver.org/): the middle number moves when ther
|
||||
the last one when there are only fixes, and the first one when an upgrade needs more from you than
|
||||
dropping in the new files and running the migrations.
|
||||
|
||||
Anything under **Upgrade notes** is something you have to do, not something we did.
|
||||
Anything under **⚠️ Important — do these yourself** is something you have to do, not something
|
||||
we did. It sits at the top of a release for that reason. Older entries call the same section
|
||||
**Upgrade notes**.
|
||||
|
||||
## Unreleased
|
||||
## 2.4.1 — 11 September 2026
|
||||
|
||||
This section collects changes as they land; the release process turns it into a numbered entry
|
||||
when a version is cut.
|
||||
### ⚠️ Important — do these yourself
|
||||
|
||||
Everything else in this release happens on its own. These do not: each one leaves something working
|
||||
differently from how you expect until you act on it. Nothing here stops the upgrade or the
|
||||
installation from starting.
|
||||
|
||||
- **If you set `PROJECTSEND_CAPTCHA_DISABLED`, check what you set it to.** Only `true` or `1`
|
||||
switches the CAPTCHA off now. Anything else — including `no`, `off`, `yes` and a misspelling —
|
||||
used to be read as "yes, disabled" and is now read as "leave it on". If you meant it off, write
|
||||
`true`.
|
||||
- **If a staff role uploads into public folders, give it "Upload to public folders".** That
|
||||
permission was not being asked of staff, and now is. Roles holding "Upload public files" are
|
||||
unaffected, and ordinary uploads need nothing new.
|
||||
- **If you use Microsoft sign-in, add the `xms_edov` optional claim to your app registration.** In
|
||||
the Entra portal: your app registration → Token configuration → Add optional claim → ID →
|
||||
`xms_edov`. Until you do, Microsoft sign-in keeps working and keeps creating new accounts, but it
|
||||
will no longer attach itself to an account that already exists.
|
||||
|
||||
**Added**
|
||||
|
||||
- **Your logo now appears on the sign-in screen.** Requested by
|
||||
[@Zodiac1978](https://github.com/Zodiac1978) in
|
||||
[#1777](https://github.com/projectsend/projectsend/issues/1777).
|
||||
- **An installation on AWS can authenticate as its own IAM role instead of storing an access key.**
|
||||
- **Clients can now see how often their own files were downloaded, and when.**
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **A lookalike domain can no longer hand somebody else's account to an OIDC sign-in.** Reported by
|
||||
[@choewonwoo1817](https://github.com/choewonwoo1817).
|
||||
- **Changing your own email address now asks for your password.** Reported by
|
||||
[@Noorkhalel](https://github.com/Noorkhalel).
|
||||
- **Microsoft sign-in now checks that the person owns the address they presented.** Reported by
|
||||
[@archnexus707](https://github.com/archnexus707).
|
||||
- **Two people filling in the first-run setup screen at the same moment can no longer both become
|
||||
administrators.** Reported by [@ry2811](https://github.com/ry2811).
|
||||
- **Uploading into a public folder now needs a permission that says so.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **Moving a file into a public folder now needs that same permission.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **A staff member limited to some clients can no longer see or change other people's groups.**
|
||||
Reported by [@Drescargot](https://github.com/Drescargot).
|
||||
- **Deleting a client can no longer hand their files to a client you do not manage.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **Erasing a staff account no longer hands their files to a client.**
|
||||
- **An interrupted upload can no longer park unlimited bytes on the server.** Reported by
|
||||
[@ry2811](https://github.com/ry2811).
|
||||
- **The password reset screen no longer says whether an email address has an account here.**
|
||||
- **An expired password reset link now says so before asking for a new password.**
|
||||
- **A Docker upgrade no longer fails when external storage is already configured.**
|
||||
[#1770](https://github.com/projectsend/projectsend/issues/1770).
|
||||
- **A public gallery no longer renders the same thumbnail several times at once.**
|
||||
- **`PROJECTSEND_CAPTCHA_DISABLED` no longer reads a "no" as a "yes".**
|
||||
|
||||
### Issues closed since 2.4.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1768](https://github.com/projectsend/projectsend/issues/1768) — Search in file not restricted in directory
|
||||
- [#1773](https://github.com/projectsend/projectsend/issues/1773) — Feature Request : Support AWS IAM roles / default credential provider chain for S3 storage
|
||||
- [#1774](https://github.com/projectsend/projectsend/issues/1774) — HTTP Error by upload on R2098
|
||||
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — [Documentation] Error 500 on install
|
||||
|
||||
## 2.4.0 — 8 September 2026
|
||||
|
||||
Clients can now look after the files they uploaded, and this release closes three ways somebody
|
||||
could see a little more than they should.
|
||||
|
||||
**New**
|
||||
|
||||
- **Clients can edit and delete the files they uploaded**, with the name, description, expiry,
|
||||
categories, download limit and public flag each behind the permission that already governs it.
|
||||
A file shared *with* a client is still not theirs to touch.
|
||||
- **A switch to stop this installation fetching the project news**, on Settings → General. On by
|
||||
default; off means the request is never made.
|
||||
|
||||
**Closed holes in who can see what**
|
||||
|
||||
- A staff member limited to their own assigned clients could read the names of other clients out of
|
||||
file details. Sharing means a file can reach somebody through one client while it was uploaded by
|
||||
another, or while it is also shared with another. That is normal and the file is theirs to open —
|
||||
but the uploader's name, the other recipient's name, and both their ID numbers were being sent
|
||||
along with it, on the library list, the file's edit page, the details panel, the per-client file
|
||||
list, and the matching API responses. A group holding none of their clients was named the same
|
||||
way. The file list could also be filtered by uploader, which answered "does this client of yours
|
||||
share files with that client of mine" without naming anybody.
|
||||
- A staff member limited to their assigned clients could read other clients' names, and their IDs,
|
||||
out of file details and the uploader filter. Reported by
|
||||
[@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
|
||||
- Download links to external storage now last a minute instead of an hour. Previews keep the hour.
|
||||
- Eight advisories in bundled dependencies, including an XSS bypass in the markdown renderer that
|
||||
builds your email templates.
|
||||
|
||||
Those names are now left out for a limited staff member, and the uploader filter no longer answers
|
||||
for a client they are not assigned to. Administrators and any unrestricted role see exactly what
|
||||
they saw before.
|
||||
**Fixed**
|
||||
|
||||
**Who this affected.** Only installations using the Client Manager role, or a custom role with
|
||||
"Limit to assigned clients" switched on, and only where files are shared with more than one client
|
||||
or through groups. No files, downloads or credentials were reachable this way — a file belonging
|
||||
to a client outside the roster was refused before, and still is.
|
||||
- A failed upload keeps its parts, so retrying it works instead of needing the whole file again.
|
||||
- `projectsend:captcha-off` no longer claims success on an installation whose CAPTCHA keys are
|
||||
supplied centrally, where it changed nothing.
|
||||
|
||||
Reported by [@Noorkhalel](https://github.com/Noorkhalel) (GHSA-whmp-p9hv-r7j7).
|
||||
### Upgrade notes
|
||||
|
||||
- **Resuming an interrupted download from external storage more than a minute after it started now
|
||||
fails.** Start it again from ProjectSend. Local-disk installations and zip bundles are unaffected.
|
||||
- **If your temporary directory is on a small or separate volume, allow headroom for twice your
|
||||
largest allowed upload.** Only while a file is being assembled, and nothing needs configuring.
|
||||
|
||||
Thanks to [@Noorkhalel](https://github.com/Noorkhalel), [@denkfabrik-li](https://github.com/denkfabrik-li)
|
||||
and [@mehmedturk](https://github.com/mehmedturk) for reporting and fixing.
|
||||
|
||||
### Issues closed since 2.3.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1765](https://github.com/projectsend/projectsend/issues/1765) — Projectsend 2.2.1 thumbnail issue after file upload
|
||||
- [#1771](https://github.com/projectsend/projectsend/issues/1771) — Permissions granted to the Client role are not applied to client accounts
|
||||
|
||||
## 2.3.0 — 1 September 2026
|
||||
|
||||
|
||||
+57
-2
@@ -324,8 +324,9 @@ like your logo reachable from the web.
|
||||
|
||||
## Step 6 — Point your web server at it
|
||||
|
||||
A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever
|
||||
you unpacked the files (there are **three** places, including one inside `/protected-files/`):
|
||||
A complete nginx server block below; [Apache is further down](#if-you-are-using-apache). Change
|
||||
`server_name`, and change `/var/www/projectsend` to wherever you unpacked the files (there are
|
||||
**three** places, including one inside `/protected-files/`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -374,6 +375,38 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### If you are using Apache
|
||||
|
||||
Two things matter, and both are easy to get wrong:
|
||||
|
||||
- **The document root is the `public/` directory**, not the directory you unpacked into. Everything
|
||||
above `public/` — your `.env`, your uploaded files, the application code — has to stay out of
|
||||
reach of any URL.
|
||||
- **`AllowOverride All`, and `mod_rewrite` enabled** (`sudo a2enmod rewrite`). ProjectSend ships a
|
||||
`public/.htaccess` that sends every address to the front controller. If Apache is told to ignore
|
||||
it, every page except the home page is a 404.
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName files.example.com
|
||||
DocumentRoot /var/www/projectsend/public
|
||||
|
||||
<Directory /var/www/projectsend/public>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/projectsend-error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/projectsend-access.log combined
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Downloads work as they are: PHP sends the bytes. If that becomes a capacity problem, `mod_xsendfile`
|
||||
hands the job to Apache — see [How downloads are sent](#how-downloads-are-sent).
|
||||
|
||||
On shared hosting you usually cannot edit any of this, and `public/.htaccess` is all you have. If
|
||||
the site returns a 500 on every page, see [When something goes wrong](#when-something-goes-wrong).
|
||||
|
||||
Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a
|
||||
whole 5 GB file at once — but the pieces still need room. In your `php.ini`:
|
||||
|
||||
@@ -581,6 +614,28 @@ names the exact command to run; do that, then reload.
|
||||
Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is
|
||||
folder permissions (step 4) or a wrong database password (step 3).
|
||||
|
||||
**Every page is a 500, and `storage/logs/` is empty.**
|
||||
The empty log is the answer, not a dead end: nothing reached PHP, so ProjectSend had nothing to
|
||||
write. The error is your web server's, and it is in your web server's log — on Apache
|
||||
`/var/log/apache2/error.log`, or wherever your host puts it. On Apache two causes account for
|
||||
almost all of these, and both are about `public/.htaccess`:
|
||||
|
||||
- **`Options not allowed here`.** The file starts by turning off directory listings and content
|
||||
negotiation, and your `AllowOverride` does not permit that. Allow it (`AllowOverride All`), or
|
||||
delete the `Options` line — it is hardening, not a requirement.
|
||||
- **`Request exceeded the limit of 10 internal redirects`.** Apache cannot work out which directory
|
||||
the file is serving, so the rule that sends every address to `index.php` rewrites to a path that
|
||||
does not exist, and tries again. Uncomment the `RewriteBase` line in `public/.htaccess` and set it
|
||||
to the path ProjectSend is served from — `/` at the domain root, `/projectsend` in a subdirectory.
|
||||
Reported on IONOS by [@Zodiac1978](https://github.com/Zodiac1978) in
|
||||
[#1778](https://github.com/projectsend/projectsend/issues/1778).
|
||||
|
||||
**If you edit `public/.htaccess`, write down what you changed.** Updating replaces every file the
|
||||
release ships, that one included, so a change that made your site work will be gone after the next
|
||||
update and the 500 will come back. If the Apache configuration is yours to edit, put the directives
|
||||
in a `<Directory>` block in the vhost instead: they do the same job there, and no update can touch
|
||||
them. On shared hosting, where it is not yours, keep the note and re-apply it.
|
||||
|
||||
**"Please provide a valid cache path" or "failed to open stream".**
|
||||
`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4.
|
||||
|
||||
|
||||
@@ -30,9 +30,53 @@ class NewPasswordController extends Controller
|
||||
return Inertia::render('auth/reset-password', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
'expired' => $this->linkIsSpent(
|
||||
(string) $request->string('email'),
|
||||
(string) $request->route('token'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this link is one store() is certain to refuse.
|
||||
*
|
||||
* The scaffolding renders the form without looking at the token, so an
|
||||
* expired link asked for a new password, asked for it a second time to
|
||||
* confirm, and only then answered "this password reset token is
|
||||
* invalid" — naming a word nobody outside the code knows, after the
|
||||
* work rather than before it. Reset links last an hour and people open
|
||||
* them late; that is ordinary, not an error to be scolded for.
|
||||
*
|
||||
* store() still validates and remains the rule. This is the screen
|
||||
* being honest a minute earlier.
|
||||
*
|
||||
* **Anything that will not validate reads as expired, whether or not
|
||||
* the address is one we know.** That is the whole of the rule and it
|
||||
* exists for one reason: a page answering "expired" for a real address
|
||||
* and drawing the form for an unknown one tells anybody who types a
|
||||
* guess whether an account is here — the exact property
|
||||
* /forgot-password protects by saying "a link will be sent if the
|
||||
* account exists".
|
||||
*
|
||||
* The first version of this method described that oracle in a comment
|
||||
* and then built it: unknown address returned false and drew the form,
|
||||
* known address returned true and said expired. Two branches, two
|
||||
* answers, and the difference *was* the account. Now both answer the
|
||||
* same, so the page reveals nothing and the message is still right in
|
||||
* every case somebody real will meet — a mistyped address gets "ask
|
||||
* for a new link", which is what they should do anyway.
|
||||
*/
|
||||
private function linkIsSpent(string $email, string $token): bool
|
||||
{
|
||||
$broker = Password::broker();
|
||||
$user = $email === '' ? null : $broker->getUser(['email' => $email]);
|
||||
|
||||
// One answer for "no such account", "wrong token" and "spent
|
||||
// token", because telling them apart is telling somebody which
|
||||
// addresses exist here.
|
||||
return $user === null || ! $broker->tokenExists($user, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
@@ -113,8 +157,21 @@ class NewPasswordController extends Controller
|
||||
return to_route('login')->with('status', __($status));
|
||||
}
|
||||
|
||||
// One sentence for every way this can fail, and deliberately not
|
||||
// Laravel's own. The scaffolding answers `passwords.user` for an
|
||||
// address it cannot find and `passwords.token` for a real one whose
|
||||
// token is dead — two different sentences, which is the same
|
||||
// account-enumeration oracle the screen above was fixed for,
|
||||
// reachable through the write instead. `passwords.throttled` is the
|
||||
// third and the sharpest: the broker throttles per *user*, so an
|
||||
// address nobody holds can never be throttled, and being told to
|
||||
// wait is being told the account is there.
|
||||
//
|
||||
// Nothing is lost by collapsing them. The action is the same in
|
||||
// every case — ask for a new link — and /forgot-password already
|
||||
// refuses to say whether an address has an account.
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
'email' => [__('This password reset link is no longer valid. Ask for a new one and try again.')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Updates\LatestReleaseInfo;
|
||||
use App\Modules\Platform\Updates\RunningCodeState;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use App\Modules\Platform\Announcements\Events\ResolvingAnnouncement;
|
||||
use App\Modules\Platform\Navigation\Events\ResolvingNavigationLinks;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
@@ -84,6 +87,19 @@ class HandleInertiaRequests extends Middleware
|
||||
// ignore this and always show it.
|
||||
'attribution' => app(Attribution::class)->visible(),
|
||||
'capabilities' => $capabilities->enabledKeys(),
|
||||
// Sidebar entries a package asked for. Shared rather than
|
||||
// passed per page because the sidebar is on every page, and
|
||||
// dispatched unconditionally so that with nothing listening
|
||||
// the list is empty and the sidebar is exactly what it was.
|
||||
// See ResolvingNavigationLinks for why core never learns what
|
||||
// is in it.
|
||||
'extra_nav_links' => $this->extraNavLinks($request),
|
||||
// Shared rather than a dashboard prop, because it is shown in
|
||||
// two places — the band on the dashboard and the icon beside
|
||||
// the notification bell everywhere else — and "the same
|
||||
// message" is the requirement. Two props would drift the day
|
||||
// somebody edited one.
|
||||
'announcement' => $this->announcement($request),
|
||||
// Shared rather than passed by each page: the sign-in buttons,
|
||||
// the registration form and the Connected accounts nav entry
|
||||
// all need the same list, and a nav entry to a screen with
|
||||
@@ -301,4 +317,48 @@ class HandleInertiaRequests extends Middleware
|
||||
/** @var array<string, string> */
|
||||
return app('translator')->getLoader()->load($locale, '*', '*');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{title: string, url: string, external: bool, icon: string|null}>
|
||||
*/
|
||||
private function extraNavLinks(Request $request): array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Staff only, decided here rather than in each listener: these
|
||||
// render in the administration area, and a client's portal shows
|
||||
// their own files and nothing about the installation.
|
||||
$event = new ResolvingNavigationLinks(isStaff: $user !== null && $user->isStaff());
|
||||
|
||||
if (! $event->isStaff) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
return $event->links;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
|
||||
*/
|
||||
private function announcement(Request $request): ?array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dispatched for clients too, unlike the sidebar links beside it.
|
||||
// A client is somebody a shared instance may legitimately need to
|
||||
// address — about their own account, not about the installation —
|
||||
// and the event refuses anything not aimed at them, so widening
|
||||
// this does not widen what reaches them.
|
||||
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
return $event->announcement;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\AccountLookup;
|
||||
use App\Modules\Identity\Ldap\LdapProvisioner;
|
||||
use App\Modules\Identity\PasswordVerification;
|
||||
use App\Modules\Identity\SignIn;
|
||||
@@ -71,7 +72,13 @@ class LoginRequest extends FormRequest
|
||||
{
|
||||
$this->ensureIsNotRateLimited();
|
||||
|
||||
$user = User::query()->where('email', $this->string('email'))->first();
|
||||
// Exact, for the reason SocialAuthenticator is: a collation that
|
||||
// folds accents would otherwise let somebody typing
|
||||
// admin@éxample.com be *identified* as admin@example.com. A
|
||||
// password still gates this one, so it was never the takeover the
|
||||
// social path was — but identifying the wrong account is the bug,
|
||||
// and the credential check is a second line rather than the rule.
|
||||
$user = app(AccountLookup::class)->byEmail((string) $this->string('email'));
|
||||
|
||||
// A directory identity with no local account yet. Returns null
|
||||
// unless LDAP is on, auto-provisioning is on, and the bind
|
||||
|
||||
@@ -5,9 +5,11 @@ namespace App\Http\Requests\Settings;
|
||||
use App\Models\User;
|
||||
use App\Modules\Clients\ClientFieldContext;
|
||||
use App\Modules\Clients\ClientPortalCustomFields;
|
||||
use App\Modules\Identity\AuthSource;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Closure;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
@@ -31,6 +33,26 @@ class ProfileUpdateRequest extends FormRequest
|
||||
Rule::unique(User::class)->ignore($this->user()?->id),
|
||||
],
|
||||
|
||||
// Changing this address is a credential change, not a detail:
|
||||
// it is where a password reset is sent, so whoever can change
|
||||
// it owns the account from the next reset onwards. A stolen
|
||||
// session used to be enough (GHSA-f32x-fgmp-q353) — temporary
|
||||
// access became permanent ownership with one PATCH.
|
||||
//
|
||||
// `exclude_if` rather than a flat rule, so the rest of the
|
||||
// screen keeps saving with nothing extra: a name, a timezone
|
||||
// or a custom field is not a credential and must not start
|
||||
// asking for a password. Only a *different* address does.
|
||||
//
|
||||
// The same rule destroy() one controller away has always
|
||||
// asked, for the same reason: both doors lead to owning the
|
||||
// account.
|
||||
'current_password' => [
|
||||
Rule::excludeIf(! $this->changesEmail()),
|
||||
'required',
|
||||
'current_password',
|
||||
],
|
||||
|
||||
// Saved with the rest of the profile so the screen keeps one
|
||||
// Save button. `timezone` is fillable, so ProfileController's
|
||||
// fill() picks it up with no special handling.
|
||||
@@ -43,6 +65,21 @@ class ProfileUpdateRequest extends FormRequest
|
||||
];
|
||||
|
||||
$user = $this->user();
|
||||
|
||||
// An account whose credentials live in a directory or at an
|
||||
// identity provider holds a local password nobody knows — see
|
||||
// LdapProvisioner, which stores Str::password(64) exactly so it
|
||||
// can never be used. Asking such a person to confirm "your current
|
||||
// password" is a dead end dressed as a form error, and the address
|
||||
// is not theirs to change here in any case: it is what the
|
||||
// directory or the provider says it is, and a local edit would
|
||||
// either be overwritten or break the link.
|
||||
if ($this->changesEmail() && $user !== null && $user->auth_source !== AuthSource::Local) {
|
||||
$rules['email'][] = function (string $attribute, mixed $value, Closure $fail): void {
|
||||
$fail(__('Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.'));
|
||||
};
|
||||
}
|
||||
|
||||
if ($user?->isClient() === true) {
|
||||
$rules = [
|
||||
...$rules,
|
||||
@@ -52,4 +89,29 @@ class ProfileUpdateRequest extends FormRequest
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request asks for an address other than the stored one.
|
||||
*
|
||||
* Compared lowercased and trimmed because the `lowercase` rule runs
|
||||
* beside this one rather than before it: without that, re-saving the
|
||||
* profile with the address typed in a different case would be read as
|
||||
* a change and demand a password for nothing.
|
||||
*/
|
||||
private function changesEmail(): bool
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$submitted = $this->input('email');
|
||||
|
||||
if (! is_string($submitted)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mb_strtolower(trim($submitted)) !== mb_strtolower(trim((string) $user->email));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,16 @@ class User extends Authenticatable implements HasLocalePreference
|
||||
// credentials live is a security decision, not an attribute a
|
||||
// form or an API payload may set. Written with forceFill by
|
||||
// the code that provisions the account.
|
||||
//
|
||||
// Same for 'email_verified_at' below, and it is worth saying
|
||||
// what absence from $fillable does and does not buy. It stops
|
||||
// a request smuggling the value in. It does not tell the code
|
||||
// that meant to set it deliberately that it failed: a key in a
|
||||
// create() array is dropped in silence, so every path that
|
||||
// provisions an account had one and lost it — staff accounts,
|
||||
// client accounts, the setup screen and projectsend:admin, all
|
||||
// fixed in September 2026. Not fillable only helps when the
|
||||
// writer knows it has to be deliberate.
|
||||
'auth_source' => AuthSource::class,
|
||||
'ldap_synced_at' => 'datetime',
|
||||
'active' => 'boolean',
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\ActivityPresenter;
|
||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Clients;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
|
||||
/**
|
||||
* Creating a client account — the rules and the side effects, shared by
|
||||
* every surface that makes one.
|
||||
*
|
||||
* The same argument StaffAccounts makes for staff. What a client account
|
||||
* *is* — its type, its role, an active flag, a quota where zero means
|
||||
* "inherit the site default" rather than "none" — is a set of invariants,
|
||||
* and an invariant enforced in one controller and re-implemented in
|
||||
* another is one that will eventually hold in only one of them. There are
|
||||
* three surfaces onto this now: the staff screens, `/api/v1/clients`, and
|
||||
* the platform control plane in the private package, which reaches this
|
||||
* by name because it cannot import a host class.
|
||||
*
|
||||
* What stays with the caller is what genuinely differs: the shape of the
|
||||
* request, its validation rules, its response, and anything about *who is
|
||||
* asking* — a client-scoped staff member gaining the client on their own
|
||||
* roster is a fact about the creator, not about the account created.
|
||||
*
|
||||
* **Not to be confused with ClientProvisioning**, which sits beside it and
|
||||
* handles the other half: an account that comes into existence without
|
||||
* anybody deciding to create it — the public registration form, and a
|
||||
* first successful LDAP sign-in. The policies genuinely differ rather than
|
||||
* merely duplicating. An account made here is approved and verified by
|
||||
* construction, because somebody who already knows who this is asked for
|
||||
* it; one made there may wait for approval, joins a configured group, and
|
||||
* tells the administrators it arrived.
|
||||
*/
|
||||
class ClientAccounts
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param int $storageQuotaMb 0 means no per-account quota and
|
||||
* inherits the site default at
|
||||
* enforcement time — see
|
||||
* ClientStorageUsage::quotaMb(). It does
|
||||
* not mean unlimited.
|
||||
* @param bool $welcome whether this installation should email the
|
||||
* new account. A caller that sends its own
|
||||
* welcome passes false rather than having the
|
||||
* customer receive two.
|
||||
*/
|
||||
public function create(
|
||||
string $name,
|
||||
string $email,
|
||||
string $password,
|
||||
int $storageQuotaMb = 0,
|
||||
bool $welcome = true,
|
||||
string $emailField = 'email',
|
||||
): User {
|
||||
// Before anything is written, and deliberately not left to the
|
||||
// caller. The platform sets this cap and the platform is also what
|
||||
// calls the control plane — so enforcing it here is what stops a
|
||||
// leaked control token minting accounts without limit. A guard
|
||||
// that only ran on the surfaces that remembered it would not be a
|
||||
// guard.
|
||||
$this->seats->guardClient($emailField);
|
||||
|
||||
$client = User::create([
|
||||
'type' => UserType::Client,
|
||||
'active' => true,
|
||||
'account_requested' => false,
|
||||
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'storage_quota_mb' => $storageQuotaMb,
|
||||
]);
|
||||
|
||||
// forceFill, and not part of the create() array above: like
|
||||
// StaffAccounts, email_verified_at is deliberately absent from
|
||||
// User::$fillable — where an account stands is a security decision
|
||||
// rather than an attribute — so mass assignment drops it in
|
||||
// silence. Every client-creation path used to pass it in that
|
||||
// array and lose it. The intent is real: an account created by
|
||||
// somebody who already knows who this is has no address to
|
||||
// confirm and nobody to confirm it to. (Inert today, since
|
||||
// MustVerifyEmail is not enabled on the model, but the column is
|
||||
// what a later switch would read.)
|
||||
$client->forceFill(['email_verified_at' => now()])->save();
|
||||
|
||||
$this->activity->log(Action::UserCreated, subject: $client);
|
||||
|
||||
if ($welcome && $this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||
$client->notify(new ClientWelcomeNotification);
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,9 @@ class ClientStorageUsage
|
||||
|
||||
/**
|
||||
* A client's own storage_quota_mb of 0 means "no custom quota set" —
|
||||
* it inherits Setting::DefaultClientStorageQuotaMb instead of being
|
||||
* unlimited, so a site-wide default (once set) also protects clients
|
||||
* who never got an explicit quota, including self-registered ones.
|
||||
* The site default itself being 0 is what actually means unlimited.
|
||||
* it inherits the installation's default instead of being unlimited,
|
||||
* so a default (once set) also protects clients who never got an
|
||||
* explicit quota, including self-registered ones.
|
||||
*
|
||||
* @return int 0 means unlimited.
|
||||
*/
|
||||
@@ -39,7 +38,46 @@ class ClientStorageUsage
|
||||
{
|
||||
return $client->storage_quota_mb > 0
|
||||
? $client->storage_quota_mb
|
||||
: (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
|
||||
: $this->defaultQuotaMb();
|
||||
}
|
||||
|
||||
/**
|
||||
* What a client with no quota of their own actually gets.
|
||||
*
|
||||
* Three sources, narrowest first, and the third is why this is a
|
||||
* method rather than a `Settings::get()` at the point of use.
|
||||
*
|
||||
* `Setting::DefaultClientStorageQuotaMb` belongs to whoever runs the
|
||||
* installation, and its default is 0 — which means unlimited. That is
|
||||
* the right default for somebody setting up their own install, and the
|
||||
* wrong one for an installation a platform operates on other people's
|
||||
* behalf: there, an account that arrived without an explicit quota has
|
||||
* no ceiling at all, which on a shared installation is one account
|
||||
* away from unmetered hosting.
|
||||
*
|
||||
* So a platform may set a floor in the environment, exactly as it sets
|
||||
* the seat caps, and for the same reason those are not settings: it is
|
||||
* not a preference the installation's administrator is expressing, it
|
||||
* is the shape of what was sold. It applies only where the setting says
|
||||
* nothing, so an administrator who has chosen a number keeps it, and an
|
||||
* install with no platform behind it is unaffected.
|
||||
*
|
||||
* Unset and zero are the same answer here, on purpose: a platform that
|
||||
* wanted no ceiling would not set the variable.
|
||||
*
|
||||
* @return int 0 means unlimited.
|
||||
*/
|
||||
public function defaultQuotaMb(): int
|
||||
{
|
||||
$site = (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb);
|
||||
|
||||
if ($site > 0) {
|
||||
return $site;
|
||||
}
|
||||
|
||||
$floor = config('projectsend.platform.default_client_quota_mb');
|
||||
|
||||
return is_numeric($floor) ? max(0, (int) $floor) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\User;
|
||||
use App\Modules\Api\Support\PollingQuery;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientAccounts;
|
||||
use App\Modules\Clients\ClientCustomFieldType;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
@@ -22,10 +23,7 @@ use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountContentDeletion;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -62,6 +60,7 @@ class ClientsController extends Controller
|
||||
private readonly AccountContentDeletion $accountDeletion,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly ClientAccounts $clients,
|
||||
private readonly ErasureSchedule $erasure,
|
||||
) {}
|
||||
|
||||
@@ -118,8 +117,6 @@ class ClientsController extends Controller
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->seats->guardClient();
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
@@ -138,21 +135,18 @@ class ClientsController extends Controller
|
||||
|
||||
$validated['custom_field_values'] = $this->validateCustomFieldValues($request);
|
||||
|
||||
$client = User::create([
|
||||
'type' => UserType::Client,
|
||||
'active' => true,
|
||||
'account_requested' => false,
|
||||
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
// 0 means "no custom quota" and inherits the site default at
|
||||
// enforcement time — see ClientStorageUsage::quotaMb().
|
||||
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$this->activity->log(Action::UserCreated, subject: $client);
|
||||
// The invariants — the seat guard, the type, the role, the quota's
|
||||
// "0 means inherit" — live in ClientAccounts, shared with the staff
|
||||
// screens and with the platform control plane. What stays here is
|
||||
// this surface's own business: its validation, its custom fields,
|
||||
// and who the creator is.
|
||||
$client = $this->clients->create(
|
||||
name: $validated['name'],
|
||||
email: $validated['email'],
|
||||
password: $validated['password'],
|
||||
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
|
||||
welcome: false,
|
||||
);
|
||||
|
||||
$creator = $request->user();
|
||||
assert($creator !== null);
|
||||
@@ -170,6 +164,10 @@ class ClientsController extends Controller
|
||||
|
||||
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
||||
|
||||
// Sent here rather than inside ClientAccounts so the custom fields
|
||||
// are already saved when it goes: a welcome that arrives before
|
||||
// the account is finished describes an account that does not quite
|
||||
// exist yet.
|
||||
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||
$client->notify(new ClientWelcomeNotification);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Clients\ClientAccounts;
|
||||
use App\Modules\Clients\ClientCustomFieldType;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
@@ -20,10 +21,7 @@ use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountContentDeletion;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\Erasure\ErasureSchedule;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorAdministration;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Pagination;
|
||||
@@ -51,6 +49,7 @@ class ClientsController extends Controller
|
||||
private readonly AccountContentDeletion $accountDeletion,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly SeatAllowance $seats,
|
||||
private readonly ClientAccounts $clients,
|
||||
private readonly ErasureSchedule $erasure,
|
||||
) {}
|
||||
|
||||
@@ -123,16 +122,25 @@ class ClientsController extends Controller
|
||||
|
||||
return Inertia::render('clients/create', [
|
||||
'custom_fields' => $this->customFieldDefinitions(),
|
||||
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
// The resolved default, not the raw setting: a platform can put
|
||||
// a floor under it from the environment, and both screens
|
||||
// present this as what will actually happen rather than as a
|
||||
// value being edited. The edit screen mirrors quotaMb()'s
|
||||
// resolution client-side to draw the usage bar, and handing it
|
||||
// the effective number is what keeps that mirror correct
|
||||
// without it having to know floors exist.
|
||||
//
|
||||
// The Client settings form deliberately still reads the raw
|
||||
// setting (ClientSettingsController): that field is edited and
|
||||
// saved back, so prefilling it with a floor would write the
|
||||
// platform's number into the setting as the administrator's own
|
||||
// choice, where it would outlive the floor.
|
||||
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
// A client created here is approved by construction, so it counts
|
||||
// immediately — unlike a self-registration awaiting a decision.
|
||||
$this->seats->guardClient();
|
||||
|
||||
$validated = $request->validate(array_merge([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', new AvailableEmailRule],
|
||||
@@ -140,24 +148,19 @@ class ClientsController extends Controller
|
||||
'storage_quota_mb' => ['nullable', 'integer', 'min:0'],
|
||||
], $this->customFieldRules()));
|
||||
|
||||
$client = User::create([
|
||||
'type' => UserType::Client,
|
||||
'active' => true,
|
||||
'account_requested' => false,
|
||||
'role_id' => Role::query()->where('name', SystemRole::Client->value)->value('id'),
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
// 0 (including an omitted field) means "no custom quota" —
|
||||
// it inherits Setting::DefaultClientStorageQuotaMb at
|
||||
// enforcement time (see ClientStorageUsage::quotaMb()), not
|
||||
// baked in here, so a later change to the site default
|
||||
// keeps applying to this client automatically.
|
||||
'storage_quota_mb' => $validated['storage_quota_mb'] ?? 0,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$this->activity->log(Action::UserCreated, subject: $client);
|
||||
// The seat guard, the type, the role, and the quota's "0 means
|
||||
// inherit the site default" all live in ClientAccounts, shared
|
||||
// with the API and the control plane. A client created here is
|
||||
// approved by construction, so it counts against the cap
|
||||
// immediately — unlike a self-registration awaiting a decision.
|
||||
// The welcome waits until the custom fields are saved below.
|
||||
$client = $this->clients->create(
|
||||
name: $validated['name'],
|
||||
email: $validated['email'],
|
||||
password: $validated['password'],
|
||||
storageQuotaMb: $validated['storage_quota_mb'] ?? 0,
|
||||
welcome: false,
|
||||
);
|
||||
|
||||
$creator = $request->user();
|
||||
assert($creator !== null);
|
||||
@@ -226,7 +229,8 @@ class ClientsController extends Controller
|
||||
'storage_quota_mb' => $client->storage_quota_mb,
|
||||
'two_factor_enabled' => $client->hasTwoFactorEnabled(),
|
||||
],
|
||||
'default_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
// Resolved, not raw — see create() above.
|
||||
'default_storage_quota_mb' => $this->storageUsage->defaultQuotaMb(),
|
||||
'storage_used_mb' => (int) ceil($this->storageUsage->usedBytes($client) / 1024 / 1024),
|
||||
'custom_fields' => $this->customFieldDefinitions(),
|
||||
'custom_field_values' => ClientCustomFieldValue::query()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Access;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* How often a client's own file has been taken, and when it last was.
|
||||
*
|
||||
* The question somebody asks about a file they sent: did it arrive? On a
|
||||
* hosted free account the link is the whole of the sharing, so "3
|
||||
* downloads, last one on Tuesday" is the only evidence there is that it
|
||||
* worked.
|
||||
*
|
||||
* **Own files only, and that is a privacy rule rather than a scoping
|
||||
* convenience.** A download entry says somebody fetched the file, and on
|
||||
* a file shared with several clients, telling one of them the count tells
|
||||
* them about the others' activity. Nobody is entitled to that except the
|
||||
* person who put the file there. So a file shared *with* this client
|
||||
* carries no numbers at all — not zero, which would be a claim, but
|
||||
* nothing.
|
||||
*
|
||||
* Counts come from the activity log through File::downloads(), the same
|
||||
* source every other download count in the interface uses. There is
|
||||
* deliberately no counter column — see DownloadAllowance for the whole
|
||||
* argument, which applies unchanged here.
|
||||
*
|
||||
* One query for a page, whatever it holds.
|
||||
*/
|
||||
class OwnFileDownloads
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, File> $files
|
||||
* @return array<int, array{count: int, last_at: string|null}> keyed by
|
||||
* file id, only for files this client uploaded
|
||||
*/
|
||||
public function forMany(Collection $files, User $client): array
|
||||
{
|
||||
$own = $files
|
||||
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($own === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Every own file gets an entry, including the ones with nothing to
|
||||
// report: the difference between "nobody has downloaded this" and
|
||||
// "this is not yours to know" is exactly what the caller renders,
|
||||
// and a missing key would collapse the two.
|
||||
$stats = [];
|
||||
|
||||
foreach ($own as $id) {
|
||||
$stats[$id] = ['count' => 0, 'last_at' => null];
|
||||
}
|
||||
|
||||
$rows = ActivityLog::query()
|
||||
->selectRaw('subject_id, count(*) as downloads, max(created_at) as last_at')
|
||||
->where('subject_type', (new File)->getMorphClass())
|
||||
->whereIn('subject_id', $own)
|
||||
->whereIn('action', [
|
||||
Action::FileDownloaded->value,
|
||||
Action::ShareLinkDownloaded->value,
|
||||
Action::PublicFileDownloaded->value,
|
||||
])
|
||||
->groupBy('subject_id')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$id = (int) $row->getAttribute('subject_id');
|
||||
$lastAt = $row->getAttribute('last_at');
|
||||
|
||||
$stats[$id] = [
|
||||
'count' => (int) $row->getAttribute('downloads'),
|
||||
'last_at' => $lastAt === null ? null : Carbon::parse((string) $lastAt)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
}
|
||||
@@ -159,15 +159,37 @@ class StaffLibraryScope
|
||||
return null;
|
||||
}
|
||||
|
||||
$clientIds = $this->assignableClientIds($user) ?? [];
|
||||
return array_values($this->groups($user)->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||||
}
|
||||
|
||||
if ($clientIds === []) {
|
||||
return [];
|
||||
/**
|
||||
* Every group this staff member may be told about, as a query.
|
||||
*
|
||||
* The listing half of assignableGroupIds(), and the same rule: a
|
||||
* group counts as theirs because one of their clients is in it. The
|
||||
* two were not the same code, and the listing simply had none — so
|
||||
* `/groups` and `/api/v1/groups` showed a scoped staff member every
|
||||
* group on the installation, name, description and member count,
|
||||
* including groups whose every member was somebody else's client
|
||||
* (GHSA-r3hg-3fxw-rcmr).
|
||||
*
|
||||
* Deliberately the *sharing* rule rather than the change rule below.
|
||||
* A scoped staff member may already share a file with a mixed group,
|
||||
* so its existence is not news to them; what they may not do is
|
||||
* rename, publish or delete it.
|
||||
*
|
||||
* @return Builder<Group>
|
||||
*/
|
||||
public function groups(User $user): Builder
|
||||
{
|
||||
$query = Group::query();
|
||||
$clientIds = $this->assignableClientIds($user);
|
||||
|
||||
if ($clientIds === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return array_values(Group::query()
|
||||
->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds))
|
||||
->pluck('id')->map(fn ($id): int => (int) $id)->all());
|
||||
return $query->whereHas('members', fn (Builder $members) => $members->whereIn('users.id', $clientIds));
|
||||
}
|
||||
|
||||
public function canAssignClient(User $user, User $client): bool
|
||||
@@ -249,7 +271,53 @@ class StaffLibraryScope
|
||||
*/
|
||||
public function allowsGroupChange(User $user, Group $group): bool
|
||||
{
|
||||
return $this->groupReachesNoFurther($user, $group);
|
||||
return $this->groupIsNotWhollySomebodyElses($user, $group)
|
||||
&& $this->groupReachesNoFurther($user, $group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this group is somebody else's entirely — every member
|
||||
* outside the staff member's roster, and none of theirs in it.
|
||||
*
|
||||
* The half allowsGroupChange() was missing. Reach answers "what would
|
||||
* this group hand somebody", which is the right question for putting a
|
||||
* client *into* it; it says nothing about who is already there. So a
|
||||
* group with nothing shared with it yet passed the reach check
|
||||
* vacuously, and a scoped staff member could rename it, delete it, or
|
||||
* publish it — a group made entirely of clients they had never been
|
||||
* assigned (GHSA-r3hg-3fxw-rcmr).
|
||||
*
|
||||
* **Not "every member is mine", which is the obvious reading and is
|
||||
* wrong.** A mixed group has to stay changeable: GHSA-whmp-p9hv-r7j7
|
||||
* settled that a scoped staff member opens such a group's edit screen
|
||||
* and is shown only their own clients in it, rather than being refused
|
||||
* the screen. Requiring every member to be theirs turns that narrowing
|
||||
* back into a 404 and undoes the earlier fix. What is left over — a
|
||||
* mixed group whose shared content reaches past their library — is
|
||||
* refused by groupReachesNoFurther() beside this, which is the check
|
||||
* that has always covered it.
|
||||
*
|
||||
* **And deliberately not folded into groupReachesNoFurther() either.**
|
||||
* That predicate is shared with allowsGroupMembership(), where a group
|
||||
* nobody has joined must stay usable so its creator can put the first
|
||||
* member in — the case that method's own docblock calls out.
|
||||
*
|
||||
* An empty group is nobody else's, so whoever just made it can still
|
||||
* name it.
|
||||
*/
|
||||
private function groupIsNotWhollySomebodyElses(User $user, Group $group): bool
|
||||
{
|
||||
$clientIds = $this->assignableClientIds($user);
|
||||
|
||||
if ($clientIds === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $group->members()->exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $group->members()->whereIn('users.id', $clientIds)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -61,7 +61,7 @@ class FileDelivery
|
||||
/**
|
||||
* The method in force, and whether it was detected or stated.
|
||||
*
|
||||
* @return array{method: DeliveryMethod, detected: bool}
|
||||
* @return array{method: DeliveryMethod, detected: bool, observed: bool}
|
||||
*/
|
||||
public function resolve(): array
|
||||
{
|
||||
@@ -69,10 +69,25 @@ class FileDelivery
|
||||
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
|
||||
|
||||
if ($explicit !== null) {
|
||||
return ['method' => $explicit, 'detected' => false];
|
||||
return ['method' => $explicit, 'detected' => false, 'observed' => true];
|
||||
}
|
||||
|
||||
return ['method' => $this->detect(), 'detected' => true];
|
||||
// Whether there was anything to detect *from*. detect() reads
|
||||
// SERVER_SOFTWARE, which only exists inside a request — so a
|
||||
// console process has nothing to look at and falls to the `php`
|
||||
// default. That default is right for the console (no web server is
|
||||
// handling this, so nothing could hand a file off), and wrong as a
|
||||
// statement about the installation, which is how somebody reading
|
||||
// it from `artisan tinker` will take it.
|
||||
//
|
||||
// Reported rather than papered over: a reader who runs
|
||||
// `describe()` from a shell on a perfectly good nginx box was
|
||||
// being told `php`, with `detected: true` vouching for it. That
|
||||
// cost somebody an afternoon before it was recognised as an
|
||||
// artefact of asking outside a request.
|
||||
$observed = is_string($this->request->server('SERVER_SOFTWARE'));
|
||||
|
||||
return ['method' => $this->detect(), 'detected' => true, 'observed' => $observed];
|
||||
}
|
||||
|
||||
public function method(): DeliveryMethod
|
||||
@@ -88,7 +103,12 @@ class FileDelivery
|
||||
* the installation from outside, and neither should change meaning if
|
||||
* the enum ever grows a JsonSerializable of its own.
|
||||
*
|
||||
* @return array{method: string, detected: bool}
|
||||
* `observed` is false only outside an HTTP request, where nothing can
|
||||
* be detected and `method` is a default rather than a finding. Both
|
||||
* screens that read this run in a request, so they always see true;
|
||||
* it exists for whoever asks from a console.
|
||||
*
|
||||
* @return array{method: string, detected: bool, observed: bool}
|
||||
*/
|
||||
public function describe(): array
|
||||
{
|
||||
@@ -100,6 +120,8 @@ class FileDelivery
|
||||
// to the reader: a detected `php` is an installation that
|
||||
// could be faster, a stated one is somebody's decision.
|
||||
'detected' => $resolved['detected'],
|
||||
// And whether the detection had anything to work with.
|
||||
'observed' => $resolved['observed'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -31,32 +31,65 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* path, BinaryFileResponse when PHP is streaming — each dropping the
|
||||
* Content-Length passed here in favour of the range actually served.
|
||||
*
|
||||
* The two paths are not equally revocable, which is why the lifetimes
|
||||
* below differ. Every local delivery method authorises one response and
|
||||
* no more — nginx's X-Accel-Redirect, Apache's X-Sendfile, or PHP
|
||||
* streaming the bytes itself: these bytes, now, to this request, and
|
||||
* nothing that outlives it. A presigned URL is a bearer
|
||||
* credential — whoever holds it can fetch the file without passing the
|
||||
* caller's checks again, and it outlives them: a download cap that is
|
||||
* spent in the meantime, an expires_at that falls in between, an
|
||||
* assignment that is withdrawn. Nothing here can revoke one, so the only
|
||||
* dial is how long it lasts.
|
||||
*
|
||||
* A download needs to survive being followed, which is a redirect and a
|
||||
* request: a minute is generous. A preview is held by the player for as
|
||||
* long as somebody watches, and each seek outside the buffer is a fresh
|
||||
* Range request against the same URL, so it keeps the hour. That is the
|
||||
* trade, stated rather than left in a single number.
|
||||
*
|
||||
* Callers of inline() must have established that the mime type is
|
||||
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
||||
* is one.
|
||||
*/
|
||||
class StoredFileResponse
|
||||
{
|
||||
/**
|
||||
* Long enough for a browser, a download manager or a queued transfer
|
||||
* to follow the redirect and start the request. An object store
|
||||
* checks the signature when the request arrives, not while it runs,
|
||||
* so a transfer that begins inside this window finishes however long
|
||||
* it takes.
|
||||
*/
|
||||
private const DOWNLOAD_LINK_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* A preview is watched, not fetched: the player holds this URL and
|
||||
* issues a Range request every time somebody seeks past the buffer,
|
||||
* so it has to outlive the viewing rather than the redirect.
|
||||
*/
|
||||
private const PREVIEW_LINK_SECONDS = 3600;
|
||||
|
||||
public function __construct(private readonly FileDelivery $delivery) {}
|
||||
|
||||
/** Shown in place — a preview. */
|
||||
public function inline(File $file): Response|RedirectResponse
|
||||
{
|
||||
return $this->make($file, ContentDisposition::inline($file->original_name));
|
||||
return $this->make($file, ContentDisposition::inline($file->original_name), self::PREVIEW_LINK_SECONDS);
|
||||
}
|
||||
|
||||
/** Handed over — a download. */
|
||||
public function attachment(File $file): Response|RedirectResponse
|
||||
{
|
||||
return $this->make($file, ContentDisposition::attachment($file->original_name));
|
||||
return $this->make($file, ContentDisposition::attachment($file->original_name), self::DOWNLOAD_LINK_SECONDS);
|
||||
}
|
||||
|
||||
private function make(File $file, string $disposition): Response|RedirectResponse
|
||||
private function make(File $file, string $disposition, int $linkSeconds): Response|RedirectResponse
|
||||
{
|
||||
if ($file->disk !== 'files') {
|
||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||
$file->path,
|
||||
now()->addHour(),
|
||||
now()->addSeconds($linkSeconds),
|
||||
['ResponseContentDisposition' => $disposition],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Events;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
|
||||
/**
|
||||
* A file's bytes are on a disk and its row exists.
|
||||
*
|
||||
* Dispatched from StoreUploadedFile, which every upload path goes through
|
||||
* — the chunked flow that staff and clients share, and the synchronous
|
||||
* POST beside it — so a listener sees every upload once and does not have
|
||||
* to know which route produced it.
|
||||
*
|
||||
* A notification rather than a filter: nothing here is mutable and no
|
||||
* listener can change what was stored. Anything that needs to influence
|
||||
* the upload has to do so before the bytes land, which is what
|
||||
* ResolvingUploadDisk is for.
|
||||
*
|
||||
* Fired after the row is created and before the caller has linked a
|
||||
* version or answered the request, so a listener sees a complete File and
|
||||
* can safely read it back.
|
||||
*/
|
||||
class FileWasStored
|
||||
{
|
||||
public function __construct(
|
||||
public readonly File $file,
|
||||
public readonly User $uploader,
|
||||
) {}
|
||||
}
|
||||
@@ -311,9 +311,12 @@ class FilesController extends Controller
|
||||
'download_limit_scope' => ['sometimes', Rule::enum(DownloadLimitScope::class)],
|
||||
]);
|
||||
|
||||
// Reparenting through update() must respect the same library scope as
|
||||
// Reparenting through update() must respect the same two rules as
|
||||
// the web move()/bulkUpdate() paths: the destination folder must be
|
||||
// one this user can see. Only enforced when folder_id actually
|
||||
// one this user can see, and one they may put content into. A public
|
||||
// destination publishes what lands in it, so the second question is
|
||||
// the one `upload_public` exists to ask and store() above already
|
||||
// asks (GHSA-rxf8-wh8v-jm9j). Only enforced when folder_id actually
|
||||
// changes, so re-saving a file that already sits in an out-of-scope
|
||||
// folder (reachable via a direct client share) still works. The
|
||||
// integer rule admits numeric strings, so cast before the strict
|
||||
@@ -322,7 +325,9 @@ class FilesController extends Controller
|
||||
$validated['folder_id'] = (int) $validated['folder_id'];
|
||||
|
||||
if ($validated['folder_id'] !== $file->folder_id) {
|
||||
$this->scope->folders($user)->findOrFail($validated['folder_id']);
|
||||
$destination = $this->scope->folders($user)->whereKey($validated['folder_id'])->firstOrFail();
|
||||
|
||||
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -91,12 +92,36 @@ class ChunkedUploadsController extends Controller
|
||||
$folder = isset($validated['folder_id']) ? Folder::query()->whereKey($validated['folder_id'])->first() : null;
|
||||
abort_unless(Folder::uploadableBy($user, $folder), 403);
|
||||
|
||||
// One session per file, and a person uploads a handful at a time.
|
||||
// A cap is here because nothing else counts sessions: for anyone
|
||||
// without a quota to spend — staff, and clients on an installation
|
||||
// that sets no quotas — the number of sessions is the only thing
|
||||
// standing between a declared size and any multiple of it.
|
||||
$openSessions = UploadSession::query()->where('user_id', $user->id)->count();
|
||||
$maxOpen = max(1, (int) config('projectsend.uploads.max_open_sessions'));
|
||||
|
||||
if ($openSessions >= $maxOpen) {
|
||||
throw ValidationException::withMessages([
|
||||
'filename' => __('Too many uploads are already in progress. Finish or cancel one and try again.'),
|
||||
]);
|
||||
}
|
||||
|
||||
// The declared size here is client-supplied and unverified until
|
||||
// complete()'s real assembled byte count — re-checked there too.
|
||||
if ($user->isClient()) {
|
||||
$quotaBytes = $this->storageUsage->quotaBytes($user);
|
||||
|
||||
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
|
||||
// Sessions already open count too, at the size they declared.
|
||||
// A quota measured against stored files alone is spent twice
|
||||
// over by opening the sessions one after another: each one is
|
||||
// told there is room, because the ones before it had not
|
||||
// finished and so had not become files. putPart() holds each
|
||||
// session to its declaration, so reserving the declarations
|
||||
// here is what puts bytes waiting on the temporary volume
|
||||
// under the same ceiling as bytes that landed.
|
||||
$pendingBytes = (int) UploadSession::query()->where('user_id', $user->id)->sum('size');
|
||||
|
||||
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + $pendingBytes + (int) $validated['size'] > $quotaBytes) {
|
||||
throw ValidationException::withMessages([
|
||||
'size' => __('This upload would exceed your storage quota of :quota MB.', [
|
||||
// The resolved quota, not the column: a client who
|
||||
@@ -187,13 +212,7 @@ class ChunkedUploadsController extends Controller
|
||||
// ownership of the session is still enforced below.
|
||||
$this->authorizeSession($request, $session);
|
||||
|
||||
// signPart() bounds the part number; bound the part body too, or a
|
||||
// session can absorb unlimited bytes. The quota is only enforceable
|
||||
// at complete(), against the assembled size — until then nothing
|
||||
// stops a client declaring a 1-byte upload and streaming gigabytes
|
||||
// of parts, which never becomes a File row and so never counts
|
||||
// against anything. Stale sessions are purged daily, so without a
|
||||
// cap here the exposure is a day's worth of disk.
|
||||
// signPart() bounds the part number; bound the part body too.
|
||||
abort_unless($part >= 1 && $part <= 10000, 422);
|
||||
|
||||
$maxPartBytes = max(1, (int) config('projectsend.upload_part_size_mb')) * 1024 * 1024;
|
||||
@@ -205,16 +224,48 @@ class ChunkedUploadsController extends Controller
|
||||
abort(413);
|
||||
}
|
||||
|
||||
// Bounding one request bounds one request, and nothing else. Ten
|
||||
// thousand part numbers at twice a 20 MB part is about 400 GB per
|
||||
// session, sessions were not counted against anything, and none of
|
||||
// it becomes a File row — so a client with a 1 MB quota could
|
||||
// declare a one-byte upload and fill the temporary volume, then do
|
||||
// it again. The session needs a ceiling of its own, and the room
|
||||
// for a part has to be claimed before the part is read: a body's
|
||||
// length is not known until it has arrived, and by then it is on
|
||||
// the disk this is protecting.
|
||||
//
|
||||
// The ceiling is the size the session declared, which store() has
|
||||
// already weighed against the file-size limit and the quota. So
|
||||
// what a part gets is whatever the session has left, and the write
|
||||
// is then capped at exactly that — an over-long body is cut off
|
||||
// mid-stream as it always was, just against a smaller number.
|
||||
$reserve = $this->reservePartRoom($session, $part, $limit);
|
||||
|
||||
if ($reserve < 1) {
|
||||
// 413 rather than 422: this is about the size of what is being
|
||||
// sent, and a client's resume logic already understands it. The
|
||||
// session survives — the parts it holds are untouched, and it
|
||||
// can still be completed or aborted.
|
||||
abort(413);
|
||||
}
|
||||
|
||||
$stream = $request->getContent(true);
|
||||
|
||||
try {
|
||||
$etag = $this->parts->storePart($session, $part, $stream, $limit);
|
||||
$etag = $this->parts->storePart($session, $part, $stream, $reserve);
|
||||
} catch (PartTooLargeException) {
|
||||
abort(413);
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
// In the finally, because every way out of here needs it: the
|
||||
// refused part was deleted and weighs nothing, a client that
|
||||
// hung up left a short one, and a clean write leaves exactly
|
||||
// what it reserved. Without this a client's own retries would
|
||||
// slowly exhaust a session that has plenty of room.
|
||||
$session->settleStaged($reserve, $this->parts->partSize($session, $part));
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
@@ -231,6 +282,52 @@ class ChunkedUploadsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim room for one part, returning how many bytes were claimed — 0
|
||||
* when the session has none left.
|
||||
*
|
||||
* Read-then-claim, under a lock held for the two statements and not
|
||||
* for the transfer. The protocol sends parts in parallel and how many
|
||||
* is the client's choice, so without it every part in flight reads the
|
||||
* same "room left" and they all claim it; and making the claim alone
|
||||
* atomic is no better, because then the honest parallel upload is the
|
||||
* one that gets refused. The lock is the same per-session shape
|
||||
* complete() already uses, and it is released before a byte is read.
|
||||
*/
|
||||
private function reservePartRoom(UploadSession $session, int $part, int $limit): int
|
||||
{
|
||||
$lock = Cache::lock('upload-part:'.$session->id, 30);
|
||||
|
||||
try {
|
||||
$lock->block(15);
|
||||
} catch (LockTimeoutException) {
|
||||
// Nothing is wrong with the upload — the queue for this one
|
||||
// session just did not clear. 503 with Retry-After is what the
|
||||
// client's own backoff is for.
|
||||
abort(503, headers: ['Retry-After' => '5']);
|
||||
}
|
||||
|
||||
try {
|
||||
$existing = $this->parts->partSize($session, $part);
|
||||
|
||||
$session->refresh();
|
||||
|
||||
// Re-sending a part replaces it rather than adding to it, so
|
||||
// what it already holds is room this request may spend again.
|
||||
// That is an ordinary resume.
|
||||
$room = max(0, $session->size - ($session->staged_bytes - $existing));
|
||||
$reserve = min($limit, $room);
|
||||
|
||||
if ($reserve > 0 && ! $session->reserveStaged($reserve, $existing)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $reserve;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the parts already received, so an interrupted upload can resume
|
||||
* rather than start again.
|
||||
|
||||
@@ -286,7 +286,11 @@ class FilesController extends Controller
|
||||
// an out-of-scope folder (reachable via a direct client share) still
|
||||
// works.
|
||||
if ($folderId !== null && $folderId !== $file->folder_id) {
|
||||
$this->scope->folders($user)->findOrFail($folderId);
|
||||
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
|
||||
|
||||
// And one they may publish into, if it is public. Reparenting
|
||||
// through the edit form is the same privileged write as move().
|
||||
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||
}
|
||||
|
||||
// Normalised into the shape ApplyFileEdits reads, then handed
|
||||
@@ -344,9 +348,18 @@ class FilesController extends Controller
|
||||
$folderId = $validated['folder_id'] ?? null;
|
||||
$user = $request->user();
|
||||
|
||||
// The target folder must be one the mover can actually see.
|
||||
if ($folderId !== null && $user !== null) {
|
||||
$this->scope->folders($user)->findOrFail($folderId);
|
||||
// The target folder must be one the mover can actually see, and one
|
||||
// they are allowed to put content into. Those are two questions:
|
||||
// a file in a public folder is published by being there, so the
|
||||
// destination reaches the property `upload_public` guards without
|
||||
// anybody touching the switch. Asking only the first let an editor
|
||||
// who is deliberately not allowed to publish do it by dragging
|
||||
// (GHSA-rxf8-wh8v-jm9j — the move half of GHSA-237r-jx85-j3hr,
|
||||
// whose fix was wired into the upload paths and no further).
|
||||
if ($folderId !== null && $user !== null && $folderId !== $file->folder_id) {
|
||||
$destination = $this->scope->folders($user)->whereKey($folderId)->firstOrFail();
|
||||
|
||||
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||
}
|
||||
|
||||
$file->update(['folder_id' => $folderId]);
|
||||
@@ -403,13 +416,19 @@ class FilesController extends Controller
|
||||
&& ($validated['remove_category_ids'] ?? []) === [];
|
||||
abort_if($touchesNothing, 422, __('Change at least one field before applying a bulk edit.'));
|
||||
|
||||
// The target folder must be one this user can actually see — same
|
||||
// rule move() already applies to a single file's target.
|
||||
// The target folder must be one this user can actually see, and one
|
||||
// they may put content into — the same two questions move() asks of
|
||||
// a single file's target. Checked once, on the destination, rather
|
||||
// than per file: the destination is one folder for the whole batch,
|
||||
// and if putting content there publishes it then no file in the
|
||||
// batch may go.
|
||||
$targetFolderId = null;
|
||||
if ($validated['folder_action'] === 'move') {
|
||||
$targetFolderId = $validated['folder_id'] ?? null;
|
||||
if ($targetFolderId !== null) {
|
||||
$this->scope->folders($user)->findOrFail($targetFolderId);
|
||||
$destination = $this->scope->folders($user)->whereKey($targetFolderId)->firstOrFail();
|
||||
|
||||
abort_unless(Folder::uploadableBy($user, $destination), 403);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -402,7 +402,20 @@ class FoldersController extends Controller
|
||||
'parent_id' => Rules::folderId(),
|
||||
]);
|
||||
|
||||
$newParent = $this->resolveParent($request->user(), $validated['parent_id'] ?? null);
|
||||
$user = $request->user();
|
||||
$newParent = $this->resolveParent($user, $validated['parent_id'] ?? null);
|
||||
|
||||
// A folder carries its contents with it, and a folder inside a
|
||||
// public one is public — isEffectivelyPublic() reads the whole
|
||||
// ancestry. So dropping a private folder into a public parent
|
||||
// publishes every file in its subtree at once, which is the same
|
||||
// act the upload path refuses without `upload_public`. The flag on
|
||||
// this screen is already guarded (update() above leaves public
|
||||
// state alone without the permission); the placement was not
|
||||
// (GHSA-rxf8-wh8v-jm9j).
|
||||
if ($user !== null) {
|
||||
abort_unless(Folder::uploadableBy($user, $newParent), 403);
|
||||
}
|
||||
|
||||
$this->folders->move($folder, $newParent);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Modules\Comments\Access\VisibleCommentScope;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Comments\CommentScope;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\OwnFileDownloads;
|
||||
use App\Modules\Files\DownloadLimitScope;
|
||||
use App\Modules\Files\Editing\ApplyFileEdits;
|
||||
use App\Modules\Files\Editing\FileExpiry;
|
||||
@@ -19,6 +20,7 @@ use App\Modules\Files\Folders\BreadcrumbBuilder;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Sharing\ClientShareLinks;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Files\Versions\FileVersionLinks;
|
||||
use App\Modules\Files\Versions\FileVersions;
|
||||
@@ -73,6 +75,8 @@ class MyFilesController extends Controller
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly FileVersions $versions,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly ClientShareLinks $shareLinks,
|
||||
private readonly OwnFileDownloads $ownDownloads,
|
||||
private readonly ApplyFileEdits $fileEdits,
|
||||
private readonly FileExpiry $expiry,
|
||||
private readonly ActivityLogger $activity,
|
||||
@@ -224,6 +228,14 @@ class MyFilesController extends Controller
|
||||
// docs/theming-files-checklist.md).
|
||||
$versions = $this->versionLinks->forMany($fileRows, $client);
|
||||
$unreadComments = $this->comments->unreadCountsFor($client, array_values(array_map(intval(...), $fileRows->pluck('id')->all())));
|
||||
// One query for the page. Already narrowed to links this client
|
||||
// minted on files this client uploaded — see ClientShareLinks for
|
||||
// why both halves are required.
|
||||
$shareUrls = $this->shareLinks->forMany($fileRows, $client);
|
||||
// Also one query for the page, and also own files only — see
|
||||
// OwnFileDownloads for why telling a recipient the count would be
|
||||
// telling them about the other recipients.
|
||||
$downloads = $this->ownDownloads->forMany($fileRows, $client);
|
||||
|
||||
return Inertia::render("portal/themes/{$this->themeKey()}/my-files", [
|
||||
'folder' => $current === null ? null : ['id' => $current->id, 'name' => $current->name],
|
||||
@@ -267,6 +279,19 @@ class MyFilesController extends Controller
|
||||
// counterpart they were not given is null, not hidden by
|
||||
// the theme. A theme must never filter this itself.
|
||||
'version' => $versions[$file->id] ?? ['previous' => null, 'next' => null],
|
||||
// The public URL for a file of their own, where one
|
||||
// exists. Null on a file somebody shared with them, and
|
||||
// null on their own file that has no link — a client has
|
||||
// no way to mint one, so this is populated only where the
|
||||
// installation did it for them. Never derived from
|
||||
// is_mine: a theme renders what is here and nothing else.
|
||||
'share_url' => $shareUrls[$file->id] ?? null,
|
||||
// How often this went out and when it last did — the
|
||||
// answer to "did it arrive?", which on a link-only
|
||||
// account is the only evidence there is. Null on a file
|
||||
// somebody shared with this client: not zero, which would
|
||||
// be a claim about other people's activity, but nothing.
|
||||
'downloads' => $downloads[$file->id] ?? null,
|
||||
'categories' => $file->categories->map(fn (Category $category): array => [
|
||||
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
|
||||
])->values()->all(),
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use App\Modules\Files\Sharing\CreateShareLink;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -30,6 +31,7 @@ class ShareLinksController extends Controller
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly CreateShareLink $links,
|
||||
) {}
|
||||
|
||||
public function store(Request $request, File $file): RedirectResponse
|
||||
@@ -80,16 +82,16 @@ class ShareLinksController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
ShareLink::query()->create([
|
||||
'shareable_type' => $file->getMorphClass(),
|
||||
'shareable_id' => $file->id,
|
||||
'token' => $validated['token'] ?? Str::random(32),
|
||||
'created_by' => $user->id,
|
||||
'expires_at' => $user->can('set_file_expiration_date') ? $expiresAt : null,
|
||||
'max_downloads' => $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
|
||||
]);
|
||||
|
||||
$this->activity->log(Action::ShareLinkCreated, subject: $file);
|
||||
// The permission gates stay here, where the request is: whether
|
||||
// this person may set an expiry or a cap is a fact about them,
|
||||
// not about link creation, and the action has no viewer to ask.
|
||||
$this->links->for(
|
||||
file: $file,
|
||||
creator: $user,
|
||||
expiresAt: $user->can('set_file_expiration_date') ? $expiresAt : null,
|
||||
maxDownloads: $user->can('limit_downloads') ? $validated['max_downloads'] ?? null : null,
|
||||
token: $validated['token'] ?? null,
|
||||
);
|
||||
|
||||
return back()->with('success', __('Public link created.'));
|
||||
}
|
||||
|
||||
@@ -152,15 +152,26 @@ class Folder extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $user may upload a new file directly into $folder (null =
|
||||
* loose at the root, always allowed).
|
||||
* Whether $user may put content into $folder (null = loose at the
|
||||
* root, always allowed).
|
||||
*
|
||||
* **Read the name as "may place into", not "may upload into".** Every
|
||||
* way a file arrives in a folder has to come through here, and the
|
||||
* name cost us one advisory already: the publication rule below was
|
||||
* written for GHSA-237r-jx85-j3hr and wired into the upload paths
|
||||
* alone, because those are what the name suggested. Moving a file in,
|
||||
* bulk-moving a selection in, reparenting one through the edit form,
|
||||
* and dragging a whole folder into a public parent all put content
|
||||
* somewhere too, and none of them asked (GHSA-rxf8-wh8v-jm9j). They
|
||||
* ask now. Anything new that writes a `folder_id` or a `parent_id`
|
||||
* belongs on this list.
|
||||
*
|
||||
* Staff are held to the library boundary they are held to everywhere
|
||||
* else: an unscoped staff member may use any folder, a client-scoped
|
||||
* one only the folders StaffLibraryScope already shows them. This is
|
||||
* the only place that decides it: every upload path — the web form,
|
||||
* the API and the chunked flow the browser actually posts to — comes
|
||||
* through here rather than checking folder_id for itself.
|
||||
* one only the folders StaffLibraryScope already shows them. Callers
|
||||
* that have already resolved the destination through
|
||||
* StaffLibraryScope::folders() have answered that half — the two are
|
||||
* the same query — and call this for the publication half.
|
||||
*
|
||||
* For a client this is unchanged, and is still the whole of the
|
||||
* check: they own the folder, or it is a public folder that opts into
|
||||
@@ -174,7 +185,30 @@ class Folder extends Model
|
||||
}
|
||||
|
||||
if ($user->isStaff()) {
|
||||
return app(StaffLibraryScope::class)->allowsFolder($user, $folder);
|
||||
if (! app(StaffLibraryScope::class)->allowsFolder($user, $folder)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Being allowed to reach the folder is not the same as being
|
||||
// allowed to publish, and putting a file in a public folder
|
||||
// publishes it: isEffectivelyPublic() is "my own flag, or my
|
||||
// folder's". So the destination reaches the property that
|
||||
// `upload_public` guards, without ever touching the switch
|
||||
// (GHSA-237r-jx85-j3hr).
|
||||
//
|
||||
// The keys already say this. The client branch below has always
|
||||
// asked for `upload_to_public_folders` here, and
|
||||
// MyFilesController's picker calls that the established meaning
|
||||
// of the two — it was simply never asked on a staff role, which
|
||||
// left that permission doing nothing at all for staff.
|
||||
//
|
||||
// Effectively public, not `public`: the flag is inherited down
|
||||
// a subtree, so a private folder inside a public one publishes
|
||||
// just the same and a check on the folder's own flag would walk
|
||||
// straight past it.
|
||||
return ! $folder->isEffectivelyPublic()
|
||||
|| $user->can('upload_public')
|
||||
|| $user->can('upload_to_public_folders');
|
||||
}
|
||||
|
||||
return $folder->isOwnedBy($user)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Sharing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* The public URLs a client may be shown for their own files.
|
||||
*
|
||||
* A client's portal lists two kinds of file side by side: what they
|
||||
* uploaded, and what somebody shared with them. Both can carry share
|
||||
* links, and only one kind of link is theirs to see — a link a staff
|
||||
* member minted for a file they were given is that staff member's
|
||||
* decision about who may reach it, and handing the recipient the URL
|
||||
* would turn "you may download this" into "you may pass this on to
|
||||
* anyone".
|
||||
*
|
||||
* So the rule is narrow and stated once: **a link this client created, on
|
||||
* a file this client uploaded.** Both halves, not either. Neither is
|
||||
* redundant — a client-created link on a file they no longer own would
|
||||
* outlive a reassignment, and a staff link on their own upload is still
|
||||
* not theirs to hand out.
|
||||
*
|
||||
* Inactive links are left out rather than shown greyed: the only thing a
|
||||
* client can do with this is copy it, and a URL that answers "this link
|
||||
* has expired" is worse than no URL at all.
|
||||
*
|
||||
* Resolved for a whole page at a time. One query for the listing, not one
|
||||
* per row.
|
||||
*/
|
||||
class ClientShareLinks
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, File> $files
|
||||
* @return array<int, string> file id => URL, for the files that have one
|
||||
*/
|
||||
public function forMany(Collection $files, User $client): array
|
||||
{
|
||||
$own = $files
|
||||
->filter(fn (File $file): bool => $file->uploaded_by === $client->id)
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if ($own === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$links = ShareLink::query()
|
||||
->where('shareable_type', (new File)->getMorphClass())
|
||||
->whereIn('shareable_id', $own)
|
||||
->where('created_by', $client->id)
|
||||
// Oldest first, so a file that somehow carries two is
|
||||
// described by the one the client has already been given
|
||||
// rather than by whichever the database returned today.
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$urls = [];
|
||||
|
||||
foreach ($links as $link) {
|
||||
$fileId = (int) $link->shareable_id;
|
||||
|
||||
if (isset($urls[$fileId]) || ! $link->isActive()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$urls[$fileId] = route('share.show', $link->token);
|
||||
}
|
||||
|
||||
return $urls;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Sharing;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Minting a public link for a file, in one place.
|
||||
*
|
||||
* Extracted from ShareLinksController rather than invented: the
|
||||
* controller is an HTTP handler behind `staff` middleware, and a link now
|
||||
* needs creating from outside a request as well. Two copies of "make a
|
||||
* token, write the row, log it" would drift, and the half most likely to
|
||||
* drift is the token.
|
||||
*
|
||||
* **The token is the whole authorization.** There is nothing behind
|
||||
* /s/{token} — no session, no second factor — so its only defence is
|
||||
* being unguessable. Str::random(32) is about 190 bits, which is more
|
||||
* than a UUID's 122; anything minted here gets that and never a chosen
|
||||
* value. A caller that wants a chosen token is a person typing one into a
|
||||
* form, and that path stays in the controller where its minimum length
|
||||
* can be argued about in a validation rule.
|
||||
*
|
||||
* Expiry and download caps are the caller's to decide and are passed in
|
||||
* already resolved, because "the end of the 12th" depends on whose zone
|
||||
* you are in and this class has no viewer.
|
||||
*/
|
||||
class CreateShareLink
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
public function for(
|
||||
File $file,
|
||||
User $creator,
|
||||
?CarbonInterface $expiresAt = null,
|
||||
?int $maxDownloads = null,
|
||||
?string $token = null,
|
||||
): ShareLink {
|
||||
$link = ShareLink::query()->create([
|
||||
'shareable_type' => $file->getMorphClass(),
|
||||
'shareable_id' => $file->id,
|
||||
'token' => $token ?? Str::random(32),
|
||||
'created_by' => $creator->id,
|
||||
'expires_at' => $expiresAt,
|
||||
'max_downloads' => $maxDownloads,
|
||||
]);
|
||||
|
||||
$this->activity->log(Action::ShareLinkCreated, subject: $file);
|
||||
|
||||
return $link;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ namespace App\Modules\Files\Thumbnails;
|
||||
|
||||
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
||||
use claviska\SimpleImage;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -102,12 +104,111 @@ class ThumbnailGenerator
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a render may hold the lock before another request is
|
||||
* entitled to assume it died. Generous: a 40-megapixel decode is a
|
||||
* second or two, and an external source is copied local first.
|
||||
*/
|
||||
private const LOCK_SECONDS = 120;
|
||||
|
||||
/**
|
||||
* How long to wait for the request that got there first.
|
||||
*
|
||||
* Waiting costs an idle worker — about 35 MB. Rendering costs that
|
||||
* plus four bytes per source pixel, up to 160 MB at the megapixel
|
||||
* ceiling. Waiting is the cheap option by an order of magnitude,
|
||||
* which is the whole reason this exists.
|
||||
*
|
||||
* Configurable because the right number depends on how long a decode
|
||||
* takes here, and that is a property of the machine rather than of
|
||||
* the application: a small VPS reading a large source off a slow disk
|
||||
* wants longer than this, and nothing in the code can know that.
|
||||
*/
|
||||
private const DEFAULT_LOCK_WAIT_SECONDS = 15;
|
||||
|
||||
/**
|
||||
* Render one image, once, however many requests ask at the same time.
|
||||
*
|
||||
* **Why the lock.** Renditions are generated on demand and cached by
|
||||
* existence, and nothing between the callers stopped two requests
|
||||
* rendering the same image at once. The atomic rename below settles
|
||||
* which file survives — it never stopped both from decoding. So N
|
||||
* concurrent requests for one cold rendition were N full-size decodes,
|
||||
* each holding four bytes per source pixel.
|
||||
*
|
||||
* That is not an attack. A public listing emits a thumbnail URL per
|
||||
* file, a browser opens six or more connections at once, and the first
|
||||
* visit to a gallery of ordinary camera images is six simultaneous
|
||||
* decodes on a container sized for one. It kills the container, and
|
||||
* because a killed render writes nothing, the cache never warms: the
|
||||
* page dies again on the next visit. `PublicGroupsController` reaches
|
||||
* here with no account at all.
|
||||
*
|
||||
* **Why waiting rather than refusing.** The request that waits holds
|
||||
* an idle worker. The request that renders holds a worker plus the
|
||||
* whole source bitmap. Six waiters cost what one renderer costs, so
|
||||
* blocking is the cheap answer even when it looks like the slow one.
|
||||
*
|
||||
* **Why the re-check after acquiring.** The winner has finished by the
|
||||
* time a waiter gets in, so the file it was waiting for is already
|
||||
* there. Re-reading is what turns a wait into a cache hit rather than
|
||||
* a second render of the same image.
|
||||
*/
|
||||
public function generate(
|
||||
string $sourcePath,
|
||||
string $destinationPath,
|
||||
string $mimeType,
|
||||
ImageAudience $audience,
|
||||
ImageRendition $rendition,
|
||||
): void {
|
||||
// Keyed on the destination, which already encodes the file, the
|
||||
// audience and the rendition — two requests collide here exactly
|
||||
// when they would have written the same path.
|
||||
$lock = Cache::lock('rendition:'.sha1($destinationPath), self::LOCK_SECONDS);
|
||||
|
||||
try {
|
||||
$lock->block($this->lockWaitSeconds());
|
||||
} catch (LockTimeoutException) {
|
||||
// Deliberately not rendering anyway. Falling through on
|
||||
// timeout would reinstate exactly the pile-on this exists to
|
||||
// stop, at the moment the system is already struggling — one
|
||||
// failed thumbnail is a better outcome than a container that
|
||||
// dies and takes the warm cache with it.
|
||||
throw new RuntimeException('Timed out waiting for another request to render this image.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Somebody else rendered it while we waited. An empty file is
|
||||
// not a rendition — same rule the callers apply, and the same
|
||||
// reason: nothing invalidates one once it is cached.
|
||||
if (is_file($destinationPath) && filesize($destinationPath) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->render($sourcePath, $destinationPath, $mimeType, $audience, $rendition);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamped to at least a second: a zero would make every concurrent
|
||||
* request fail instead of waiting, which is the opposite of the point
|
||||
* and exactly what a stray empty environment variable produces.
|
||||
*/
|
||||
private function lockWaitSeconds(): int
|
||||
{
|
||||
$configured = config('projectsend.rendition_lock_wait_seconds');
|
||||
|
||||
return max(1, is_numeric($configured) ? (int) $configured : self::DEFAULT_LOCK_WAIT_SECONDS);
|
||||
}
|
||||
|
||||
private function render(
|
||||
string $sourcePath,
|
||||
string $destinationPath,
|
||||
string $mimeType,
|
||||
ImageAudience $audience,
|
||||
ImageRendition $rendition,
|
||||
): void {
|
||||
$dimensions = @getimagesize($sourcePath);
|
||||
|
||||
|
||||
@@ -27,6 +27,12 @@ use Throwable;
|
||||
*/
|
||||
class LocalPartStore
|
||||
{
|
||||
/**
|
||||
* Said twice, because a full temp volume can announce itself in the
|
||||
* middle of the copy or only when the last buffer is flushed.
|
||||
*/
|
||||
private const WRITE_FAILED = 'Could not assemble the upload: writing to the temporary directory failed.';
|
||||
|
||||
/**
|
||||
* The route name is a parameter because the same flow is mounted twice:
|
||||
* once on the session-authenticated web routes for the browser, once on
|
||||
@@ -112,6 +118,25 @@ class LocalPartStore
|
||||
return md5_file($path) ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What one part number currently weighs on disk, 0 if it has never
|
||||
* arrived. Read before and after a part is received, so the session's
|
||||
* reservation can be settled against what is really there rather than
|
||||
* against what the request claimed it would send.
|
||||
*/
|
||||
public function partSize(UploadSession $session, int $partNumber): int
|
||||
{
|
||||
$path = $this->partPath($session, $partNumber);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
clearstatcache(true, $path);
|
||||
|
||||
return (int) (filesize($path) ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{PartNumber: int, Size: int, ETag: string}>
|
||||
*/
|
||||
@@ -140,9 +165,21 @@ class LocalPartStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-append parts in order onto the files disk, hashing as we
|
||||
* go. Peak temp usage ≈ file size + one part (parts are unlinked
|
||||
* as they are consumed).
|
||||
* Stream-append parts in order onto the files disk, hashing as we go.
|
||||
*
|
||||
* The parts stay on disk until the assembled bytes are safely on the
|
||||
* target disk. ChunkedUploadsController's completion lock promises that
|
||||
* "a later retry still works", and everything that can fail after the
|
||||
* concatenation — reopening the copy, a disk refusing the write, the
|
||||
* File row itself — happens while the client has nothing but this
|
||||
* session to retry with. Unlinking each part as it was consumed left
|
||||
* listParts() empty, so every later complete() answered "Upload is
|
||||
* incomplete: missing parts" for good.
|
||||
*
|
||||
* The cost is temp space: peak usage is the whole file twice over
|
||||
* (every part, plus the assembled copy) rather than the file plus one
|
||||
* part. Both are freed by the abort() below the moment the write lands,
|
||||
* and by the failure path the moment it does not.
|
||||
*
|
||||
* @return array{path: string, disk: string, size: int, checksum: string}
|
||||
*/
|
||||
@@ -158,6 +195,93 @@ class LocalPartStore
|
||||
}
|
||||
|
||||
$assembledPath = $this->directory($session).'/assembled';
|
||||
|
||||
try {
|
||||
[$size, $checksum] = $this->concatenate($session, $parts, $assembledPath);
|
||||
|
||||
$readStream = fopen($assembledPath, 'rb');
|
||||
|
||||
if ($readStream === false) {
|
||||
throw new RuntimeException('Could not reopen assembled file.');
|
||||
}
|
||||
|
||||
$diskEvent = new ResolvingUploadDisk($session->user);
|
||||
Event::dispatch($diskEvent);
|
||||
$disk = $diskEvent->disk;
|
||||
|
||||
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||
|
||||
if (is_resource($readStream)) {
|
||||
fclose($readStream);
|
||||
}
|
||||
|
||||
// The disks are configured with 'throw' => false, so a refused
|
||||
// write is a `false` return rather than an exception — and the
|
||||
// caller goes on to record a File row for bytes that were never
|
||||
// stored. Losing an upload silently is worse than failing it, and
|
||||
// this is the only place that can tell the difference: a real
|
||||
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
||||
// which looked exactly like a successful upload.
|
||||
if ($written === false) {
|
||||
// The reason is lost by the time it gets here — 'throw' => false
|
||||
// means Flysystem swallowed the exception rather than passing it
|
||||
// on — so log what was attempted. Which bucket it was is the
|
||||
// difference between reading this as "my credentials expired"
|
||||
// and "I typed the wrong bucket name", and only the log can say
|
||||
// it: the message below is shown to whoever was uploading, which
|
||||
// includes clients, and a bucket name is not theirs to see.
|
||||
Log::error('Upload could not be written to storage.', [
|
||||
'disk' => $disk,
|
||||
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
||||
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
||||
'path' => $targetPath,
|
||||
]);
|
||||
|
||||
throw new RuntimeException(
|
||||
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
||||
.'Check the storage backend is reachable and its credentials are still valid.'
|
||||
);
|
||||
}
|
||||
} catch (Throwable $failure) {
|
||||
// The half-written copy belongs to this attempt and the next one
|
||||
// makes its own; the parts belong to the client, and they are
|
||||
// what a retry needs. Deleting the copy here is also the only
|
||||
// thing that removes it at all on this path — it used to sit in
|
||||
// the session directory until the sweeper came round.
|
||||
FileSystem::delete($assembledPath);
|
||||
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
$this->abort($session);
|
||||
|
||||
return [
|
||||
'path' => $targetPath,
|
||||
'disk' => $disk,
|
||||
'size' => $size,
|
||||
'checksum' => $checksum,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate the parts into $assembledPath, returning the byte count
|
||||
* and the sha256 of what was written.
|
||||
*
|
||||
* Every read and every write is checked. They were not, and while a
|
||||
* failing fwrite on a full volume is loud in practice — Laravel's
|
||||
* error handler turns the warning into an ErrorException — loud there
|
||||
* means a 500 carrying a PHP message, where the disk-refused-the-write
|
||||
* case a few lines above becomes a sentence the person uploading can
|
||||
* act on. A short write arriving without a warning would be worse
|
||||
* still: $size and the hash describe the buffer that was read, so an
|
||||
* unchecked one yields a truncated file with a checksum matching bytes
|
||||
* that were never stored.
|
||||
*
|
||||
* @param list<array{PartNumber: int, Size: int, ETag: string}> $parts
|
||||
* @return array{0: int, 1: string}
|
||||
*/
|
||||
private function concatenate(UploadSession $session, array $parts, string $assembledPath): array
|
||||
{
|
||||
$out = fopen($assembledPath, 'wb');
|
||||
|
||||
if ($out === false) {
|
||||
@@ -167,85 +291,46 @@ class LocalPartStore
|
||||
$hash = hash_init('sha256');
|
||||
$size = 0;
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$partPath = $this->partPath($session, $part['PartNumber']);
|
||||
$in = fopen($partPath, 'rb');
|
||||
try {
|
||||
foreach ($parts as $part) {
|
||||
$in = fopen($this->partPath($session, $part['PartNumber']), 'rb');
|
||||
|
||||
if ($in === false) {
|
||||
fclose($out);
|
||||
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||
}
|
||||
|
||||
while (! feof($in)) {
|
||||
$buffer = fread($in, 1024 * 1024);
|
||||
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
if ($in === false) {
|
||||
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||
}
|
||||
|
||||
fwrite($out, $buffer);
|
||||
hash_update($hash, $buffer);
|
||||
$size += strlen($buffer);
|
||||
try {
|
||||
while (! feof($in)) {
|
||||
$buffer = fread($in, 1024 * 1024);
|
||||
|
||||
if ($buffer === false) {
|
||||
throw new RuntimeException('Could not read part '.$part['PartNumber'].'.');
|
||||
}
|
||||
|
||||
if ($buffer !== '' && @fwrite($out, $buffer) !== strlen($buffer)) {
|
||||
throw new RuntimeException(self::WRITE_FAILED);
|
||||
}
|
||||
|
||||
hash_update($hash, $buffer);
|
||||
$size += strlen($buffer);
|
||||
}
|
||||
} finally {
|
||||
fclose($in);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $failure) {
|
||||
fclose($out);
|
||||
|
||||
fclose($in);
|
||||
unlink($partPath);
|
||||
throw $failure;
|
||||
}
|
||||
|
||||
fclose($out);
|
||||
|
||||
$readStream = fopen($assembledPath, 'rb');
|
||||
|
||||
if ($readStream === false) {
|
||||
throw new RuntimeException('Could not reopen assembled file.');
|
||||
// fclose flushes, so a volume that filled up on the last buffer
|
||||
// fails here rather than in the loop.
|
||||
if (! fclose($out)) {
|
||||
throw new RuntimeException(self::WRITE_FAILED);
|
||||
}
|
||||
|
||||
$diskEvent = new ResolvingUploadDisk($session->user);
|
||||
Event::dispatch($diskEvent);
|
||||
$disk = $diskEvent->disk;
|
||||
|
||||
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
|
||||
|
||||
if (is_resource($readStream)) {
|
||||
fclose($readStream);
|
||||
}
|
||||
|
||||
// The disks are configured with 'throw' => false, so a refused
|
||||
// write is a `false` return rather than an exception — and the
|
||||
// caller goes on to record a File row for bytes that were never
|
||||
// stored. Losing an upload silently is worse than failing it, and
|
||||
// this is the only place that can tell the difference: a real
|
||||
// instance of it was a GCS bucket rejecting the adapter's ACL,
|
||||
// which looked exactly like a successful upload.
|
||||
if ($written === false) {
|
||||
// The reason is lost by the time it gets here — 'throw' => false
|
||||
// means Flysystem swallowed the exception rather than passing it
|
||||
// on — so log what was attempted. Which bucket it was is the
|
||||
// difference between reading this as "my credentials expired"
|
||||
// and "I typed the wrong bucket name", and only the log can say
|
||||
// it: the message below is shown to whoever was uploading, which
|
||||
// includes clients, and a bucket name is not theirs to see.
|
||||
Log::error('Upload could not be written to storage.', [
|
||||
'disk' => $disk,
|
||||
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
|
||||
'driver' => config('filesystems.disks.'.$disk.'.driver'),
|
||||
'path' => $targetPath,
|
||||
]);
|
||||
|
||||
throw new RuntimeException(
|
||||
'Could not write the assembled upload to the "'.$disk.'" disk. '
|
||||
.'Check the storage backend is reachable and its credentials are still valid.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->abort($session);
|
||||
|
||||
return [
|
||||
'path' => $targetPath,
|
||||
'disk' => $disk,
|
||||
'size' => $size,
|
||||
'checksum' => hash_final($hash),
|
||||
];
|
||||
return [$size, hash_final($hash)];
|
||||
}
|
||||
|
||||
public function abort(UploadSession $session): void
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Uploads;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Events\FileWasStored;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
@@ -52,6 +54,13 @@ class StoreUploadedFile
|
||||
|
||||
$this->activity->log($action, $uploader, $file);
|
||||
|
||||
// Every upload path converges here — the chunked flow staff and
|
||||
// clients share, and the synchronous POST beside it — so a
|
||||
// listener sees each upload once without knowing which route
|
||||
// produced it. Dispatched after the row exists, so what it
|
||||
// receives is a complete File.
|
||||
Event::dispatch(new FileWasStored($file, $uploader));
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Files\Models\Folder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* A resumable chunked upload in progress. Parts live on disk under the
|
||||
@@ -20,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property int|null $previous_file_id
|
||||
* @property string $original_name
|
||||
* @property int $size
|
||||
* @property int $staged_bytes
|
||||
* @property string|null $mime_type
|
||||
* @property string|null $description
|
||||
* @property string $status
|
||||
@@ -53,4 +55,71 @@ class UploadSession extends Model
|
||||
{
|
||||
return $this->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim room on the temporary volume for a part that is about to
|
||||
* arrive, returning false if the session has no room left.
|
||||
*
|
||||
* The claim is made before the bytes are read, and it is one
|
||||
* statement, because neither weaker version holds. Checking the part
|
||||
* directory and then writing leaves a gap that every other part
|
||||
* currently in flight fits through — and the protocol sends parts in
|
||||
* parallel, so the number of them is the caller's choice, not ours.
|
||||
* Charging the real size afterwards is the same gap by another name.
|
||||
*
|
||||
* $replacing is what the part number already holds, since re-sending a
|
||||
* part overwrites it rather than adding to it. That is an ordinary
|
||||
* resume, not an attack.
|
||||
*
|
||||
* The ceiling is the size the session declared. A client cannot stage
|
||||
* more than it said it was sending, which is the invariant the whole
|
||||
* fix rests on: store() has already measured that declaration against
|
||||
* the file-size limit and the storage quota, so bounding staged bytes
|
||||
* by it puts temporary bytes under the same limits as stored ones.
|
||||
*/
|
||||
public function reserveStaged(int $bytes, int $replacing = 0): bool
|
||||
{
|
||||
$delta = $bytes - $replacing;
|
||||
|
||||
$query = static::query()->whereKey($this->getKey());
|
||||
|
||||
// Both bounds are rearranged so that the column is never part of a
|
||||
// subtraction. `staged_bytes + :delta BETWEEN 0 AND size` reads
|
||||
// naturally and is wrong: staged_bytes is BIGINT UNSIGNED, and on
|
||||
// MySQL a negative delta makes that expression underflow and raise
|
||||
// SQLSTATE 22003 — in the comparison, before any row is chosen, so
|
||||
// the bound meant to prevent it is the thing that trips over it.
|
||||
// SQLite has no unsigned integers, so the suite cannot see this at
|
||||
// all; UploadSessionStagedBytesMysqlTest is what covers it.
|
||||
if ($delta >= 0) {
|
||||
if ($delta > $this->size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query->where('staged_bytes', '<=', $this->size - $delta);
|
||||
} else {
|
||||
// Never give back more than is held.
|
||||
$query->where('staged_bytes', '>=', -$delta);
|
||||
}
|
||||
|
||||
return $query->update(['staged_bytes' => DB::raw(sprintf('staged_bytes + (%d)', $delta))]) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a reservation with what the part actually weighs.
|
||||
*
|
||||
* Always called, whatever happened to the part: a body shorter than
|
||||
* its Content-Length, a client that hung up mid-transfer, a part
|
||||
* refused for being too long and deleted. Whatever is on disk now is
|
||||
* the truth, and the difference goes back to the session — otherwise
|
||||
* a client's own retries would slowly exhaust their room.
|
||||
*/
|
||||
public function settleStaged(int $reserved, int $actual): void
|
||||
{
|
||||
if ($reserved === $actual) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reserveStaged($actual, $reserved);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,13 @@ class GroupsController extends Controller
|
||||
'visibility' => ['nullable', Rule::in(['public', 'private'])],
|
||||
]);
|
||||
|
||||
$query = Group::query()->withCount('members');
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// The API twin of the web listing's narrowing, and it has to be
|
||||
// here rather than only there: the same disclosure through a token
|
||||
// is the same disclosure (GHSA-r3hg-3fxw-rcmr).
|
||||
$query = $this->scope->groups($viewer)->withCount('members');
|
||||
|
||||
if (($filters['search'] ?? null) !== null) {
|
||||
$search = $filters['search'];
|
||||
|
||||
@@ -40,7 +40,14 @@ class GroupsController extends Controller
|
||||
'visibility' => $validated['visibility'] ?? null,
|
||||
];
|
||||
|
||||
$groups = Group::query()
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// Scoped, not Group::query(): a client-scoped staff member is told
|
||||
// about a group because one of their clients is in it. Without
|
||||
// this the listing showed every group on the installation, to a
|
||||
// viewer who could reach nothing of theirs (GHSA-r3hg-3fxw-rcmr).
|
||||
$groups = $this->scope->groups($viewer)
|
||||
->withCount('members')
|
||||
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
||||
->where('name', 'like', "%{$search}%")
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -61,12 +62,8 @@ class AccountContentDeletion
|
||||
*/
|
||||
public function candidates(?User $viewer, ?int $excludeId = null): array
|
||||
{
|
||||
return User::query()
|
||||
return $this->reachableTargets($viewer)
|
||||
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
|
||||
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
|
||||
->where('type', UserType::Staff)
|
||||
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
|
||||
->where('active', true)
|
||||
->with('role')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
@@ -99,17 +96,62 @@ class AccountContentDeletion
|
||||
return [];
|
||||
}
|
||||
|
||||
$viewer = $request->user();
|
||||
|
||||
return $request->validate([
|
||||
'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])],
|
||||
'reassign_to_id' => [
|
||||
'required_if:content_action,reassign',
|
||||
'integer',
|
||||
Rule::exists('users', 'id')->where('active', true),
|
||||
Rule::notIn([$target->id]),
|
||||
// The same question the picker asks, asked again of what
|
||||
// came back from it. It used to be "exists, and is active",
|
||||
// which is not the boundary the picker documents two
|
||||
// methods up: a client-scoped staff member was shown their
|
||||
// own roster and could name anybody, so deleting a roster
|
||||
// client could hand that client's files and folders to a
|
||||
// client on somebody else's roster — who then reads, edits
|
||||
// and deletes them under the own-upload rules
|
||||
// (GHSA-w29w-pj29-x7ww).
|
||||
//
|
||||
// One predicate for both, rather than a matching pair: a
|
||||
// picker that promises a boundary the write does not keep
|
||||
// is exactly what this was.
|
||||
function (string $attribute, mixed $value, Closure $fail) use ($viewer): void {
|
||||
if (! $this->reachableTargets($viewer)->whereKey($value)->exists()) {
|
||||
// Deliberately the message an id that does not
|
||||
// exist at all would get. "Not yours" and "not
|
||||
// there" have to read the same, or refusing is how
|
||||
// a scoped staff member enumerates the accounts
|
||||
// outside their roster.
|
||||
$fail('validation.exists')->translate();
|
||||
}
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every active account $viewer may hand content to: staff, who are
|
||||
* narrowed nowhere in the application, plus the clients
|
||||
* StaffLibraryScope shows them. An unscoped viewer gets everybody,
|
||||
* because clients() returns everybody for them.
|
||||
*
|
||||
* $viewer is null only where the question is about the installation
|
||||
* rather than about a screen — the erasure default in privacy
|
||||
* settings, which is stored once for everybody.
|
||||
*
|
||||
* @return Builder<User>
|
||||
*/
|
||||
private function reachableTargets(?User $viewer): Builder
|
||||
{
|
||||
return User::query()
|
||||
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
|
||||
->where('type', UserType::Staff)
|
||||
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
|
||||
->where('active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{content_action?: string, reassign_to_id?: int} $validated
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Identity;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Finding the account that holds an address, exactly.
|
||||
*
|
||||
* `where('email', $address)` is not an exact match. It is whatever the
|
||||
* database's collation says equality means, and the documented one here —
|
||||
* `utf8mb4_unicode_ci`, in INSTALL.md and in config/database.php — folds
|
||||
* accents:
|
||||
*
|
||||
* administrator@example.com = administrator@éxample.com -> 1
|
||||
*
|
||||
* Those are two different domains. `éxample.com` is `xn--xample-9ua.com`,
|
||||
* a name somebody else can own and prove they own. So an attacker could
|
||||
* register the second at an OIDC provider, verify it honestly, sign in,
|
||||
* and be handed the first account — no password, no interaction from its
|
||||
* owner, and an administrator session if that account was one
|
||||
* (GHSA-wgxf-v8cr-37mj).
|
||||
*
|
||||
* ### Loose is right when refusing and wrong when selecting
|
||||
*
|
||||
* The same looseness protects elsewhere and is deliberately left alone.
|
||||
* `AvailableEmailRule` and `ClientProvisioning::emailIsAvailable()` ask
|
||||
* "is this address free?", and a collation that answers "no" to a
|
||||
* near-miss refuses *more* registrations, which is the safe direction.
|
||||
* This class is for the other question — "which account is this?" — where
|
||||
* matching more than you meant hands somebody an account.
|
||||
*
|
||||
* ### Why the filtering is in PHP
|
||||
*
|
||||
* A `COLLATE utf8mb4_bin` in the query would work on MySQL and break
|
||||
* everywhere else, and the test suite runs on SQLite, which is byte-exact
|
||||
* and would never have shown the bug in the first place. Comparing here
|
||||
* gives one answer on every driver, and it is the answer that does not
|
||||
* depend on how somebody created their database.
|
||||
*
|
||||
* Case is still folded, because that is a real requirement rather than an
|
||||
* accident: addresses are stored lowercased and a provider may send any
|
||||
* case. `mb_strtolower` folds case without folding accents, which is
|
||||
* exactly the line to draw.
|
||||
*/
|
||||
class AccountLookup
|
||||
{
|
||||
/**
|
||||
* The account whose address is exactly this one, or null.
|
||||
*
|
||||
* @param bool $withTrashed include soft-deleted accounts — a
|
||||
* deleted account still holds its address
|
||||
* until erasure
|
||||
*/
|
||||
public function byEmail(string $email, bool $withTrashed = false): ?User
|
||||
{
|
||||
$query = $withTrashed ? User::withTrashed() : User::query();
|
||||
|
||||
// The database narrows, this decides. A collation that matches too
|
||||
// much returns extra rows here and they are dropped; one that
|
||||
// matches too little was never going to return the right row at
|
||||
// all, which is a different bug and not one anybody has.
|
||||
return $query->where('email', $email)->get()
|
||||
->first(fn (User $user): bool => $this->isSameAddress($user->email, $email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two strings name the same mailbox: case-insensitively, and
|
||||
* byte-exact about everything else.
|
||||
*/
|
||||
public function isSameAddress(?string $stored, ?string $given): bool
|
||||
{
|
||||
if ($stored === null || $given === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mb_strtolower(trim($stored), 'UTF-8') === mb_strtolower(trim($given), 'UTF-8');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Identity\Erasure\AvailableEmailRule;
|
||||
use App\Modules\Identity\FirstAdministrator;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\UserType;
|
||||
@@ -30,7 +31,14 @@ class CreateAdminCommand extends Command
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('if-none') && User::query()->where('type', UserType::Staff)->exists()) {
|
||||
$ifNone = (bool) $this->option('if-none');
|
||||
|
||||
// Asked early so an unattended boot does not prompt for a name and
|
||||
// a password it is about to throw away. It is asked again below,
|
||||
// under a lock, because this read on its own has the same hole the
|
||||
// setup screen had: two containers coming up against one database
|
||||
// both see no staff and both create an administrator.
|
||||
if ($ifNone && $this->staffExists()) {
|
||||
$this->info('A staff user already exists; nothing to do.');
|
||||
|
||||
return self::SUCCESS;
|
||||
@@ -57,15 +65,36 @@ class CreateAdminCommand extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'type' => UserType::Staff,
|
||||
'active' => true,
|
||||
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$create = function () use ($name, $email, $password): User {
|
||||
$user = User::create([
|
||||
'type' => UserType::Staff,
|
||||
'active' => true,
|
||||
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
// forceFill, for the reason SetupController gives beside it:
|
||||
// email_verified_at is not in User::$fillable, so passing it
|
||||
// into create() lost it without a word. Whoever provisioned
|
||||
// this container supplied the address themselves.
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
|
||||
return $user;
|
||||
};
|
||||
|
||||
// Without --if-none an operator is asking for an administrator
|
||||
// outright, whoever else exists, so there is nothing to claim.
|
||||
$user = $ifNone
|
||||
? FirstAdministrator::claim(fn (): bool => ! $this->staffExists(), $create)
|
||||
: $create();
|
||||
|
||||
if ($user === null) {
|
||||
$this->info('A staff user already exists; nothing to do.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
app(ActivityLogger::class)->log(Action::UserCreated, null, $user);
|
||||
|
||||
@@ -84,4 +113,12 @@ class CreateAdminCommand extends Command
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @phpstan-impure another process can create one between two calls
|
||||
*/
|
||||
private function staffExists(): bool
|
||||
{
|
||||
return User::query()->where('type', UserType::Staff)->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Identity\Console;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\AccountLookup;
|
||||
use App\Modules\Identity\Erasure\AccountEraser;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -25,7 +26,10 @@ class EraseAccountCommand extends Command
|
||||
{
|
||||
$email = (string) $this->argument('email');
|
||||
|
||||
$user = User::withTrashed()->where('email', $email)->first();
|
||||
// Exact: this deletes somebody permanently, and a collation that
|
||||
// folds accents could hand it a different account than the one an
|
||||
// operator typed. See AccountLookup.
|
||||
$user = app(AccountLookup::class)->byEmail($email, withTrashed: true);
|
||||
|
||||
if ($user === null) {
|
||||
$this->error("No account found for {$email}.");
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -102,6 +103,24 @@ class AccountEraser
|
||||
$this->activity->logSystem(Action::AccountContentCascadeDeleted, ['name' => $user->name, ...$result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The account configured to inherit erased content, or null when
|
||||
* there is nobody valid to hand it to — in which case handleContent()
|
||||
* cascades, because orphaning is never the answer.
|
||||
*
|
||||
* **A staff member's content may only go to staff.** The target is one
|
||||
* installation-wide id used for every erasure, and the picker offers
|
||||
* clients on purpose: erasing a client and handing their files to
|
||||
* another client is what the setting is for. Applied to a *staff*
|
||||
* account the same id means something else entirely — a staff library
|
||||
* is usually the whole installation's, and a client named there would
|
||||
* inherit all of it, in one unattended scheduled job.
|
||||
*
|
||||
* The check cannot live in the settings validation, which is where it
|
||||
* would otherwise belong: that runs when the target is chosen, and
|
||||
* whose account will be erased later is not knowable then. So it is
|
||||
* asked here, where both halves are in hand.
|
||||
*/
|
||||
private function fallbackFor(User $user): ?User
|
||||
{
|
||||
$id = (int) $this->settings->get(Setting::AccountErasureReassignTo);
|
||||
@@ -113,6 +132,7 @@ class AccountEraser
|
||||
return User::query()
|
||||
->where('active', true)
|
||||
->whereKeyNot($user->id)
|
||||
->when($user->isStaff(), fn ($query) => $query->where('type', UserType::Staff))
|
||||
->find($id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Identity;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\EnsureSystemRoles;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Creating the very first administrator is a claim, not a check followed
|
||||
* by an insert.
|
||||
*
|
||||
* "Has this installation been set up" is answered by asking whether any
|
||||
* staff row exists, and an empty result has nothing in it to lock. So two
|
||||
* unauthenticated setup requests arriving together both read "no staff",
|
||||
* both spend a quarter of a second hashing a password, and both insert a
|
||||
* System Administrator. The operator's own setup succeeds and looks
|
||||
* entirely normal, which is the point: a stranger walks away with a
|
||||
* second, permanent administrator account and nothing says so.
|
||||
* (GHSA-w3w9-prpw-qx77, reported by @ry2811.)
|
||||
*
|
||||
* What gets locked is the System Administrator role row. It is the thing
|
||||
* being claimed; it is written by the roles migration and rewritten on
|
||||
* every boot, so unlike the staff rows it is always there to be locked.
|
||||
* The second caller waits on it, and by the time it has the lock the
|
||||
* first caller's user row is committed and visible — so its own re-check,
|
||||
* asked inside the claim this time, sees an installation that is already
|
||||
* set up and creates nothing.
|
||||
*
|
||||
* Locking the staff query itself would not do. There are no matching rows
|
||||
* on a fresh install, and a lock over nothing serialises nothing.
|
||||
*/
|
||||
final class FirstAdministrator
|
||||
{
|
||||
/**
|
||||
* Create the initial administrator, or nothing if somebody else got
|
||||
* there first.
|
||||
*
|
||||
* @param callable(): bool $stillNeeded asked again with the claim held
|
||||
* @param callable(): User $create runs only if it is still needed
|
||||
* @return User|null null when the claim was lost
|
||||
*/
|
||||
public static function claim(callable $stillNeeded, callable $create): ?User
|
||||
{
|
||||
// The lock needs a row to bite on. This is idempotent and already
|
||||
// runs on every boot; asking again costs one query on the one
|
||||
// request in the life of an installation that comes through here,
|
||||
// and means a database somehow missing its roles gets them back
|
||||
// rather than quietly racing.
|
||||
(new EnsureSystemRoles)->ensure();
|
||||
|
||||
return DB::transaction(function () use ($stillNeeded, $create): ?User {
|
||||
Role::query()
|
||||
->where('name', SystemRole::SystemAdministrator->value)
|
||||
->lockForUpdate()
|
||||
->value('id');
|
||||
|
||||
return $stillNeeded() ? $create() : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Identity\FirstAdministrator;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\UserType;
|
||||
@@ -54,17 +55,46 @@ class SetupController extends Controller
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
]);
|
||||
|
||||
$this->settings->set(Setting::SiteName, $validated['site_name']);
|
||||
// The check above is not enough on its own: it is a plain read, and
|
||||
// between it and the insert a second setup request can do the same
|
||||
// read and insert an administrator of its own. Everything this
|
||||
// request writes therefore happens inside the claim, so a request
|
||||
// that loses the race writes nothing at all — not the site name
|
||||
// either. See FirstAdministrator.
|
||||
$admin = FirstAdministrator::claim(
|
||||
fn (): bool => ! $this->setupIsComplete(),
|
||||
function () use ($validated): User {
|
||||
$this->settings->set(Setting::SiteName, $validated['site_name']);
|
||||
|
||||
$admin = User::create([
|
||||
'type' => UserType::Staff,
|
||||
'active' => true,
|
||||
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
$admin = User::create([
|
||||
'type' => UserType::Staff,
|
||||
'active' => true,
|
||||
'role_id' => Role::query()->where('name', SystemRole::SystemAdministrator->value)->value('id'),
|
||||
'name' => $validated['name'],
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
]);
|
||||
|
||||
// forceFill, not part of the create() array:
|
||||
// email_verified_at is deliberately absent from
|
||||
// User::$fillable, so mass assignment dropped it in silence
|
||||
// and this account was never marked verified. The first
|
||||
// administrator typed their own address into the form in
|
||||
// front of them; there is nobody to confirm it to. (Inert
|
||||
// today, since MustVerifyEmail is not enabled on the model,
|
||||
// but the column is what a later switch would read.)
|
||||
$admin->forceFill(['email_verified_at' => now()])->save();
|
||||
|
||||
return $admin;
|
||||
},
|
||||
);
|
||||
|
||||
// Somebody else finished setup while this request was in flight.
|
||||
// Theirs is the administrator that exists; this one is sent to the
|
||||
// login screen like any other visitor to an installed site.
|
||||
if ($admin === null) {
|
||||
return redirect()->route('home');
|
||||
}
|
||||
|
||||
// v1 logged installation as action 0; setup is a recorded action.
|
||||
$this->activity->log(Action::SetupCompleted, $admin);
|
||||
@@ -104,6 +134,9 @@ class SetupController extends Controller
|
||||
* The middleware and this must agree — one of them saying "not set
|
||||
* up" while the other says "set up" is either a redirect loop or an
|
||||
* open form.
|
||||
*
|
||||
* @phpstan-impure asking twice can honestly give two answers, which is
|
||||
* the entire reason store() asks a second time under a lock
|
||||
*/
|
||||
private function setupIsComplete(): bool
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Identity\Social;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\AccountLookup;
|
||||
|
||||
/**
|
||||
* Which local account, if any, a provider identity signs in as.
|
||||
@@ -28,6 +29,7 @@ class SocialAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SocialProvisioner $provisioner,
|
||||
private readonly AccountLookup $accounts,
|
||||
) {}
|
||||
|
||||
public function resolve(SocialSettings $settings, SocialIdentity $identity): SocialResolution
|
||||
@@ -74,7 +76,12 @@ class SocialAuthenticator
|
||||
// directory that omits the claim.
|
||||
$trusted = $identity->emailVerified || ! $settings->require_verified_email;
|
||||
|
||||
$existing = User::query()->where('email', $identity->email)->first();
|
||||
// Exactly this address, not whatever the database's collation
|
||||
// calls equal. utf8mb4_unicode_ci folds accents, so a verified
|
||||
// sign-in as administrator@éxample.com — a domain somebody else
|
||||
// can own — selected administrator@example.com and this method
|
||||
// then linked the attacker's subject to it (GHSA-wgxf-v8cr-37mj).
|
||||
$existing = $this->accounts->byEmail($identity->email);
|
||||
|
||||
if ($existing !== null) {
|
||||
// 4/5. The takeover, refused. An unverified address may not
|
||||
|
||||
@@ -39,8 +39,40 @@ final readonly class SocialIdentity
|
||||
* | LinkedIn | `email_verified` claim |
|
||||
* | OpenID Connect | `email_verified` claim, from the ID token |
|
||||
* | GitHub | An address at all — Socialite's GithubProvider replaces `email` with the result of `getEmailByToken()`, which only ever returns one that is **primary and verified** |
|
||||
* | Microsoft | The token's `tid` matching the configured tenant. Entra does not emit a usable `email_verified`, and its `email` claim is user-mutable — pinning the tenant is what makes it mean anything (this is the *nOAuth* class of bug) |
|
||||
* | Microsoft | The token's `tid` matching the configured tenant **and** `xms_edov` — see below |
|
||||
* | Facebook | Nothing. The Graph API has no equivalent claim, so an address from Facebook is never treated as verified |
|
||||
*
|
||||
* ### Microsoft takes two claims, not one
|
||||
*
|
||||
* Entra emits no usable `email_verified`, and its `email` claim is
|
||||
* user-mutable — populated from `otherMails`/proxyAddresses for a B2B
|
||||
* guest, among other places. Pinning the tenant was the first answer
|
||||
* and it is half of one: it defeats the classic cross-tenant *nOAuth*,
|
||||
* where a stranger's own tenant asserts your address, because a
|
||||
* foreign tenant carries a different `tid`.
|
||||
*
|
||||
* It does nothing about the same attack from *inside* the pinned
|
||||
* tenant. A colleague, or a guest somebody invited, could shape their
|
||||
* `email` claim to an administrator's address and have their subject
|
||||
* bound to that account (GHSA-2rfh-v3j2-2jg7). Tenant-pinning answers
|
||||
* "which directory said this", never "does this person own that
|
||||
* address".
|
||||
*
|
||||
* `xms_edov` is Microsoft's own answer to the second question — the
|
||||
* optional claim meaning the tenant has verified it owns the email's
|
||||
* domain — and their guidance says to require it wherever `email`
|
||||
* identifies an account. Absent is treated as unverified, which is the
|
||||
* only safe reading: it is absent by default, so anything else would
|
||||
* be no check at all.
|
||||
*
|
||||
* **What an installation has to do.** The claim must be added to the
|
||||
* app registration (Token configuration → optional claims → `xms_edov`
|
||||
* on the ID token). Until it is, Microsoft sign-in still works and
|
||||
* still creates new accounts — it simply stops silently attaching
|
||||
* itself to accounts that already exist, and says so, pointing the
|
||||
* person at signing in with a password and connecting the provider
|
||||
* from their settings. Accounts already linked are unaffected: they
|
||||
* resolve by subject, before this is consulted at all.
|
||||
*/
|
||||
public static function fromSocialite(
|
||||
SocialProvider $provider,
|
||||
@@ -72,7 +104,8 @@ final readonly class SocialIdentity
|
||||
|| ($raw['email_verified'] ?? null) === 'true',
|
||||
SocialProvider::Github => true,
|
||||
SocialProvider::Microsoft => is_string($settings->tenant_id)
|
||||
&& ($raw['tid'] ?? null) === $settings->tenant_id,
|
||||
&& ($raw['tid'] ?? null) === $settings->tenant_id
|
||||
&& (($raw['xms_edov'] ?? null) === true || ($raw['xms_edov'] ?? null) === 'true'),
|
||||
SocialProvider::Facebook => false,
|
||||
},
|
||||
name: is_string($user->getName()) && trim($user->getName()) !== ''
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Announcements\Events;
|
||||
|
||||
/**
|
||||
* A single message a package wants put in front of staff.
|
||||
*
|
||||
* Shown twice, from one source: a band across the top of the dashboard,
|
||||
* and an icon beside the notification bell that opens the same words on
|
||||
* every other page. One event rather than two because "the same message"
|
||||
* is the requirement — two props would drift the day somebody edits one.
|
||||
*
|
||||
* Not a widget, on purpose. The widget grid is a closed list of keys that
|
||||
* dashboard.tsx renders one by one, and each viewer arranges it — so a
|
||||
* message that matters would sit wherever somebody happened to drag it,
|
||||
* or under a fold, or switched off. A band above the grid is seen without
|
||||
* competing with the columns for space.
|
||||
*
|
||||
* **Core knows nothing about what it says.** Title, body, the label on the
|
||||
* button and where the button goes all come from the listener. The first
|
||||
* caller is the hosted edition telling a free instance what a paid plan
|
||||
* would give it, which is commercial copy belonging to one offering and
|
||||
* has no place in the public repository.
|
||||
*
|
||||
* One at a time, deliberately. A dashboard that can accumulate banners
|
||||
* accumulates them, and the second one is what teaches people to skip the
|
||||
* first. A listener that finds one already set should leave it alone
|
||||
* rather than overwrite it.
|
||||
*/
|
||||
class ResolvingAnnouncement
|
||||
{
|
||||
/**
|
||||
* @var array{title: string, body: string, action_label: string|null, action_url: string|null, tone: string}|null
|
||||
*/
|
||||
public ?array $announcement = null;
|
||||
|
||||
public function __construct(
|
||||
/** Whether the viewer is a staff account. */
|
||||
public readonly bool $isStaff,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Audience is declared by the listener and enforced here, rather than
|
||||
* each listener remembering to check `isStaff`.
|
||||
*
|
||||
* The first version of this refused clients outright, which was right
|
||||
* for the only message that existed — a hosted instance telling its
|
||||
* administrator about their plan. It stopped being right when a
|
||||
* message needed to reach the *clients* of a shared instance, and the
|
||||
* safe way to allow that is not to drop the guard: it is to make
|
||||
* every caller say who it is talking to, so a listener that forgets
|
||||
* reaches nobody rather than everybody.
|
||||
*/
|
||||
public const AUDIENCE_STAFF = 'staff';
|
||||
|
||||
public const AUDIENCE_CLIENTS = 'clients';
|
||||
|
||||
/**
|
||||
* `audience` is required and has no default. A message for staff and
|
||||
* a message for the people they share with are different messages,
|
||||
* and a signature that let one be mistaken for the other would put
|
||||
* the mistake in the quiet direction.
|
||||
*
|
||||
* `tone` picks the accent the band is drawn in. Two values, because
|
||||
* two is what the difference is worth: `info` for something worth
|
||||
* knowing, `warning` for something worth acting on. Anything else
|
||||
* falls back to `info` rather than rendering unstyled.
|
||||
*/
|
||||
public function show(
|
||||
string $title,
|
||||
string $body,
|
||||
string $audience,
|
||||
?string $actionLabel = null,
|
||||
?string $actionUrl = null,
|
||||
string $tone = 'info',
|
||||
): void {
|
||||
if ($this->announcement !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Silently ignored rather than thrown, and deliberately: a
|
||||
// listener aimed at the wrong audience should show nothing, not
|
||||
// break the page it was trying to decorate. An unrecognised value
|
||||
// reaches nobody for the same reason.
|
||||
$intended = match ($audience) {
|
||||
self::AUDIENCE_STAFF => $this->isStaff,
|
||||
self::AUDIENCE_CLIENTS => ! $this->isStaff,
|
||||
default => false,
|
||||
};
|
||||
|
||||
if (! $intended) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->announcement = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'action_label' => $actionLabel,
|
||||
'action_url' => $actionUrl,
|
||||
'tone' => in_array($tone, ['info', 'warning'], true) ? $tone : 'info',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,24 @@ enum Capability: string
|
||||
case EmailTransportConfigure = 'email.transport.configure';
|
||||
case SystemUpdates = 'system.updates';
|
||||
|
||||
// Community-only — whether this installation may switch off the
|
||||
// project news on its dashboard.
|
||||
//
|
||||
// Note what is Community-only: the *choice*, not the news. A managed
|
||||
// instance still fetches and still shows it, and cannot be made to
|
||||
// stop. That is the difference from SystemUpdates beside it, and it
|
||||
// is worth stating because the two look alike and are opposites. An
|
||||
// update notice is useless on a hosted tenant — they cannot act on
|
||||
// it, the image is ours — so the check does not run at all there.
|
||||
// News is the reverse: announcements about the product are exactly
|
||||
// what a hosted customer should be told, and an administrator
|
||||
// switching them off for everybody on that instance is not a
|
||||
// preference we meant to hand over.
|
||||
//
|
||||
// A self-hosted operator keeps the switch, because there nobody else
|
||||
// decides what their installation reaches out for.
|
||||
case NewsConfigure = 'news.configure';
|
||||
|
||||
// Community-only — scheduled-task run history and failed-queue-job
|
||||
// visibility. Cut on managed installations, where infrastructure
|
||||
// monitoring happens outside this application; a transient failure
|
||||
@@ -150,6 +168,7 @@ enum Capability: string
|
||||
self::StorageConfigure,
|
||||
self::EmailTransportConfigure,
|
||||
self::SystemUpdates,
|
||||
self::NewsConfigure,
|
||||
self::SchedulerMonitoring,
|
||||
self::CustomAssets => [Edition::Community],
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\RequiredIf;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
@@ -43,6 +44,7 @@ class ExternalStorageSettingsController extends Controller
|
||||
return Inertia::render('system/settings/storage', [
|
||||
'active' => $settings->active,
|
||||
'provider' => $settings->provider->value,
|
||||
'use_instance_role' => $settings->use_instance_role,
|
||||
// Never name a top-level Inertia prop "key" — Inertia's React
|
||||
// renderer spreads page props onto the component via
|
||||
// `{ key: <internal-remount-key>, ...props }`, and a prop
|
||||
@@ -78,10 +80,18 @@ class ExternalStorageSettingsController extends Controller
|
||||
'bucket' => ['required', 'string', 'max:255'],
|
||||
'root' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
// 'sometimes' for the same reason as 'provider' above: absent
|
||||
// means false, which is what every payload written before this
|
||||
// choice existed meant.
|
||||
'use_instance_role' => ['sometimes', 'boolean'],
|
||||
|
||||
// Required only for the provider that uses them, so switching
|
||||
// to GCS does not demand an AWS region that means nothing.
|
||||
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'access_key' => [self::requiredForStaticS3($request), 'nullable', 'string', 'max:255'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
// Still required when the machine's own role is doing the
|
||||
// authenticating: the credential chain resolves credentials,
|
||||
// not which region the bucket is in.
|
||||
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'endpoint' => ['nullable', 'string', 'max:255'],
|
||||
'use_path_style' => ['required', 'boolean'],
|
||||
@@ -94,10 +104,13 @@ class ExternalStorageSettingsController extends Controller
|
||||
|
||||
$settings = ExternalStorageSettings::current();
|
||||
|
||||
$useInstanceRole = (bool) ($validated['use_instance_role'] ?? false);
|
||||
|
||||
$settings->fill([
|
||||
'active' => $validated['active'],
|
||||
'provider' => $validated['provider'],
|
||||
'key' => $validated['access_key'] ?? null,
|
||||
'use_instance_role' => $useInstanceRole,
|
||||
'key' => $useInstanceRole ? null : ($validated['access_key'] ?? null),
|
||||
'bucket' => $validated['bucket'],
|
||||
'region' => $validated['region'] ?? null,
|
||||
'endpoint' => $validated['endpoint'] ?? null,
|
||||
@@ -108,7 +121,16 @@ class ExternalStorageSettingsController extends Controller
|
||||
// A blank credential keeps whatever is already stored — neither
|
||||
// field is ever round-tripped to the browser (only the has_*
|
||||
// flags are), so blank means "unchanged", not "cleared".
|
||||
if (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
|
||||
//
|
||||
// Except when the machine's own role takes over, which is the one
|
||||
// thing that does clear it. The whole point of the setting is that
|
||||
// no long-lived AWS credential is kept here, and a secret left
|
||||
// sitting in the row unused would still be in the next database
|
||||
// dump — and would silently come back the moment the box is
|
||||
// unticked.
|
||||
if ($useInstanceRole) {
|
||||
$settings->secret = null;
|
||||
} elseif (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
|
||||
$settings->secret = $validated['secret'];
|
||||
}
|
||||
|
||||
@@ -144,7 +166,8 @@ class ExternalStorageSettingsController extends Controller
|
||||
$validated = $request->validate([
|
||||
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
|
||||
'bucket' => ['required', 'string', 'max:255'],
|
||||
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'use_instance_role' => ['sometimes', 'boolean'],
|
||||
'access_key' => [self::requiredForStaticS3($request), 'nullable', 'string', 'max:255'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'endpoint' => ['nullable', 'string', 'max:255'],
|
||||
@@ -174,13 +197,21 @@ class ExternalStorageSettingsController extends Controller
|
||||
$config = [
|
||||
'version' => 'latest',
|
||||
'region' => $validated['region'],
|
||||
'credentials' => [
|
||||
'key' => $validated['access_key'],
|
||||
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
|
||||
],
|
||||
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
|
||||
];
|
||||
|
||||
// Omitted entirely, not left blank: an S3Client handed a
|
||||
// 'credentials' array is told to use it, so an empty one fails
|
||||
// instead of falling through to the default credential provider
|
||||
// chain. This is what makes the button test the same thing the
|
||||
// uploads will do — see ExternalStorageConfigApplier::applyS3().
|
||||
if (! ($validated['use_instance_role'] ?? false)) {
|
||||
$config['credentials'] = [
|
||||
'key' => $validated['access_key'],
|
||||
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
|
||||
];
|
||||
}
|
||||
|
||||
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
|
||||
$config['endpoint'] = $validated['endpoint'];
|
||||
}
|
||||
@@ -210,6 +241,22 @@ class ExternalStorageSettingsController extends Controller
|
||||
iterator_to_array($bucket->objects(['maxResults' => 1]), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* An access key is only demanded of an S3 backend that is actually
|
||||
* going to authenticate with one. Shared by both the save and the
|
||||
* connection test so the two cannot disagree about what is required.
|
||||
*/
|
||||
private static function requiredForStaticS3(Request $request): RequiredIf
|
||||
{
|
||||
// Rule::requiredIf(), not a closure rule: this has to fire when
|
||||
// the field is missing from the payload altogether, and a closure
|
||||
// rule is not implicit — it never runs on an absent attribute.
|
||||
return Rule::requiredIf(
|
||||
fn (): bool => $request->input('provider') === StorageProvider::S3->value
|
||||
&& ! $request->boolean('use_instance_role')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential field left blank means "keep what is stored" on save,
|
||||
* so the connection test has to read it the same way — otherwise
|
||||
|
||||
@@ -45,6 +45,10 @@ class SystemSettingsController extends Controller
|
||||
{
|
||||
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
||||
&& $request->user()?->can('manage_updates') === true;
|
||||
// No permission beside it, unlike updates: turning the news card
|
||||
// off is an ordinary settings change, and edit_settings already
|
||||
// gates this whole screen.
|
||||
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
|
||||
|
||||
return Inertia::render('system/settings/general', [
|
||||
'site_name' => $this->settings->get(Setting::SiteName),
|
||||
@@ -62,6 +66,15 @@ class SystemSettingsController extends Controller
|
||||
'viewer_timezone' => $request->user()?->timezone,
|
||||
'can_manage_updates' => $canManageUpdates,
|
||||
'check_for_updates' => $canManageUpdates ? $this->settings->get(Setting::CheckForUpdates) : null,
|
||||
// Its own capability, and deliberately not $canManageUpdates:
|
||||
// the update block disappears on a managed instance because
|
||||
// nobody there can act on it, while this one disappears
|
||||
// because the news must keep arriving whether or not the
|
||||
// instance's administrator would have chosen it. Null where
|
||||
// the choice is not theirs, so the page renders no switch
|
||||
// rather than a switch that would do nothing.
|
||||
'can_configure_news' => $canConfigureNews,
|
||||
'fetch_news' => $canConfigureNews ? $this->settings->get(Setting::FetchNews) : null,
|
||||
'last_checked_at' => $canManageUpdates ? $this->lastCheckedAt()?->toIso8601String() : null,
|
||||
'check_result' => $request->session()->get('update_check_result'),
|
||||
]);
|
||||
@@ -123,6 +136,10 @@ class SystemSettingsController extends Controller
|
||||
{
|
||||
$canManageUpdates = $this->capabilities->has(Capability::SystemUpdates)
|
||||
&& $request->user()?->can('manage_updates') === true;
|
||||
// No permission beside it, unlike updates: turning the news card
|
||||
// off is an ordinary settings change, and edit_settings already
|
||||
// gates this whole screen.
|
||||
$canConfigureNews = $this->capabilities->has(Capability::NewsConfigure);
|
||||
|
||||
$rules = [
|
||||
'site_name' => ['required', 'string', 'max:255'],
|
||||
@@ -133,6 +150,10 @@ class SystemSettingsController extends Controller
|
||||
// "follow APP_TIMEZONE", and only a fresh install has that.
|
||||
'timezone' => ['sometimes', 'string', 'timezone', Rule::in($this->timezones->all())],
|
||||
];
|
||||
|
||||
if ($canConfigureNews) {
|
||||
$rules['fetch_news'] = ['sometimes', 'boolean'];
|
||||
}
|
||||
if ($canManageUpdates) {
|
||||
// Omitting the field (any caller not sending it, not just this
|
||||
// page's own form) leaves the current value alone rather than
|
||||
@@ -156,6 +177,13 @@ class SystemSettingsController extends Controller
|
||||
$this->settings->set(Setting::CheckForUpdates, $validated['check_for_updates']);
|
||||
}
|
||||
|
||||
// Never read where the choice is not this installation's, so a
|
||||
// hand-crafted PATCH cannot switch off the news on a managed
|
||||
// instance any more than the absent checkbox could.
|
||||
if ($canConfigureNews && array_key_exists('fetch_news', $validated)) {
|
||||
$this->settings->set(Setting::FetchNews, $validated['fetch_news']);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'general']);
|
||||
|
||||
return back();
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Modules\Platform\Installation\Console;
|
||||
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||
use App\Modules\Identity\UserType;
|
||||
@@ -149,6 +150,28 @@ use Throwable;
|
||||
* quota nobody is watching. The installation looks completely healthy
|
||||
* while it happens, to its operator and to its administrator alike.
|
||||
*
|
||||
* ### `settings` holds the three an outsider has to act on, and no more
|
||||
*
|
||||
* Not a dump of the settings table. Everything here leaves the container,
|
||||
* so each field needs a reason somebody outside would act on it, and
|
||||
* these three have one: they are the settings whose wrong value is
|
||||
* invisible from outside and expensive.
|
||||
*
|
||||
* `two_factor_enforcement` is what the platform sold compared against
|
||||
* what the installation applied. The other two are one question in two
|
||||
* halves -- **who can make an account here, and what that account is
|
||||
* allowed** -- and the answer matters most where it is least visible. On
|
||||
* a shared installation every client is a separate customer, so
|
||||
* self-registration switched on means accounts appearing that nobody
|
||||
* provisioned, and a default quota of zero means those accounts have no
|
||||
* ceiling at all. Both defaults are the permissive ones (see Setting),
|
||||
* which is right for a self-hosted install setting itself up and wrong
|
||||
* for an installation somebody else is operating.
|
||||
*
|
||||
* Neither is a secret: both are on the client settings screen any
|
||||
* administrator can open. What the document adds is that a watcher can
|
||||
* see them without one.
|
||||
*
|
||||
* ### A version is a decision, a commit is a fact
|
||||
*
|
||||
* `build` says which commit this installation was built from. A version
|
||||
@@ -206,8 +229,12 @@ class StatusCommand extends Command
|
||||
|
||||
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
|
||||
|
||||
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats, Settings $settings): int
|
||||
{
|
||||
public function handle(
|
||||
CapabilityRegistry $capabilities,
|
||||
SeatAllowance $seats,
|
||||
Settings $settings,
|
||||
ClientStorageUsage $quotas,
|
||||
): int {
|
||||
$status = [
|
||||
'version' => (string) config('projectsend.version'),
|
||||
'edition' => $capabilities->edition()->value,
|
||||
@@ -252,6 +279,25 @@ class StatusCommand extends Command
|
||||
// the middleware enforces would be worse than reporting
|
||||
// none at all.
|
||||
'two_factor_enforcement' => $this->enforcement($settings),
|
||||
// Whether strangers can make themselves an account here.
|
||||
// Read the way RegistrationController reads it, `=== true`
|
||||
// included: `get()` casts a boolean with `(bool)`, so the
|
||||
// only value that is neither true nor false is null, and
|
||||
// null is what the gate treats as closed.
|
||||
'clients_can_register' => $settings->get(Setting::ClientsCanRegister) === true,
|
||||
// What a client with no quota of their own is allowed, in
|
||||
// megabytes. **Zero means unlimited**, which is the whole
|
||||
// reason this is worth reporting: it is the default, it is
|
||||
// the answer for every account created without one asked
|
||||
// for, and nothing else in this document reveals it.
|
||||
//
|
||||
// The *effective* number, through the same method the
|
||||
// upload check resolves it with, rather than the setting
|
||||
// read on its own. A platform can put a floor under it from
|
||||
// the environment, and a document that reported the setting
|
||||
// while the uploads obeyed the floor would say the ceiling
|
||||
// was missing on an installation that has one.
|
||||
'default_client_storage_quota_mb' => $quotas->defaultQuotaMb(),
|
||||
],
|
||||
// Cast so an installation with no packages emits {} rather
|
||||
// than [] -- an empty PHP array encodes as a list, and a
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Navigation\Events;
|
||||
|
||||
/**
|
||||
* Extra links a package wants in the sidebar.
|
||||
*
|
||||
* The sidebar is built from a hardcoded array in app-sidebar.tsx, which
|
||||
* means a package could not contribute to it at all — the nav link was a
|
||||
* separate manual edit every time a package grew a screen, and being
|
||||
* manual it was forgotten. This is the seam that fixes that, in the shape
|
||||
* the extension-points document settles on: core dispatches
|
||||
* unconditionally, listeners add or do not, and with no listener the
|
||||
* default (no extra links) holds.
|
||||
*
|
||||
* **Core deliberately learns nothing about what is added.** A link's
|
||||
* label, its URL and the reason it exists all arrive from whoever
|
||||
* registers it. That is not fastidiousness: the first caller is the
|
||||
* hosted edition's link to its own customer portal, and a product URL
|
||||
* belonging to one commercial offering has no business sitting in the
|
||||
* public repository just because the sidebar happens to live here.
|
||||
*
|
||||
* Staff only, and enforced here rather than trusted to each listener:
|
||||
* these appear in the administration area, and a client's portal shows
|
||||
* only their own files.
|
||||
*/
|
||||
class ResolvingNavigationLinks
|
||||
{
|
||||
/**
|
||||
* @var list<array{title: string, url: string, external: bool, icon: string|null}>
|
||||
*/
|
||||
public array $links = [];
|
||||
|
||||
public function __construct(
|
||||
/** Whether the viewer is a staff account. Listeners that only make
|
||||
* sense for staff should check this rather than assume. */
|
||||
public readonly bool $isStaff,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* `external` opens in a new tab and marks the link as leaving this
|
||||
* installation — a link that navigates a person away from the app
|
||||
* they are working in should say so before they click it, not after.
|
||||
*/
|
||||
public function add(string $title, string $url, bool $external = false, ?string $icon = null): void
|
||||
{
|
||||
$this->links[] = [
|
||||
'title' => $title,
|
||||
'url' => $url,
|
||||
'external' => $external,
|
||||
'icon' => $icon,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\News\Console;
|
||||
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -12,9 +14,13 @@ use Illuminate\Support\Facades\Http;
|
||||
use Stevebauman\Purify\Facades\Purify;
|
||||
|
||||
/**
|
||||
* Both editions — unlike CheckForUpdatesCommand, this isn't gated on any
|
||||
* Capability: dashboard news is informational content, not an update
|
||||
* action, so Cloud tenants see it too.
|
||||
* Both editions, and on a managed instance not switchable off — unlike
|
||||
* CheckForUpdatesCommand, which does not run there at all. Dashboard news
|
||||
* is informational content rather than an update action, so hosted
|
||||
* customers see it too, and see it whether their administrator would have
|
||||
* chosen to or not. Capability::NewsConfigure is what a self-hosted
|
||||
* installation holds and a managed one does not: the choice is the
|
||||
* edition difference, not the news.
|
||||
*
|
||||
* The feed returns raw HTML in `content` (links, paragraphs) — sanitized
|
||||
* here, once, before it's ever cached or sent to the frontend, so the
|
||||
@@ -34,12 +40,36 @@ class FetchNewsCommand extends Command
|
||||
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// The setting only decides where the installation is allowed to
|
||||
// make that choice. On a managed instance it is not: announcements
|
||||
// about the product are what a hosted customer should be told, and
|
||||
// one administrator switching them off for everybody on that
|
||||
// instance is not a decision the platform hands over. So the news
|
||||
// runs there regardless of what any row says — including a row
|
||||
// left behind by an instance that used to be self-hosted.
|
||||
//
|
||||
// The opposite of the update check, which does not run on a
|
||||
// managed instance at all because nobody there could act on it.
|
||||
// The two look alike and point in different directions.
|
||||
//
|
||||
// Returns success rather than failure: a scheduled task that was
|
||||
// asked not to run has not failed, and reporting it as a failure
|
||||
// would put a red line in the scheduler history every night for
|
||||
// an installation that is behaving exactly as configured.
|
||||
if ($this->capabilities->has(Capability::NewsConfigure)
|
||||
&& $this->settings->get(Setting::FetchNews) !== true) {
|
||||
$this->info('The news feed is switched off for this installation.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$response = Http::withHeaders(['User-Agent' => 'ProjectSend'])
|
||||
->timeout(10)
|
||||
->get(self::FEED_URL);
|
||||
|
||||
@@ -50,7 +50,9 @@ class ExternalStorageConfigApplier
|
||||
// account's private key included — in the same database whose dump
|
||||
// the `encrypted` cast exists to survive. Both are now read straight
|
||||
// from the row, by the provider branch that uses them.
|
||||
private const CACHE_KEY = 'platform.external_storage_settings.v3';
|
||||
// v4: use_instance_role joined the shape. Not a credential — it is
|
||||
// the fact that there isn't one — so it caches like the rest.
|
||||
private const CACHE_KEY = 'platform.external_storage_settings.v4';
|
||||
|
||||
public function __construct(
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
@@ -93,8 +95,16 @@ class ExternalStorageConfigApplier
|
||||
*/
|
||||
private function applyS3(array $resolved): void
|
||||
{
|
||||
Config::set('filesystems.disks.files_external.key', $resolved['key']);
|
||||
Config::set('filesystems.disks.files_external.secret', $this->credential('secret'));
|
||||
// Left null on purpose when the machine's own role is doing the
|
||||
// authenticating. Laravel's FilesystemManager::formatS3Config()
|
||||
// only builds a `credentials` entry when both a key and a secret
|
||||
// are non-empty, and the AWS SDK falls back to its default
|
||||
// credential provider chain — ECS task role, EC2 instance
|
||||
// profile, EKS/IRSA, environment — whenever none is supplied.
|
||||
// Passing an empty string instead of nothing would be a
|
||||
// credential, and would fail rather than fall through.
|
||||
Config::set('filesystems.disks.files_external.key', $resolved['use_instance_role'] ? null : $resolved['key']);
|
||||
Config::set('filesystems.disks.files_external.secret', $resolved['use_instance_role'] ? null : $this->credential('secret'));
|
||||
Config::set('filesystems.disks.files_external.region', $resolved['region']);
|
||||
Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']);
|
||||
Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']);
|
||||
@@ -172,13 +182,14 @@ class ExternalStorageConfigApplier
|
||||
* filled in and active, nothing more. Callers AND the capability check
|
||||
* live and uncached — see class docblock.
|
||||
*
|
||||
* @return array{configured: bool, provider: string, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
||||
* @return array{configured: bool, provider: string, use_instance_role: bool, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
||||
*/
|
||||
private function resolve(): array
|
||||
{
|
||||
$blank = [
|
||||
'configured' => false,
|
||||
'provider' => StorageProvider::S3->value,
|
||||
'use_instance_role' => false,
|
||||
'key' => null,
|
||||
'region' => null, 'bucket' => null,
|
||||
'endpoint' => null, 'use_path_style' => false, 'root' => null,
|
||||
@@ -202,6 +213,7 @@ class ExternalStorageConfigApplier
|
||||
return [
|
||||
'configured' => true,
|
||||
'provider' => $settings->provider->value,
|
||||
'use_instance_role' => $settings->use_instance_role,
|
||||
'key' => $settings->key,
|
||||
'region' => $settings->region,
|
||||
'bucket' => $settings->bucket,
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* @property int $id
|
||||
* @property bool $active
|
||||
* @property StorageProvider $provider
|
||||
* @property bool $use_instance_role
|
||||
* @property string|null $key
|
||||
* @property string|null $secret
|
||||
* @property string|null $key_file
|
||||
@@ -33,6 +34,7 @@ class ExternalStorageSettings extends Model
|
||||
protected $fillable = [
|
||||
'active',
|
||||
'provider',
|
||||
'use_instance_role',
|
||||
'key',
|
||||
'secret',
|
||||
'key_file',
|
||||
@@ -55,6 +57,7 @@ class ExternalStorageSettings extends Model
|
||||
protected $attributes = [
|
||||
'active' => false,
|
||||
'provider' => 's3',
|
||||
'use_instance_role' => false,
|
||||
'use_path_style' => false,
|
||||
];
|
||||
|
||||
@@ -63,6 +66,7 @@ class ExternalStorageSettings extends Model
|
||||
return [
|
||||
'active' => 'boolean',
|
||||
'provider' => StorageProvider::class,
|
||||
'use_instance_role' => 'boolean',
|
||||
'secret' => 'encrypted',
|
||||
'key_file' => 'encrypted',
|
||||
'use_path_style' => 'boolean',
|
||||
@@ -71,7 +75,38 @@ class ExternalStorageSettings extends Model
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
return static::query()->firstOrNew([]);
|
||||
$settings = static::query()->firstOrNew([]);
|
||||
|
||||
// Column defaults — the $attributes array above — apply to a NEW
|
||||
// model, never to one hydrated from a row. So a row written by an
|
||||
// older release, before one of these columns existed, reads that
|
||||
// column as null however sensible its default is.
|
||||
//
|
||||
// That matters here more than it would anywhere else, because
|
||||
// PlatformServiceProvider::boot() reads these settings on every
|
||||
// process boot — and boot happens BEFORE `artisan migrate` runs.
|
||||
// For the length of an upgrade the code is new and the schema is
|
||||
// still old, and every artisan command in that window, including
|
||||
// the one that would run the migrations, boots through here.
|
||||
//
|
||||
// A null `provider` made the match in isConfigured() throw
|
||||
// UnhandledMatchError, which the official image's readiness probe
|
||||
// reported to the operator as "database unreachable" — on a
|
||||
// perfectly reachable database, in a container that then
|
||||
// restart-looped without ever reaching the migration that would
|
||||
// have fixed it (#1770, upgrading from 2.0/2.1 with external
|
||||
// storage configured).
|
||||
//
|
||||
// Applying the defaults to a hydrated row closes that window for
|
||||
// every column that has one, rather than for the single column
|
||||
// where it was found. Inert on any install whose schema is current.
|
||||
foreach ((new self)->getAttributes() as $column => $default) {
|
||||
if (! array_key_exists($column, $settings->getAttributes())) {
|
||||
$settings->setAttribute($column, $default);
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,8 +123,19 @@ class ExternalStorageSettings extends Model
|
||||
// What counts as "filled in" is per provider, because the two
|
||||
// authenticate with different things entirely: S3 wants a key and
|
||||
// a secret, GCS wants a service account key file.
|
||||
//
|
||||
// The match is deliberately left total rather than given a default
|
||||
// arm: current() guarantees a provider even on a row older than the
|
||||
// column, and a default arm here would quietly swallow a genuinely
|
||||
// unhandled case instead of naming it.
|
||||
//
|
||||
// Unless S3 is being asked to authenticate as the machine it is
|
||||
// running on, in which case there is no credential to fill in at
|
||||
// all and demanding one would leave the disk permanently
|
||||
// "unconfigured" — which fails silently, by leaving every new
|
||||
// upload on the local disk rather than by reporting anything.
|
||||
return match ($this->provider) {
|
||||
StorageProvider::S3 => $this->filled('key') && $this->filled('secret'),
|
||||
StorageProvider::S3 => $this->use_instance_role || ($this->filled('key') && $this->filled('secret')),
|
||||
StorageProvider::Gcs => $this->filled('key_file'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,6 +249,17 @@ enum Setting: string
|
||||
// is allowed to tell the admin a newer release exists.
|
||||
case CheckForUpdates = 'check_for_updates';
|
||||
|
||||
// Whether this installation fetches the project's news feed for the
|
||||
// dashboard card. Its own key rather than riding on CheckForUpdates
|
||||
// above, because they are two different wants: "do not tell me about
|
||||
// releases" and "do not show me the project's news" are asked
|
||||
// separately, and an installation with no outbound access at all
|
||||
// wants both off while an ordinary one may want updates and no feed.
|
||||
//
|
||||
// On by default, so nothing changes for an installation that has
|
||||
// never seen this switch.
|
||||
case FetchNews = 'fetch_news';
|
||||
|
||||
// Cached result of the last update check — never written directly by
|
||||
// a settings form, only by CheckForUpdatesCommand. Empty string means
|
||||
// "no successful check yet" (fresh install, or checks disabled).
|
||||
@@ -367,6 +378,7 @@ enum Setting: string
|
||||
self::ClientsCanPreviewFiles,
|
||||
self::PublicListingPreviewEnabled,
|
||||
self::CheckForUpdates,
|
||||
self::FetchNews,
|
||||
self::ExpiredFilesAutoDeleteEnabled,
|
||||
self::PublicCommentsEnabled,
|
||||
self::CommentsGuestModeration,
|
||||
@@ -433,6 +445,7 @@ enum Setting: string
|
||||
self::OrphanFilesAutoDeleteEnabled => false,
|
||||
|
||||
self::CheckForUpdates,
|
||||
self::FetchNews,
|
||||
self::CommentsGuestModeration,
|
||||
// On, so that an installation updating into these switches
|
||||
// keeps the preview it already had rather than losing it to a
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Updates\Console;
|
||||
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Modules\Platform\Updates\CheckForUpdates;
|
||||
@@ -32,12 +34,37 @@ class CheckForUpdatesCommand extends Command
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly CheckForUpdates $check,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// Ahead of the setting, and deliberately not a setting itself.
|
||||
//
|
||||
// A Setting says "the operator does not want this". The true
|
||||
// statement on a managed installation is "there is nowhere for
|
||||
// this to appear and nothing they could do about it": the
|
||||
// dashboard's System card is gated on Capability::SystemUpdates
|
||||
// (DashboardController), which is Community-only, so the answer
|
||||
// this command fetches cannot be drawn on any screen — and the
|
||||
// update UI is closed by the same capability, so it could not be
|
||||
// acted on if it were. The image is chosen by whoever provisioned
|
||||
// the instance.
|
||||
//
|
||||
// Encoding that as a preference would leave it switchable back on
|
||||
// per tenant, which buys a nightly call to GitHub for a number
|
||||
// nobody can see, and would leave the reason in a provisioning
|
||||
// script rather than beside the code. A self-hosted installation
|
||||
// holds the capability and loses nothing: its own setting below
|
||||
// still decides.
|
||||
if (! $this->capabilities->has(Capability::SystemUpdates)) {
|
||||
$this->info('Update checks do not apply to this installation.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->settings->get(Setting::CheckForUpdates) !== true) {
|
||||
$this->info('Update checks are disabled.');
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Reading an on/off environment variable strictly.
|
||||
*
|
||||
* `env()` recognises the words `true` and `false` and hands back
|
||||
* everything else as the string it was — and every non-empty string is
|
||||
* truthy in PHP. So the obvious `(bool) env(...)` reads `no`, `off` and
|
||||
* a typo as **on**:
|
||||
*
|
||||
* SOMETHING=no -> on
|
||||
* SOMETHING=off -> on
|
||||
* SOMETHING=enabld -> on
|
||||
*
|
||||
* For most settings that is a shrug — somebody notices the feature is on
|
||||
* and fixes the line. It stops being a shrug when the wrong answer is the
|
||||
* unsafe one, which is every flag that switches a protection *off* or a
|
||||
* disclosure *on*. Those have to fail the other way, so this lists what
|
||||
* counts as yes and reads everything else — including anything it does
|
||||
* not recognise — as no.
|
||||
*
|
||||
* The list is deliberately short. Nothing an operator might have meant as
|
||||
* "no" is in it, which is the point; a value typed as `enabled` turns
|
||||
* nothing on and is a configuration mistake to be found rather than
|
||||
* guessed at.
|
||||
*/
|
||||
final class EnvFlag
|
||||
{
|
||||
private const TRUE_VALUES = ['1', 'true'];
|
||||
|
||||
/**
|
||||
* @param mixed $value whatever `env()` returned
|
||||
*/
|
||||
public static function isTrue(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return $value === 1;
|
||||
}
|
||||
|
||||
return is_string($value)
|
||||
&& in_array(strtolower(trim($value)), self::TRUE_VALUES, true);
|
||||
}
|
||||
}
|
||||
+37
-2
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Modules\Platform\Capabilities\Edition;
|
||||
use App\Support\EnvFlag;
|
||||
|
||||
return [
|
||||
|
||||
@@ -80,10 +81,33 @@ return [
|
||||
// read at all and that is how TRUSTED_PROXIES came to silently do
|
||||
// nothing.
|
||||
'two_factor_enforcement' => env('PROJECTSEND_TWO_FACTOR_ENFORCEMENT'),
|
||||
|
||||
// A floor under what a client with no quota of their own gets, in
|
||||
// megabytes. Not a setting, for the same reason the seat caps above
|
||||
// are not: it is the shape of what the platform sold rather than a
|
||||
// preference the installation's administrator is expressing, and an
|
||||
// administrator who has chosen a number keeps it — see
|
||||
// ClientStorageUsage::defaultQuotaMb().
|
||||
//
|
||||
// It exists because the setting's own default is 0, and 0 means
|
||||
// unlimited. On an installation a platform runs for other people
|
||||
// that is one account away from unmetered hosting, and the account
|
||||
// does not have to be one the platform created.
|
||||
'default_client_quota_mb' => env('PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB'),
|
||||
],
|
||||
|
||||
'uploads' => [
|
||||
'parts_path' => env('UPLOAD_PARTS_PATH'),
|
||||
|
||||
// How many resumable uploads one account may have in flight.
|
||||
//
|
||||
// A session holds room on the temporary volume from the moment it
|
||||
// is created until it completes, is cancelled, or is swept — and
|
||||
// for an account with no storage quota to spend, this number is
|
||||
// the only thing bounding how much room that is. The browser
|
||||
// uploads a few files at once, so this is far above anything a
|
||||
// person does by hand.
|
||||
'max_open_sessions' => 25,
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -141,7 +165,12 @@ return [
|
||||
*/
|
||||
|
||||
'captcha' => [
|
||||
'disabled' => (bool) env('PROJECTSEND_CAPTCHA_DISABLED', false),
|
||||
// Read strictly rather than cast: `env()` returns anything it does
|
||||
// not recognise as the string it was, and every non-empty string
|
||||
// is truthy — so `(bool)` would read `PROJECTSEND_CAPTCHA_DISABLED=no`
|
||||
// as "yes, disabled" and quietly take the bot protection off the
|
||||
// login and registration forms. See App\Support\EnvFlag.
|
||||
'disabled' => EnvFlag::isTrue(env('PROJECTSEND_CAPTCHA_DISABLED', false)),
|
||||
|
||||
'managed' => [
|
||||
'provider' => env('PROJECTSEND_CAPTCHA_MANAGED_PROVIDER'),
|
||||
@@ -161,7 +190,13 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => '2.3.0',
|
||||
// How long a request waits for another one that is already rendering
|
||||
// the same thumbnail or preview, before giving up rather than
|
||||
// decoding the same image a second time. See ThumbnailGenerator.
|
||||
// A slow disk or a large source wants longer than the default 15.
|
||||
'rendition_lock_wait_seconds' => env('PROJECTSEND_RENDITION_LOCK_WAIT_SECONDS'),
|
||||
|
||||
'version' => '2.4.1',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('external_storage_settings', function (Blueprint $table) {
|
||||
// Every row that exists predates the choice, and every one of
|
||||
// them authenticates with a stored key and secret — so `false`
|
||||
// is what keeps this migration invisible to anyone already
|
||||
// using external storage.
|
||||
//
|
||||
// An explicit column rather than "the key field was left
|
||||
// blank": on this form a blank credential already means "keep
|
||||
// the one you have", because neither the secret nor the key
|
||||
// file is ever sent back to the browser. Blank cannot also
|
||||
// mean "authenticate a different way" without the two
|
||||
// meanings colliding on the first save.
|
||||
$table->boolean('use_instance_role')->default(false)->after('provider');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('external_storage_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('use_instance_role');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('upload_sessions', function (Blueprint $table) {
|
||||
// Bytes this session currently holds on the temporary volume,
|
||||
// including any part still being received. The filesystem is
|
||||
// still the part ledger; this is the running total, kept here
|
||||
// because a limit has to be claimed before the bytes arrive and
|
||||
// a directory listing cannot be read and written atomically.
|
||||
$table->unsignedBigInteger('staged_bytes')->default(0)->after('size');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('upload_sessions', function (Blueprint $table) {
|
||||
$table->dropColumn('staged_bytes');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -58,12 +58,26 @@ fi
|
||||
# Wait for the database. `migrate` against a database still starting up is
|
||||
# the single most common first-run failure, and a bare failure here would
|
||||
# restart-loop the container with a stack trace instead of a clear message.
|
||||
#
|
||||
# The probe boots the whole application, so it fails for two quite different
|
||||
# reasons: the database really is not there yet, or it is there and the
|
||||
# application could not start. Both used to be reported as the first one,
|
||||
# which sent an operator off checking credentials that were never wrong
|
||||
# (#1770). The last failure is kept and printed, so whichever it was is on
|
||||
# screen instead of being guessed at.
|
||||
if [ "$1" = "supervisord" ] || [ "$1" = "/usr/bin/supervisord" ]; then
|
||||
i=0
|
||||
until su-exec www-data php artisan db:show --quiet >/dev/null 2>&1; do
|
||||
until probe_error=$(su-exec www-data php artisan db:show --quiet 2>&1); do
|
||||
i=$((i + 1))
|
||||
if [ "$i" -ge 60 ]; then
|
||||
echo "projectsend: database unreachable after 60s — check DB_HOST, DB_DATABASE and credentials" >&2
|
||||
echo "projectsend: gave up waiting for the database after 60s." >&2
|
||||
echo "projectsend: the last attempt failed with:" >&2
|
||||
printf '%s\n' "$probe_error" | tail -n 20 >&2
|
||||
echo "projectsend:" >&2
|
||||
echo "projectsend: if that names the database host, the connection or the credentials," >&2
|
||||
echo "projectsend: check DB_HOST, DB_DATABASE, DB_USERNAME and DB_PASSWORD." >&2
|
||||
echo "projectsend: if it is an application error, the database is fine and this is a" >&2
|
||||
echo "projectsend: bug — please report it at https://github.com/projectsend/projectsend/issues" >&2
|
||||
exit 1
|
||||
fi
|
||||
[ "$i" = 1 ] && echo "projectsend: waiting for the database..."
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Edita el fitxer",
|
||||
"Expires on": "Caduca el",
|
||||
"Make this file public": "Fes públic aquest fitxer",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Aquest lloc encara no té cap pàgina pública configurada, així que no es veurà res fins que un administrador en configuri una."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Aquest lloc encara no té cap pàgina pública configurada, així que no es veurà res fins que un administrador en configuri una.",
|
||||
"Show ProjectSend news on the dashboard": "Mostra les novetats de ProjectSend al tauler",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera els anuncis del projecte des de projectsend.org un cop al dia per a la targeta del tauler. Si ho desactives, aquesta instal·lació deixa de contactar amb projectsend.org per a novetats.",
|
||||
"Announcement": "Avís",
|
||||
"More": "Més",
|
||||
"Copy the public link to this file": "Copia l'enllaç públic d'aquest fitxer",
|
||||
"Downloaded :count times, last on :date": "Descarregat :count vegades, l'última el :date",
|
||||
"Downloaded once, on :date": "Descarregat una vegada, el :date",
|
||||
"Not downloaded yet": "Encara no s'ha descarregat",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Afegeix també la reclamació opcional \"xms_edov\" al registre de l'aplicació, a Configuració de testimoni. Indicar el tenant diu quin directori avala l'inici de sessió; aquesta reclamació diu que el directori ha comprovat que la persona és realment propietària de l'adreça. Sense ella, algú altre dins del teu tenant podria iniciar sessió amb l'adreça d'un company, de manera que ProjectSend crearà comptes nous però mai no vincularà un inici de sessió de Microsoft a un compte que ja existeix.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "El restabliment de contrasenya s'envia a aquesta adreça, així que canviar-la requereix la teva contrasenya.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "La teva adreça de correu prové del directori o del proveïdor d'identitat amb què inicies sessió, i no es pot canviar aquí.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Els enllaços de restabliment duren una hora. Demana'n un de nou i arribarà de seguida.",
|
||||
"Send me a new link": "Envia'm un enllaç nou",
|
||||
"This link has expired": "Aquest enllaç ha caducat",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Aquest enllaç de restabliment ja no és vàlid. Demana'n un de nou i torna-ho a provar.",
|
||||
"Authenticate as this server's IAM role": "Autentica't amb el rol d'IAM d'aquest servidor",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Per a instal·lacions a AWS que ja tinguin un rol assignat: un rol de tasca d'ECS, un perfil d'instància d'EC2, EKS/IRSA. ProjectSend demana credencials temporals al SDK d'AWS en lloc de desar una clau d'accés. Deixa-ho desactivat per a MinIO, Backblaze, Wasabi i qualsevol altre servei que necessiti una clau i un secret.",
|
||||
"Saving will delete the access key and secret currently stored here.": "En desar s'esborraran la clau d'accés i la clau secreta que hi ha desades aquí.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ja hi ha massa pujades en curs. Acaba'n o cancel·la'n una i torna-ho a provar."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Upravit soubor",
|
||||
"Expires on": "Vyprší dne",
|
||||
"Make this file public": "Zveřejnit tento soubor",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tento web zatím nemá nastavenou veřejnou stránku, takže nebude nic vidět, dokud ji administrátor nenastaví."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tento web zatím nemá nastavenou veřejnou stránku, takže nebude nic vidět, dokud ji administrátor nenastaví.",
|
||||
"Show ProjectSend news on the dashboard": "Zobrazovat novinky ProjectSendu v přehledu",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Jednou denně načte oznámení projektu z projectsend.org pro kartu v přehledu. Když to vypnete, tato instalace se kvůli novinkám na projectsend.org už vůbec nepřipojí.",
|
||||
"Announcement": "Oznámení",
|
||||
"More": "Další",
|
||||
"Copy the public link to this file": "Zkopírovat veřejný odkaz na tento soubor",
|
||||
"Downloaded :count times, last on :date": "Stažení: :count — naposledy :date",
|
||||
"Downloaded once, on :date": "Staženo jednou, :date",
|
||||
"Not downloaded yet": "Zatím nestaženo",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Přidejte také volitelný nárok \"xms_edov\" do registrace aplikace v části Konfigurace tokenu. Uvedení tenanta říká, který adresář se za přihlášení zaručil; tento nárok říká, že adresář ověřil, že adresa opravdu patří dané osobě. Bez něj by se někdo jiný ve vašem tenantovi mohl přihlásit adresou kolegy, takže ProjectSend bude vytvářet nové účty, ale nikdy nepřipojí přihlášení přes Microsoft k účtu, který už existuje.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Obnovení hesla se posílá na tuto adresu, takže její změna vyžaduje vaše heslo.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Vaše e-mailová adresa pochází z adresáře nebo poskytovatele identity, přes kterého se přihlašujete, a nelze ji zde změnit.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Odkazy pro obnovení platí jednu hodinu. Požádejte o nový a za chvíli dorazí.",
|
||||
"Send me a new link": "Pošlete mi nový odkaz",
|
||||
"This link has expired": "Platnost tohoto odkazu vypršela",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Tento odkaz pro obnovení už neplatí. Požádejte o nový a zkuste to znovu.",
|
||||
"Authenticate as this server's IAM role": "Ověřovat se rolí IAM tohoto serveru",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Pro instalace běžící na AWS, ke kterým je již přiřazena role – role úlohy ECS, profil instance EC2, EKS/IRSA. ProjectSend si vyžádá dočasné přihlašovací údaje od AWS SDK místo toho, aby ukládal přístupový klíč. Pro MinIO, Backblaze, Wasabi a cokoli dalšího, co vyžaduje klíč a tajný klíč, nechte vypnuté.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Uložením se smaže přístupový klíč i tajný klíč, které jsou zde nyní uložené.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Už probíhá příliš mnoho nahrávání. Dokončete nebo zrušte jedno z nich a zkuste to znovu."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Datei bearbeiten",
|
||||
"Expires on": "Läuft ab am",
|
||||
"Make this file public": "Diese Datei öffentlich machen",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Für diese Website ist noch keine öffentliche Seite eingerichtet, daher ist nichts sichtbar, bis ein Administrator eine einrichtet."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Für diese Website ist noch keine öffentliche Seite eingerichtet, daher ist nichts sichtbar, bis ein Administrator eine einrichtet.",
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend-Neuigkeiten in der Übersicht anzeigen",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Ruft einmal täglich Projektankündigungen von projectsend.org für die Karte in der Übersicht ab. Ausgeschaltet nimmt diese Installation für Neuigkeiten überhaupt keine Verbindung zu projectsend.org mehr auf.",
|
||||
"Announcement": "Ankündigung",
|
||||
"More": "Mehr",
|
||||
"Copy the public link to this file": "Öffentlichen Link zu dieser Datei kopieren",
|
||||
"Downloaded :count times, last on :date": ":count Mal heruntergeladen, zuletzt am :date",
|
||||
"Downloaded once, on :date": "Einmal heruntergeladen, am :date",
|
||||
"Not downloaded yet": "Noch nicht heruntergeladen",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Fügen Sie außerdem den optionalen Anspruch \"xms_edov\" unter Tokenkonfiguration zu Ihrer App-Registrierung hinzu. Die Angabe des Mandanten sagt, welches Verzeichnis für die Anmeldung bürgt; dieser Anspruch sagt, dass das Verzeichnis geprüft hat, dass die Person die Adresse wirklich besitzt. Ohne ihn könnte sich jemand anderes in Ihrem Mandanten mit der Adresse einer Kollegin anmelden, daher legt ProjectSend zwar neue Konten an, verknüpft eine Microsoft-Anmeldung aber nie mit einem bereits bestehenden Konto.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Eine Kennwortzurücksetzung geht an diese Adresse, daher ist für eine Änderung Ihr Kennwort erforderlich.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Ihre E-Mail-Adresse stammt aus dem Verzeichnis oder Identitätsanbieter, mit dem Sie sich anmelden, und kann hier nicht geändert werden.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Zurücksetzungslinks gelten eine Stunde. Fordern Sie einen neuen an, er trifft gleich ein.",
|
||||
"Send me a new link": "Neuen Link senden",
|
||||
"This link has expired": "Dieser Link ist abgelaufen",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Dieser Link zum Zurücksetzen ist nicht mehr gültig. Fordern Sie einen neuen an und versuchen Sie es erneut.",
|
||||
"Authenticate as this server's IAM role": "Mit der IAM-Rolle dieses Servers authentifizieren",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Für Installationen auf AWS, denen bereits eine Rolle zugewiesen ist – eine ECS-Task-Rolle, ein EC2-Instanzprofil, EKS/IRSA. ProjectSend fordert temporäre Anmeldedaten beim AWS SDK an, statt einen Zugriffsschlüssel zu speichern. Lassen Sie dies für MinIO, Backblaze, Wasabi und alles andere, was Schlüssel und geheimen Schlüssel benötigt, ausgeschaltet.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Beim Speichern werden der hier hinterlegte Zugriffsschlüssel und der geheime Schlüssel gelöscht.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Es laufen bereits zu viele Uploads. Beenden oder brechen Sie einen ab und versuchen Sie es erneut."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Editar archivo",
|
||||
"Expires on": "Vence el",
|
||||
"Make this file public": "Hacer público este archivo",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este sitio todavía no tiene una página pública configurada, así que no se verá nada hasta que un administrador la configure."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este sitio todavía no tiene una página pública configurada, así que no se verá nada hasta que un administrador la configure.",
|
||||
"Show ProjectSend news on the dashboard": "Mostrar las noticias de ProjectSend en el panel de control",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Trae los anuncios del proyecto desde projectsend.org una vez al día para la tarjeta del panel. Si lo desactivas, esta instalación deja de contactar a projectsend.org por noticias.",
|
||||
"Announcement": "Aviso",
|
||||
"More": "Más",
|
||||
"Copy the public link to this file": "Copiar el enlace público a este archivo",
|
||||
"Downloaded :count times, last on :date": "Descargado :count veces, la última el :date",
|
||||
"Downloaded once, on :date": "Descargado una vez, el :date",
|
||||
"Not downloaded yet": "Todavía no se descargó",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Añade también la notificación opcional \"xms_edov\" al registro de tu aplicación, en Configuración de token. Indicar el tenant dice qué directorio avaló el inicio de sesión; esa notificación dice que el directorio comprobó que la persona es realmente dueña de la dirección. Sin ella, alguien más dentro de tu tenant podría entrar con la dirección de un compañero, así que ProjectSend creará cuentas nuevas pero nunca enlazará un inicio de sesión de Microsoft con una cuenta que ya existe.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "El restablecimiento de contraseña se envía a esta dirección, así que cambiarla necesita tu contraseña.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Tu dirección de correo viene del directorio o proveedor de identidad con el que inicias sesión, y no se puede cambiar aquí.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Los enlaces de restablecimiento duran una hora. Pedí uno nuevo y llegará en un momento.",
|
||||
"Send me a new link": "Enviame un enlace nuevo",
|
||||
"This link has expired": "Este enlace ha caducado",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Este enlace de restablecimiento ya no es válido. Pedí uno nuevo y volvé a intentarlo.",
|
||||
"Authenticate as this server's IAM role": "Autenticarse con el rol de IAM de este servidor",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Para instalaciones en AWS que ya tengan un rol asignado: un rol de tarea de ECS, un perfil de instancia de EC2, EKS/IRSA. ProjectSend le pide credenciales temporales al SDK de AWS en lugar de guardar una clave de acceso. Déjalo desactivado para MinIO, Backblaze, Wasabi y cualquier otro servicio que necesite una clave y una clave secreta.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Al guardar se borrarán la clave de acceso y la clave secreta que hay guardadas aquí.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ya hay demasiadas subidas en curso. Termina o cancela una e inténtalo de nuevo."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Modifier le fichier",
|
||||
"Expires on": "Expire le",
|
||||
"Make this file public": "Rendre ce fichier public",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ce site n'a pas encore de page publique configurée, donc rien ne sera visible tant qu'un administrateur n'en aura pas configuré une."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ce site n'a pas encore de page publique configurée, donc rien ne sera visible tant qu'un administrateur n'en aura pas configuré une.",
|
||||
"Show ProjectSend news on the dashboard": "Afficher les actualités ProjectSend sur le tableau de bord",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Récupère une fois par jour les annonces du projet depuis projectsend.org pour la carte du tableau de bord. Désactivé, cette installation ne contacte plus du tout projectsend.org pour les actualités.",
|
||||
"Announcement": "Annonce",
|
||||
"More": "Plus",
|
||||
"Copy the public link to this file": "Copier le lien public vers ce fichier",
|
||||
"Downloaded :count times, last on :date": "Téléchargé :count fois, la dernière le :date",
|
||||
"Downloaded once, on :date": "Téléchargé une fois, le :date",
|
||||
"Not downloaded yet": "Pas encore téléchargé",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Ajoutez également la revendication facultative « xms_edov » à votre inscription d'application, sous Configuration des jetons. Indiquer le locataire dit quel annuaire s'est porté garant de la connexion ; cette revendication dit que l'annuaire a vérifié que la personne possède réellement l'adresse. Sans elle, quelqu'un d'autre dans votre locataire pourrait se connecter avec l'adresse d'un collègue, aussi ProjectSend créera de nouveaux comptes mais ne rattachera jamais une connexion Microsoft à un compte existant.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Une réinitialisation de mot de passe part vers cette adresse, la modifier demande donc votre mot de passe.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Votre adresse e-mail provient de l'annuaire ou du fournisseur d'identité avec lequel vous vous connectez, et ne peut pas être modifiée ici.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Les liens de réinitialisation durent une heure. Demandez-en un nouveau, il arrivera dans un instant.",
|
||||
"Send me a new link": "Envoyez-moi un nouveau lien",
|
||||
"This link has expired": "Ce lien a expiré",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Ce lien de réinitialisation n'est plus valide. Demandez-en un nouveau et réessayez.",
|
||||
"Authenticate as this server's IAM role": "S'authentifier avec le rôle IAM de ce serveur",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Pour les installations sur AWS disposant déjà d'un rôle — un rôle de tâche ECS, un profil d'instance EC2, EKS/IRSA. ProjectSend demande des identifiants temporaires au SDK AWS au lieu de stocker une clé d'accès. Laissez cette option désactivée pour MinIO, Backblaze, Wasabi et tout autre service nécessitant une clé et une clé secrète.",
|
||||
"Saving will delete the access key and secret currently stored here.": "L'enregistrement supprimera la clé d'accès et la clé secrète actuellement stockées ici.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Trop de téléversements sont déjà en cours. Terminez-en un ou annulez-en un, puis réessayez."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Sunting berkas",
|
||||
"Expires on": "Kedaluwarsa pada",
|
||||
"Make this file public": "Jadikan berkas ini publik",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Situs ini belum punya halaman publik, jadi tidak ada yang terlihat sampai administrator menyiapkannya."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Situs ini belum punya halaman publik, jadi tidak ada yang terlihat sampai administrator menyiapkannya.",
|
||||
"Show ProjectSend news on the dashboard": "Tampilkan kabar terbaru ProjectSend di dasbor",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Mengambil pengumuman proyek dari projectsend.org sekali sehari untuk kartu di dasbor. Jika dimatikan, instalasi ini sama sekali tidak lagi menghubungi projectsend.org untuk kabar terbaru.",
|
||||
"Announcement": "Pengumuman",
|
||||
"More": "Lainnya",
|
||||
"Copy the public link to this file": "Salin tautan publik ke berkas ini",
|
||||
"Downloaded :count times, last on :date": "Diunduh :count kali, terakhir pada :date",
|
||||
"Downloaded once, on :date": "Diunduh sekali, pada :date",
|
||||
"Not downloaded yet": "Belum pernah diunduh",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Tambahkan juga klaim opsional \"xms_edov\" ke pendaftaran aplikasi Anda, di bagian Token configuration. Menyebutkan tenant memberi tahu direktori mana yang menjamin proses masuk; klaim itu menyatakan bahwa direktori telah memeriksa bahwa orang tersebut benar-benar memiliki alamat itu. Tanpanya, orang lain di dalam tenant Anda dapat masuk dengan alamat rekan kerja, sehingga ProjectSend akan membuat akun baru tetapi tidak pernah menautkan proses masuk Microsoft ke akun yang sudah ada.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Pengaturan ulang kata sandi dikirim ke alamat ini, jadi mengubahnya memerlukan kata sandi Anda.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Alamat email Anda berasal dari direktori atau penyedia identitas tempat Anda masuk, dan tidak dapat diubah di sini.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Tautan atur ulang berlaku satu jam. Minta yang baru dan akan tiba sebentar lagi.",
|
||||
"Send me a new link": "Kirimi saya tautan baru",
|
||||
"This link has expired": "Tautan ini sudah kedaluwarsa",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Tautan atur ulang ini sudah tidak berlaku. Minta yang baru dan coba lagi.",
|
||||
"Authenticate as this server's IAM role": "Autentikasi sebagai peran IAM server ini",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Untuk instalasi yang berjalan di AWS dan sudah memiliki peran terpasang — peran tugas ECS, profil instance EC2, EKS/IRSA. ProjectSend meminta kredensial sementara kepada AWS SDK alih-alih menyimpan kunci akses. Biarkan nonaktif untuk MinIO, Backblaze, Wasabi, dan layanan lain yang memerlukan kunci akses dan kunci rahasia.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Menyimpan akan menghapus kunci akses dan kunci rahasia yang tersimpan di sini.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Sudah terlalu banyak unggahan yang sedang berjalan. Selesaikan atau batalkan salah satunya, lalu coba lagi."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Modifica file",
|
||||
"Expires on": "Scade il",
|
||||
"Make this file public": "Rendi pubblico questo file",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Questo sito non ha ancora una pagina pubblica, quindi non sarà visibile nulla finché un amministratore non ne configura una."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Questo sito non ha ancora una pagina pubblica, quindi non sarà visibile nulla finché un amministratore non ne configura una.",
|
||||
"Show ProjectSend news on the dashboard": "Mostra le novità di ProjectSend nel pannello",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera gli annunci del progetto da projectsend.org una volta al giorno per la scheda del pannello. Disattivandolo, questa installazione smette del tutto di contattare projectsend.org per le novità.",
|
||||
"Announcement": "Avviso",
|
||||
"More": "Altro",
|
||||
"Copy the public link to this file": "Copia il link pubblico a questo file",
|
||||
"Downloaded :count times, last on :date": "Scaricato :count volte, l'ultima il :date",
|
||||
"Downloaded once, on :date": "Scaricato una volta, il :date",
|
||||
"Not downloaded yet": "Non ancora scaricato",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Aggiungi anche l'attestazione facoltativa \"xms_edov\" alla registrazione dell'app, in Configurazione token. Indicare il tenant dice quale directory ha garantito per l'accesso; quell'attestazione dice che la directory ha verificato che la persona possieda davvero l'indirizzo. Senza di essa, qualcun altro nel tuo tenant potrebbe accedere con l'indirizzo di un collega, quindi ProjectSend creerà nuovi account ma non collegherà mai un accesso Microsoft a un account già esistente.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "La reimpostazione della password viene inviata a questo indirizzo, quindi cambiarlo richiede la tua password.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Il tuo indirizzo email proviene dalla directory o dal provider di identità con cui accedi e non può essere modificato qui.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "I link di reimpostazione durano un'ora. Chiedine uno nuovo e arriverà tra un momento.",
|
||||
"Send me a new link": "Inviami un nuovo link",
|
||||
"This link has expired": "Questo link è scaduto",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Questo link di reimpostazione non è più valido. Chiedine uno nuovo e riprova.",
|
||||
"Authenticate as this server's IAM role": "Autenticati con il ruolo IAM di questo server",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Per installazioni su AWS a cui è già associato un ruolo — un ruolo attività ECS, un profilo istanza EC2, EKS/IRSA. ProjectSend chiede credenziali temporanee all'SDK di AWS invece di memorizzare una chiave di accesso. Lascialo disattivato per MinIO, Backblaze, Wasabi e qualsiasi altro servizio che richieda una chiave di accesso e una chiave segreta.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Salvando verranno eliminate la chiave di accesso e la chiave segreta memorizzate qui.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ci sono già troppi caricamenti in corso. Completane o annullane uno e riprova."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "ファイルを編集",
|
||||
"Expires on": "有効期限",
|
||||
"Make this file public": "このファイルを公開する",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。"
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "このサイトにはまだ公開ページが設定されていないため、管理者が設定するまで何も表示されません。",
|
||||
"Show ProjectSend news on the dashboard": "ダッシュボードに ProjectSend のお知らせを表示する",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "ダッシュボードのカード用に、プロジェクトのお知らせを projectsend.org から1日1回取得します。オフにすると、このインストールはお知らせのために projectsend.org へ接続しなくなります。",
|
||||
"Announcement": "お知らせ",
|
||||
"More": "その他",
|
||||
"Copy the public link to this file": "このファイルの公開リンクをコピー",
|
||||
"Downloaded :count times, last on :date": ":count 回ダウンロードされました。最終 :date",
|
||||
"Downloaded once, on :date": "1 回ダウンロードされました。:date",
|
||||
"Not downloaded yet": "まだダウンロードされていません",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "アプリ登録の「トークン構成」で、オプションの要求「xms_edov」も追加してください。テナントの指定は、どのディレクトリがサインインを保証したかを示します。この要求は、その人が本当にそのアドレスの持ち主であることをディレクトリが確認したことを示します。これがないと、テナント内の別の人が同僚のアドレスでサインインできてしまうため、ProjectSend は新しいアカウントは作成しますが、既存のアカウントに Microsoft サインインを結び付けることはありません。",
|
||||
"A password reset goes to this address, so changing it needs your password.": "パスワードの再設定はこのアドレスに届くため、変更にはパスワードが必要です。",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "メールアドレスはサインインに使用しているディレクトリまたは ID プロバイダーのもので、ここでは変更できません。",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "再設定リンクの有効期間は 1 時間です。新しいリンクを申し込めば、すぐに届きます。",
|
||||
"Send me a new link": "新しいリンクを送る",
|
||||
"This link has expired": "このリンクは有効期限が切れています",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "この再設定リンクは無効になりました。新しいリンクを申し込んでもう一度お試しください。",
|
||||
"Authenticate as this server's IAM role": "このサーバーの IAM ロールとして認証する",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "すでにロールが割り当てられている AWS 上のインストール向けです(ECS タスクロール、EC2 インスタンスプロファイル、EKS/IRSA)。ProjectSend はアクセスキーを保存する代わりに、AWS SDK から一時的な認証情報を取得します。MinIO、Backblaze、Wasabi など、アクセスキーとシークレットキーが必要なサービスではオフのままにしてください。",
|
||||
"Saving will delete the access key and secret currently stored here.": "保存すると、ここに保存されているアクセスキーとシークレットキーは削除されます。",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "すでに進行中のアップロードが多すぎます。どれか一つを完了するか取り消してから、もう一度お試しください。"
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Bestand bewerken",
|
||||
"Expires on": "Verloopt op",
|
||||
"Make this file public": "Dit bestand openbaar maken",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Deze site heeft nog geen openbare pagina, dus er is niets zichtbaar totdat een beheerder er een instelt."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Deze site heeft nog geen openbare pagina, dus er is niets zichtbaar totdat een beheerder er een instelt.",
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend-nieuws op het overzicht tonen",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Haalt eens per dag projectaankondigingen op van projectsend.org voor de kaart op het overzicht. Uitgeschakeld neemt deze installatie voor nieuws helemaal geen contact meer op met projectsend.org.",
|
||||
"Announcement": "Mededeling",
|
||||
"More": "Meer",
|
||||
"Copy the public link to this file": "Kopieer de openbare link naar dit bestand",
|
||||
"Downloaded :count times, last on :date": ":count keer gedownload, laatst op :date",
|
||||
"Downloaded once, on :date": "Eén keer gedownload, op :date",
|
||||
"Not downloaded yet": "Nog niet gedownload",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Voeg ook de optionele claim \"xms_edov\" toe aan je app-registratie, onder Tokenconfiguratie. De tenant noemen zegt welke directory voor de aanmelding instaat; die claim zegt dat de directory heeft gecontroleerd dat de persoon het adres echt bezit. Zonder die claim kan iemand anders binnen je tenant zich aanmelden met het adres van een collega, dus ProjectSend maakt wel nieuwe accounts aan maar koppelt een Microsoft-aanmelding nooit aan een account dat al bestaat.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Een wachtwoordherstel gaat naar dit adres, dus het wijzigen ervan vraagt om je wachtwoord.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Je e-mailadres komt van de directory of identiteitsprovider waarmee je je aanmeldt en kan hier niet worden gewijzigd.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Herstellinks zijn een uur geldig. Vraag een nieuwe aan, die komt zo binnen.",
|
||||
"Send me a new link": "Stuur me een nieuwe link",
|
||||
"This link has expired": "Deze link is verlopen",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Deze herstellink is niet meer geldig. Vraag een nieuwe aan en probeer het opnieuw.",
|
||||
"Authenticate as this server's IAM role": "Verifiëren met de IAM-rol van deze server",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Voor installaties op AWS waaraan al een rol is gekoppeld — een ECS-taakrol, een EC2-instantieprofiel, EKS/IRSA. ProjectSend vraagt tijdelijke inloggegevens op bij de AWS SDK in plaats van een toegangssleutel op te slaan. Laat dit uit staan voor MinIO, Backblaze, Wasabi en al het andere dat een toegangssleutel en geheime sleutel nodig heeft.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Bij het opslaan worden de hier opgeslagen toegangssleutel en geheime sleutel verwijderd.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Er lopen al te veel uploads. Rond er een af of annuleer er een en probeer het opnieuw."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Edytuj plik",
|
||||
"Expires on": "Wygasa",
|
||||
"Make this file public": "Ustaw ten plik jako publiczny",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ta witryna nie ma jeszcze skonfigurowanej strony publicznej, więc nic nie będzie widoczne, dopóki administrator jej nie ustawi."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Ta witryna nie ma jeszcze skonfigurowanej strony publicznej, więc nic nie będzie widoczne, dopóki administrator jej nie ustawi.",
|
||||
"Show ProjectSend news on the dashboard": "Pokazuj aktualności ProjectSend na pulpicie",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Raz dziennie pobiera ogłoszenia projektu z projectsend.org na kartę pulpitu. Po wyłączeniu ta instalacja w ogóle przestaje łączyć się z projectsend.org po aktualności.",
|
||||
"Announcement": "Ogłoszenie",
|
||||
"More": "Więcej",
|
||||
"Copy the public link to this file": "Skopiuj publiczny link do tego pliku",
|
||||
"Downloaded :count times, last on :date": "Pobrania: :count — ostatnie :date",
|
||||
"Downloaded once, on :date": "Pobrano raz, :date",
|
||||
"Not downloaded yet": "Jeszcze nie pobrano",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Dodaj też opcjonalne oświadczenie \"xms_edov\" do rejestracji aplikacji, w sekcji Konfiguracja tokenu. Wskazanie dzierżawy mówi, który katalog poręczył za logowanie; to oświadczenie mówi, że katalog sprawdził, iż dana osoba naprawdę jest właścicielem adresu. Bez niego ktoś inny w Twojej dzierżawie mógłby zalogować się adresem współpracownika, więc ProjectSend będzie tworzyć nowe konta, ale nigdy nie powiąże logowania Microsoft z kontem, które już istnieje.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Resetowanie hasła trafia na ten adres, więc jego zmiana wymaga Twojego hasła.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Twój adres e-mail pochodzi z katalogu lub dostawcy tożsamości, przez którego się logujesz, i nie można go tu zmienić.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Linki do resetowania są ważne godzinę. Poproś o nowy, dotrze za chwilę.",
|
||||
"Send me a new link": "Wyślij mi nowy link",
|
||||
"This link has expired": "Ten link wygasł",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Ten link do resetowania nie jest już ważny. Poproś o nowy i spróbuj ponownie.",
|
||||
"Authenticate as this server's IAM role": "Uwierzytelniaj się rolą IAM tego serwera",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Dla instalacji działających na AWS, do których przypisano już rolę — rola zadania ECS, profil instancji EC2, EKS/IRSA. ProjectSend prosi AWS SDK o tymczasowe poświadczenia zamiast przechowywać klucz dostępu. Pozostaw wyłączone dla MinIO, Backblaze, Wasabi i wszystkiego innego, co wymaga klucza dostępu i klucza tajnego.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Zapisanie usunie klucz dostępu i klucz tajny obecnie tu przechowywane.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Trwa już zbyt wiele przesyłań. Zakończ lub anuluj jedno z nich i spróbuj ponownie."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Editar arquivo",
|
||||
"Expires on": "Expira em",
|
||||
"Make this file public": "Tornar este arquivo público",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este site ainda não tem uma página pública configurada, então nada ficará visível até que um administrador configure uma."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Este site ainda não tem uma página pública configurada, então nada ficará visível até que um administrador configure uma.",
|
||||
"Show ProjectSend news on the dashboard": "Mostrar novidades do ProjectSend no painel",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Busca os anúncios do projeto em projectsend.org uma vez por dia para o cartão do painel. Se você desativar, esta instalação para de contatar o projectsend.org por novidades.",
|
||||
"Announcement": "Aviso",
|
||||
"More": "Mais",
|
||||
"Copy the public link to this file": "Copiar o link público para este arquivo",
|
||||
"Downloaded :count times, last on :date": "Baixado :count vezes, a última em :date",
|
||||
"Downloaded once, on :date": "Baixado uma vez, em :date",
|
||||
"Not downloaded yet": "Ainda não foi baixado",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Adicione também a declaração opcional \"xms_edov\" ao registro do aplicativo, em Configuração de token. Informar o locatário diz qual diretório respondeu pelo login; essa declaração diz que o diretório verificou que a pessoa realmente é dona do endereço. Sem ela, outra pessoa dentro do seu locatário poderia entrar com o endereço de um colega, então o ProjectSend criará contas novas mas nunca vinculará um login da Microsoft a uma conta que já existe.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "A redefinição de senha vai para este endereço, então alterá-lo exige a sua senha.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Seu endereço de e-mail vem do diretório ou provedor de identidade com que você entra, e não pode ser alterado aqui.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Os links de redefinição duram uma hora. Peça um novo e ele chega em instantes.",
|
||||
"Send me a new link": "Envie-me um novo link",
|
||||
"This link has expired": "Este link expirou",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Este link de redefinição não é mais válido. Peça um novo e tente de novo.",
|
||||
"Authenticate as this server's IAM role": "Autenticar com a função IAM deste servidor",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Para instalações na AWS que já tenham uma função associada — uma função de tarefa do ECS, um perfil de instância do EC2, EKS/IRSA. O ProjectSend pede credenciais temporárias ao SDK da AWS em vez de armazenar uma chave de acesso. Deixe desativado para MinIO, Backblaze, Wasabi e qualquer outro serviço que precise de chave de acesso e chave secreta.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Ao salvar, a chave de acesso e a chave secreta armazenadas aqui serão excluídas.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Já há envios demais em andamento. Conclua ou cancele um deles e tente novamente."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Редактировать файл",
|
||||
"Expires on": "Срок действия до",
|
||||
"Make this file public": "Сделать этот файл публичным",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "На этом сайте ещё не настроена публичная страница, поэтому ничего не будет видно, пока администратор её не настроит.",
|
||||
"Show ProjectSend news on the dashboard": "Показывать новости ProjectSend на панели",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Раз в день загружает анонсы проекта с projectsend.org для карточки на панели. Если выключить, эта установка вообще перестанет обращаться к projectsend.org за новостями.",
|
||||
"Announcement": "Объявление",
|
||||
"More": "Ещё",
|
||||
"Copy the public link to this file": "Скопировать публичную ссылку на этот файл",
|
||||
"Downloaded :count times, last on :date": "Скачиваний: :count — последнее :date",
|
||||
"Downloaded once, on :date": "Скачан один раз, :date",
|
||||
"Not downloaded yet": "Ещё не скачивался",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Также добавьте необязательное утверждение «xms_edov» в регистрацию приложения, в разделе «Конфигурация токена». Указание клиента говорит, какой каталог поручился за вход; это утверждение говорит, что каталог проверил, что адрес действительно принадлежит человеку. Без него кто-то другой внутри вашего клиента смог бы войти с адресом коллеги, поэтому ProjectSend будет создавать новые учётные записи, но никогда не привяжет вход через Microsoft к уже существующей.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Сброс пароля отправляется на этот адрес, поэтому его изменение требует вашего пароля.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Ваш адрес электронной почты берётся из каталога или поставщика удостоверений, через который вы входите, и здесь его изменить нельзя.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Ссылки для сброса действуют один час. Запросите новую — она придёт через мгновение.",
|
||||
"Send me a new link": "Прислать новую ссылку",
|
||||
"This link has expired": "Срок действия ссылки истёк",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Эта ссылка для сброса больше не действует. Запросите новую и попробуйте снова.",
|
||||
"Authenticate as this server's IAM role": "Аутентификация через роль IAM этого сервера",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Для установок на AWS, которым уже назначена роль, — роль задачи ECS, профиль экземпляра EC2, EKS/IRSA. ProjectSend запрашивает временные учётные данные у AWS SDK вместо того, чтобы хранить ключ доступа. Оставьте выключенным для MinIO, Backblaze, Wasabi и всего остального, чему нужны ключ доступа и секретный ключ.",
|
||||
"Saving will delete the access key and secret currently stored here.": "При сохранении хранящиеся здесь ключ доступа и секретный ключ будут удалены.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Уже выполняется слишком много загрузок. Завершите или отмените одну из них и попробуйте снова."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Hariri faili",
|
||||
"Expires on": "Litaisha muda tarehe",
|
||||
"Make this file public": "Fanya faili hili liwe la umma",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tovuti hii bado haina ukurasa wa umma, kwa hivyo hakuna kitakachoonekana hadi msimamizi aweke mmoja."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Tovuti hii bado haina ukurasa wa umma, kwa hivyo hakuna kitakachoonekana hadi msimamizi aweke mmoja.",
|
||||
"Show ProjectSend news on the dashboard": "Onyesha habari za ProjectSend kwenye dashibodi",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Huleta matangazo ya mradi kutoka projectsend.org mara moja kwa siku kwa ajili ya kadi ya dashibodi. Ukiizima, usakinishaji huu hautawasiliana kabisa na projectsend.org kwa habari.",
|
||||
"Announcement": "Tangazo",
|
||||
"More": "Zaidi",
|
||||
"Copy the public link to this file": "Nakili kiungo cha umma cha faili hili",
|
||||
"Downloaded :count times, last on :date": "Imepakuliwa mara :count, mara ya mwisho :date",
|
||||
"Downloaded once, on :date": "Imepakuliwa mara moja, :date",
|
||||
"Not downloaded yet": "Bado haijapakuliwa",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Ongeza pia dai la hiari \"xms_edov\" kwenye usajili wa programu yako, chini ya Token configuration. Kutaja mpangaji kunaeleza ni saraka gani iliyodhamini kuingia; dai hilo linaeleza kuwa saraka imethibitisha kuwa mtu huyo ndiye mmiliki halisi wa anwani. Bila hilo, mtu mwingine ndani ya mpangaji wako anaweza kuingia kwa anwani ya mwenzake, hivyo ProjectSend itaunda akaunti mpya lakini haitaunganisha kamwe kuingia kwa Microsoft na akaunti iliyopo.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Kuweka upya nenosiri hutumwa kwa anwani hii, hivyo kuibadilisha kunahitaji nenosiri lako.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Anwani yako ya barua pepe inatoka kwenye saraka au mtoa utambulisho unaotumia kuingia, na haiwezi kubadilishwa hapa.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Viungo vya kuweka upya hudumu saa moja. Omba kipya na kitafika punde.",
|
||||
"Send me a new link": "Nitumie kiungo kipya",
|
||||
"This link has expired": "Kiungo hiki kimeisha muda",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Kiungo hiki cha kuweka upya hakifanyi kazi tena. Omba kipya kisha ujaribu tena.",
|
||||
"Authenticate as this server's IAM role": "Thibitisha kwa jukumu la IAM la seva hii",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Kwa usakinishaji unaoendeshwa kwenye AWS ambao tayari una jukumu lililoambatishwa — jukumu la kazi la ECS, wasifu wa mfano wa EC2, EKS/IRSA. ProjectSend huomba AWS SDK kitambulisho cha muda badala ya kuhifadhi ufunguo wa ufikiaji. Iache imezimwa kwa MinIO, Backblaze, Wasabi na kitu kingine chochote kinachohitaji ufunguo wa ufikiaji na ufunguo wa siri.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Kuhifadhi kutafuta ufunguo wa ufikiaji na ufunguo wa siri vilivyohifadhiwa hapa.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Tayari kuna upakiaji mwingi mno unaoendelea. Maliza au ghairi mmoja kisha ujaribu tena."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Dosyayı düzenle",
|
||||
"Expires on": "Sona erme tarihi",
|
||||
"Make this file public": "Bu dosyayı herkese açık yap",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Bu sitede henüz herkese açık bir sayfa ayarlanmamış, bu yüzden bir yönetici ayarlayana kadar hiçbir şey görünmeyecek."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Bu sitede henüz herkese açık bir sayfa ayarlanmamış, bu yüzden bir yönetici ayarlayana kadar hiçbir şey görünmeyecek.",
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend haberlerini panelde göster",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Panel kartı için proje duyurularını günde bir kez projectsend.org adresinden alır. Kapatıldığında bu kurulum haberler için projectsend.org ile hiç bağlantı kurmaz.",
|
||||
"Announcement": "Duyuru",
|
||||
"More": "Daha fazla",
|
||||
"Copy the public link to this file": "Bu dosyanın herkese açık bağlantısını kopyalayın",
|
||||
"Downloaded :count times, last on :date": ":count kez indirildi, son olarak :date",
|
||||
"Downloaded once, on :date": "Bir kez indirildi, :date",
|
||||
"Not downloaded yet": "Henüz indirilmedi",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Uygulama kaydınıza, Belirteç yapılandırması altında \"xms_edov\" isteğe bağlı talebini de ekleyin. Kiracıyı belirtmek, oturum açma için hangi dizinin kefil olduğunu söyler; bu talep ise dizinin, kişinin adresin gerçekten sahibi olduğunu doğruladığını söyler. Bu olmadan, kiracınızdaki başka biri bir iş arkadaşının adresiyle oturum açabilir; bu nedenle ProjectSend yeni hesaplar oluşturur ancak bir Microsoft oturum açma işlemini var olan bir hesaba asla bağlamaz.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Parola sıfırlama bu adrese gönderilir, bu yüzden değiştirmek parolanızı gerektirir.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "E-posta adresiniz, oturum açtığınız dizinden veya kimlik sağlayıcısından gelir ve burada değiştirilemez.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Sıfırlama bağlantıları bir saat geçerlidir. Yenisini isteyin, birazdan ulaşır.",
|
||||
"Send me a new link": "Bana yeni bir bağlantı gönder",
|
||||
"This link has expired": "Bu bağlantının süresi doldu",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Bu sıfırlama bağlantısı artık geçerli değil. Yenisini isteyip tekrar deneyin.",
|
||||
"Authenticate as this server's IAM role": "Bu sunucunun IAM rolüyle kimlik doğrula",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "AWS üzerinde çalışan ve halihazırda bir rol atanmış kurulumlar için — bir ECS görev rolü, bir EC2 örnek profili, EKS/IRSA. ProjectSend, erişim anahtarı saklamak yerine AWS SDK'dan geçici kimlik bilgileri ister. MinIO, Backblaze, Wasabi ve erişim anahtarı ile gizli anahtar gerektiren diğer her şey için kapalı bırakın.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Kaydetmek, burada saklanan erişim anahtarını ve gizli anahtarı silecek.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Zaten çok fazla yükleme sürüyor. Birini tamamlayın veya iptal edin, sonra tekrar deneyin."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "Sửa tệp",
|
||||
"Expires on": "Hết hạn vào",
|
||||
"Make this file public": "Công khai tệp này",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Trang web này chưa thiết lập trang công khai, nên sẽ không có gì hiển thị cho đến khi quản trị viên thiết lập."
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "Trang web này chưa thiết lập trang công khai, nên sẽ không có gì hiển thị cho đến khi quản trị viên thiết lập.",
|
||||
"Show ProjectSend news on the dashboard": "Hiển thị tin tức ProjectSend trên bảng điều khiển",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Tải thông báo của dự án từ projectsend.org mỗi ngày một lần cho thẻ trên bảng điều khiển. Khi tắt, bản cài đặt này sẽ hoàn toàn không liên hệ với projectsend.org để lấy tin tức.",
|
||||
"Announcement": "Thông báo",
|
||||
"More": "Thêm",
|
||||
"Copy the public link to this file": "Sao chép liên kết công khai tới tệp này",
|
||||
"Downloaded :count times, last on :date": "Đã tải xuống :count lần, lần cuối :date",
|
||||
"Downloaded once, on :date": "Đã tải xuống một lần, :date",
|
||||
"Not downloaded yet": "Chưa được tải xuống",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Đồng thời hãy thêm xác nhận quyền tùy chọn \"xms_edov\" vào đăng ký ứng dụng của bạn, trong phần Cấu hình mã thông báo. Việc nêu tên tenant cho biết thư mục nào đã bảo đảm cho lần đăng nhập; xác nhận quyền đó cho biết thư mục đã kiểm tra rằng người này thực sự sở hữu địa chỉ. Không có nó, một người khác trong tenant của bạn có thể đăng nhập bằng địa chỉ của đồng nghiệp, nên ProjectSend sẽ tạo tài khoản mới nhưng không bao giờ gắn một lần đăng nhập Microsoft vào tài khoản đã tồn tại.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Việc đặt lại mật khẩu sẽ gửi tới địa chỉ này, nên thay đổi nó cần mật khẩu của bạn.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Địa chỉ email của bạn đến từ thư mục hoặc nhà cung cấp danh tính mà bạn dùng để đăng nhập, và không thể thay đổi tại đây.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Liên kết đặt lại có hiệu lực một giờ. Hãy yêu cầu liên kết mới, nó sẽ đến ngay.",
|
||||
"Send me a new link": "Gửi cho tôi liên kết mới",
|
||||
"This link has expired": "Liên kết này đã hết hạn",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Liên kết đặt lại này không còn hiệu lực. Hãy yêu cầu liên kết mới và thử lại.",
|
||||
"Authenticate as this server's IAM role": "Xác thực bằng vai trò IAM của máy chủ này",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Dành cho các bản cài đặt chạy trên AWS đã được gắn sẵn một vai trò — vai trò tác vụ ECS, hồ sơ phiên bản EC2, EKS/IRSA. ProjectSend yêu cầu AWS SDK cấp thông tin đăng nhập tạm thời thay vì lưu khóa truy cập. Hãy để tắt với MinIO, Backblaze, Wasabi và bất kỳ dịch vụ nào khác cần khóa truy cập và khóa bí mật.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Việc lưu sẽ xóa khóa truy cập và khóa bí mật đang được lưu ở đây.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Đã có quá nhiều lần tải lên đang diễn ra. Hãy hoàn tất hoặc hủy một lần rồi thử lại."
|
||||
}
|
||||
|
||||
+20
-1
@@ -2013,5 +2013,24 @@
|
||||
"Edit file": "编辑文件",
|
||||
"Expires on": "到期日",
|
||||
"Make this file public": "将此文件设为公开",
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。"
|
||||
"This site has no public page set up yet, so nothing will be visible until an administrator sets one.": "本站尚未设置公开页面,因此在管理员设置之前不会显示任何内容。",
|
||||
"Show ProjectSend news on the dashboard": "在仪表板上显示 ProjectSend 动态",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "每天一次从 projectsend.org 获取项目公告,用于仪表板卡片。关闭后,本安装将完全不再因动态而连接 projectsend.org。",
|
||||
"Announcement": "公告",
|
||||
"More": "更多",
|
||||
"Copy the public link to this file": "复制此文件的公开链接",
|
||||
"Downloaded :count times, last on :date": "已下载 :count 次,最近一次 :date",
|
||||
"Downloaded once, on :date": "已下载 1 次,:date",
|
||||
"Not downloaded yet": "尚未下载",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "还请在应用注册的“令牌配置”中添加可选声明“xms_edov”。指定租户说明是哪个目录为此次登录背书;该声明则说明目录已核实此人确实拥有该地址。没有它,你租户内的其他人就能用同事的地址登录,因此 ProjectSend 会创建新账户,但绝不会把 Microsoft 登录关联到已存在的账户。",
|
||||
"A password reset goes to this address, so changing it needs your password.": "密码重置会发送到这个地址,所以修改它需要你的密码。",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "你的邮箱地址来自你用于登录的目录或身份提供方,无法在此处修改。",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "重置链接有效期为一小时。申请一个新的,稍后即可收到。",
|
||||
"Send me a new link": "给我发送新链接",
|
||||
"This link has expired": "此链接已过期",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "此重置链接已失效。请申请一个新的链接后重试。",
|
||||
"Authenticate as this server's IAM role": "使用此服务器的 IAM 角色进行认证",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "适用于已附加角色的 AWS 上的安装 —— ECS 任务角色、EC2 实例配置文件、EKS/IRSA。ProjectSend 会向 AWS SDK 申请临时凭证,而不是保存访问密钥。对于 MinIO、Backblaze、Wasabi 以及其他需要访问密钥和私有密钥的服务,请保持关闭。",
|
||||
"Saving will delete the access key and secret currently stored here.": "保存后会删除此处当前保存的访问密钥和私有密钥。",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "正在进行的上传太多了。请先完成或取消一个,然后重试。"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# If every page returns a 500 and storage/logs/ is empty, Apache could
|
||||
# not work out which directory this file is serving, and the rule below
|
||||
# rewrites to a path that does not exist — over and over, until Apache
|
||||
# gives up. Some shared hosts need to be told. Uncomment the line and
|
||||
# set it to the path ProjectSend is served from: "/" at the domain root,
|
||||
# "/projectsend" in a subdirectory of it.
|
||||
#
|
||||
# RewriteBase /
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { type SharedData } from '@/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { ExternalLink, Megaphone } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
|
||||
export interface Announcement {
|
||||
title: string;
|
||||
body: string;
|
||||
action_label: string | null;
|
||||
action_url: string | null;
|
||||
tone: 'info' | 'warning' | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One message, shown two ways, from one shared prop — see
|
||||
* ResolvingAnnouncement. The band is the dashboard; the icon beside the
|
||||
* notification bell carries the same words to every other page, so
|
||||
* somebody who never opens the dashboard still meets it once.
|
||||
*
|
||||
* Both live in this file deliberately. They have to say the same thing,
|
||||
* and the reliable way to keep two renderings of one message identical is
|
||||
* for them to share the component that renders it.
|
||||
*
|
||||
* Nothing here knows what it is saying. Title, body and button all arrive
|
||||
* from whatever listened.
|
||||
*
|
||||
* Coloured enough to be read and not enough to alarm: a tinted left edge
|
||||
* and a matching wash, rather than a saturated block. The first caller
|
||||
* tells a hosted customer their plan has limits and a bigger one exists,
|
||||
* which is worth noticing and not worth interrupting for — so it must not
|
||||
* look like an outage. Both palettes are declared per tone rather than
|
||||
* derived, so the dark variant is a deliberate colour rather than
|
||||
* whatever the light one happens to become.
|
||||
*/
|
||||
const TONES: Record<string, string> = {
|
||||
info: 'border-l-sky-500 bg-sky-50 dark:bg-sky-950/40',
|
||||
warning: 'border-l-amber-500 bg-amber-50 dark:bg-amber-950/40',
|
||||
};
|
||||
|
||||
const DOT_TONES: Record<string, string> = {
|
||||
info: 'bg-sky-500',
|
||||
warning: 'bg-amber-500',
|
||||
};
|
||||
|
||||
/** The words, and the button if there is one. Shared by both surfaces. */
|
||||
function AnnouncementBody({ announcement, compact = false }: { announcement: Announcement; compact?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">{announcement.title}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{announcement.body}</p>
|
||||
</div>
|
||||
|
||||
{announcement.action_label && announcement.action_url && (
|
||||
<Button asChild variant="outline" size="sm" className={compact ? 'w-full bg-transparent' : 'shrink-0 bg-transparent'}>
|
||||
{/* Always a new tab: the destination is outside this
|
||||
installation, and taking somebody out of the app
|
||||
they are working in is not what this should do. */}
|
||||
<a href={announcement.action_url} target="_blank" rel="noopener noreferrer">
|
||||
{announcement.action_label}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The dashboard band. */
|
||||
export function AnnouncementBand({ announcement }: { announcement: Announcement }) {
|
||||
const tone = TONES[announcement.tone] ?? TONES.info;
|
||||
|
||||
return (
|
||||
<div className={`mb-6 flex flex-col gap-3 rounded-lg border border-l-4 p-4 sm:flex-row sm:items-center sm:justify-between ${tone}`}>
|
||||
<AnnouncementBody announcement={announcement} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The band, reading the shared prop itself.
|
||||
*
|
||||
* Self-reading so a theme adds it in one line without threading a prop
|
||||
* through a page that has no other reason to know about it — the same
|
||||
* shape as AnnouncementIcon below. Every portal theme renders this, so a
|
||||
* message reaches a client wherever they are looking rather than only in
|
||||
* the theme somebody remembered to wire.
|
||||
*/
|
||||
export function ViewerAnnouncement() {
|
||||
const { announcement } = usePage<SharedData>().props;
|
||||
|
||||
if (!announcement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AnnouncementBand announcement={announcement} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header icon, beside the notification bell.
|
||||
*
|
||||
* Same shape as UpdateAvailableIcon next to it: absent entirely when
|
||||
* there is nothing to say, rather than a dead control. The dot marks it
|
||||
* without a count — there is only ever one of these, and "1" on a badge
|
||||
* would invite somebody to look for the second.
|
||||
*/
|
||||
export function AnnouncementIcon() {
|
||||
const { t } = useTranslation();
|
||||
const { announcement } = usePage<SharedData>().props;
|
||||
|
||||
if (!announcement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dot = DOT_TONES[announcement.tone] ?? DOT_TONES.info;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative" aria-label={announcement.title || t('Announcement')}>
|
||||
<Megaphone className="size-5" />
|
||||
<span className={`absolute top-0.5 right-0.5 size-2.5 rounded-full ${dot}`} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="flex w-80 flex-col gap-3 p-4">
|
||||
<AnnouncementBody announcement={announcement} compact />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnnouncementIcon } from '@/components/announcement';
|
||||
import { AppearanceSwitcher } from '@/components/appearance-switcher';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { IconLocaleSwitcher } from '@/components/locale-switcher';
|
||||
@@ -15,6 +16,7 @@ export function AppSidebarHeader({ breadcrumbs = [] }: { breadcrumbs?: Breadcrum
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<UpdateAvailableIcon />
|
||||
<AnnouncementIcon />
|
||||
<NotificationBell />
|
||||
<AppearanceSwitcher />
|
||||
<IconLocaleSwitcher />
|
||||
|
||||
@@ -32,7 +32,7 @@ import AppLogo from './app-logo';
|
||||
|
||||
export function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { auth, capabilities, pending, version } = usePage<SharedData>().props;
|
||||
const { auth, capabilities, extra_nav_links, pending, version } = usePage<SharedData>().props;
|
||||
|
||||
// Modules (Files, Clients, Groups…) add their own groups here as
|
||||
// they land, mirroring v1's grouped admin menu.
|
||||
@@ -262,6 +262,23 @@ export function AppSidebar() {
|
||||
});
|
||||
}
|
||||
|
||||
// Contributed by whatever is installed — see ResolvingNavigationLinks.
|
||||
// Their own group at the end rather than mixed into Administration:
|
||||
// these leave the installation, and a link that takes somebody out of
|
||||
// the app should not sit between two that do not. Empty on every
|
||||
// installation with nothing listening, and the group disappears with
|
||||
// it rather than rendering a heading over nothing.
|
||||
if (extra_nav_links.length > 0) {
|
||||
groups.push({
|
||||
title: t('More'),
|
||||
items: extra_nav_links.map((link) => ({
|
||||
title: link.title,
|
||||
url: link.url,
|
||||
external: link.external,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" variant="inset">
|
||||
<SidebarHeader>
|
||||
|
||||
@@ -7,6 +7,13 @@ export interface FileDelivery {
|
||||
method: DeliveryMethod;
|
||||
/** True when nobody set PROJECTSEND_FILE_DELIVERY and the server was detected. */
|
||||
detected: boolean;
|
||||
/**
|
||||
* Whether the detection had anything to work with. Always true in a
|
||||
* request; false only when something asks from a console, where
|
||||
* SERVER_SOFTWARE does not exist and `method` is a default rather
|
||||
* than a finding.
|
||||
*/
|
||||
observed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/sidebar';
|
||||
import { type NavGroup } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { ChevronRight, ExternalLink } from 'lucide-react';
|
||||
|
||||
export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
|
||||
const page = usePage();
|
||||
@@ -58,11 +58,31 @@ export function NavMain({ groups = [] }: { groups: NavGroup[] }) {
|
||||
</Collapsible>
|
||||
) : (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild isActive={isActive(item.url)} tooltip={item.title}>
|
||||
<Link href={item.url ?? '#'}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={item.external ? false : isActive(item.url)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
{/* An external destination is a plain
|
||||
anchor, never an Inertia <Link>:
|
||||
Link expects a page component back
|
||||
and another origin will not give it
|
||||
one, so it fails without saying so.
|
||||
It is also never "active" — nothing
|
||||
outside this app is the page you
|
||||
are on. */}
|
||||
{item.external ? (
|
||||
<a href={item.url ?? '#'} target="_blank" rel="noopener noreferrer">
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ExternalLink className="ml-auto size-3.5 opacity-60" />
|
||||
</a>
|
||||
) : (
|
||||
<Link href={item.url ?? '#'}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
{item.badge !== undefined && item.badge > 0 && (
|
||||
<SidebarMenuBadge className="bg-primary text-primary-foreground peer-hover/menu-button:text-primary-foreground peer-data-[active=true]/menu-button:text-primary-foreground rounded-full">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { useFormatDate } from '@/hooks/use-format-date';
|
||||
import { type FileRow } from '@/types/portal';
|
||||
|
||||
interface FileDownloadStatsProps {
|
||||
file: FileRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Did it arrive?" — how often a client's own file has gone out, and when
|
||||
* it last did.
|
||||
*
|
||||
* Renders nothing at all when `downloads` is null, which is every file
|
||||
* somebody shared *with* this client. That is a privacy rule, not a
|
||||
* tidiness one: a count on a file shared with several people tells each
|
||||
* of them about the others' activity. The server sends null rather than a
|
||||
* zero precisely so this cannot be shown by mistake — so gate on the
|
||||
* field being present and never on `is_mine`.
|
||||
*
|
||||
* Zero *is* rendered, as words. On a link-only account this is the only
|
||||
* evidence a customer has either way, and "nobody has taken it yet" is an
|
||||
* answer they came looking for.
|
||||
*/
|
||||
export function FileDownloadStats({ file }: FileDownloadStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { date } = useFormatDate();
|
||||
|
||||
if (file.downloads === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Whole sentences rather than assembled fragments, and a singular
|
||||
// spelled out rather than a pipe: `t()` does no plural selection —
|
||||
// the catalogues are flat key/value and a "one|many" string would
|
||||
// reach the screen with its pipe intact. A count above zero always
|
||||
// has a date, since the date is the newest of the rows counted.
|
||||
if (file.downloads.count === 0) {
|
||||
return <span>{t('Not downloaded yet')}</span>;
|
||||
}
|
||||
|
||||
const when = date(file.downloads.last_at);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{file.downloads.count === 1
|
||||
? t('Downloaded once, on :date', { date: when })
|
||||
: t('Downloaded :count times, last on :date', { count: file.downloads.count, date: when })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
import { Check, Link2, Pencil, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -24,12 +25,36 @@ interface FileRowActionsProps {
|
||||
* dialog and each theme owns its own; a file has eight fields behind five
|
||||
* separate permissions, so it gets a page (portal/edit-file.tsx) that every
|
||||
* theme shares rather than a form each theme would have to carry.
|
||||
*
|
||||
* The copy-link control follows the same rule as the other two: it appears
|
||||
* when the server put a `share_url` on the row and never otherwise. That is
|
||||
* not the same question as `is_mine` — a file shared *with* this client can
|
||||
* carry a link that is the sharer's to hand out, not the recipient's — so
|
||||
* this reads the field and derives nothing.
|
||||
*/
|
||||
export function FileRowActions({ file, size = 'sm' }: FileRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyLink = (url: string) => {
|
||||
void navigator.clipboard?.writeText(url);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.share_url !== null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={size}
|
||||
onClick={() => copyLink(file.share_url as string)}
|
||||
title={t('Copy the public link to this file')}
|
||||
>
|
||||
{copied ? <Check className="size-4" /> : <Link2 className="size-4" />}
|
||||
<span className="sr-only">{copied ? t('Copied') : t('Copy link')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{file.can_update && (
|
||||
<Button variant="ghost" size={size} asChild>
|
||||
<Link href={route('my-files.edit', file.id)}>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { AppearanceSwitcher } from '@/components/appearance-switcher';
|
||||
import { LocaleSwitcher } from '@/components/locale-switcher';
|
||||
import { PoweredBy } from '@/components/powered-by';
|
||||
import ProjectSendLogo from '@/components/projectsend-logo';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -11,14 +12,34 @@ interface AuthLayoutProps {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every screen somebody sees before they are signed in — the login form,
|
||||
* registration, the password-reset pair, the two-factor challenge, first-run
|
||||
* setup, and the page a share link opens.
|
||||
*
|
||||
* An installation that has uploaded a logo shows it here, for the same
|
||||
* reason it shows on the public listing and in the client portal: these are
|
||||
* the pages that belong to whoever runs the installation, seen by their
|
||||
* clients, and the product's own wordmark is a stand-in for a brand rather
|
||||
* than the brand. Requested in #1777. Unbranded installs are unchanged.
|
||||
*
|
||||
* The `branding` prop is null whenever the Branding capability is absent, so
|
||||
* nothing here needs its own gate — see BrandingServiceProvider.
|
||||
*/
|
||||
export default function AuthSimpleLayout({ children, title, description }: AuthLayoutProps) {
|
||||
const { name, branding } = usePage<SharedData>().props;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Link href={route('home')} className="flex flex-col items-center gap-2 font-medium">
|
||||
<ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
|
||||
{branding?.logo_url ? (
|
||||
<img src={branding.logo_url} alt={name} className="mb-1 h-12 w-auto object-contain" />
|
||||
) : (
|
||||
<ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
|
||||
)}
|
||||
<span className="sr-only">{title}</span>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import { FormEventHandler } from 'react';
|
||||
|
||||
@@ -13,6 +13,12 @@ import AuthLayout from '@/layouts/auth-layout';
|
||||
interface ResetPasswordProps {
|
||||
token: string;
|
||||
email: string;
|
||||
/**
|
||||
* Whether the server already knows this link will be refused. False
|
||||
* for an address it cannot place, which is not the same thing — see
|
||||
* NewPasswordController::linkIsSpent().
|
||||
*/
|
||||
expired: boolean;
|
||||
}
|
||||
|
||||
interface ResetPasswordForm {
|
||||
@@ -23,7 +29,7 @@ interface ResetPasswordForm {
|
||||
password_confirmation: string;
|
||||
}
|
||||
|
||||
export default function ResetPassword({ token, email }: ResetPasswordProps) {
|
||||
export default function ResetPassword({ token, email, expired }: ResetPasswordProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, setData, post, processing, errors, reset } = useForm<ResetPasswordForm>({
|
||||
@@ -40,6 +46,24 @@ export default function ResetPassword({ token, email }: ResetPasswordProps) {
|
||||
});
|
||||
};
|
||||
|
||||
// Said before the work rather than after it. Reset links last an hour
|
||||
// and people open them late; asking for a password twice and then
|
||||
// refusing it is a bad minute for somebody who is already worried.
|
||||
if (expired) {
|
||||
return (
|
||||
<AuthLayout
|
||||
title={t('This link has expired')}
|
||||
description={t('Reset links last one hour. Ask for a new one and it will arrive in a moment.')}
|
||||
>
|
||||
<Head title={t('This link has expired')} />
|
||||
|
||||
<Button className="w-full" asChild>
|
||||
<Link href={route('password.request')}>{t('Send me a new link')}</Link>
|
||||
</Button>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout title={t('Reset password')} description={t('Please enter your new password below')}>
|
||||
<Head title={t('Reset password')} />
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TopClientsWidget, type TopClient } from '@/components/dashboard-widgets
|
||||
import { TransfersRangeControls, TransfersWidget, type TransferPoint, type TransfersRange } from '@/components/dashboard-widgets/transfers-widget';
|
||||
import { WidgetBox } from '@/components/dashboard-widgets/widget-box';
|
||||
import { WidgetsDialog } from '@/components/dashboard-widgets/widgets-dialog';
|
||||
import { AnnouncementBand } from '@/components/announcement';
|
||||
import Heading from '@/components/heading';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -110,7 +111,7 @@ export default function Dashboard({
|
||||
dashboard_columns,
|
||||
}: DashboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { update_notice } = usePage<SharedData>().props;
|
||||
const { announcement, update_notice } = usePage<SharedData>().props;
|
||||
const [releaseDialogOpen, setReleaseDialogOpen] = useState(false);
|
||||
const [widgetsDialogOpen, setWidgetsDialogOpen] = useState(false);
|
||||
const [layout, setLayout] = useState<WidgetLayout>(widget_layout);
|
||||
@@ -336,6 +337,12 @@ export default function Dashboard({
|
||||
<Head title={t('Dashboard')} />
|
||||
|
||||
<div className="space-y-6 px-4 py-6">
|
||||
{/* Above the heading, not inside the grid: whoever asked
|
||||
for this wants it read, and the grid is arranged by
|
||||
each viewer. Read from shared props, the same source
|
||||
the header icon uses, so the two cannot disagree. */}
|
||||
{announcement && <AnnouncementBand announcement={announcement} />}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Heading title={t('Dashboard')} description={t('An overview of this installation')} />
|
||||
<Button variant="outline" size="sm" onClick={() => setWidgetsDialogOpen(true)}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Bell, Download, Files, FileText, FolderKanban, HardDrive } from 'lucide
|
||||
|
||||
import Heading from '@/components/heading';
|
||||
import { StatTile } from '@/components/stat-tile';
|
||||
import { ViewerAnnouncement } from '@/components/announcement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFormatDate } from '@/hooks/use-format-date';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
@@ -44,6 +45,13 @@ export default function PortalDashboard({ files_count, groups_count, storage, la
|
||||
<Head title={t('Dashboard')} />
|
||||
|
||||
<div className="space-y-6 px-4 py-6">
|
||||
{/* The same band the file portal shows, on the screen that
|
||||
is about the account rather than about the files. Reads
|
||||
the shared prop itself, so this and /my-files cannot
|
||||
disagree about what is being said or to whom — core
|
||||
drops anything not aimed at this viewer. */}
|
||||
<ViewerAnnouncement />
|
||||
|
||||
<Heading title={t('Hello, :name', { name: auth.user.name })} description={t('Here is what has been shared with you')} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
|
||||
@@ -9,9 +9,11 @@ import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { FileDownloadStats } from '@/components/portal/file-download-stats';
|
||||
import { FileRowActions } from '@/components/portal/file-row-actions';
|
||||
import { FolderRowActions } from '@/components/portal/folder-row-actions';
|
||||
import { NewFolderButton } from '@/components/portal/new-folder-button';
|
||||
import { ViewerAnnouncement } from '@/components/announcement';
|
||||
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
|
||||
import { PortalFilesToolbarCompact } from '@/components/portal/portal-files-toolbar-compact';
|
||||
import { RenameFolderDialog } from '@/components/portal/rename-folder-dialog';
|
||||
@@ -84,6 +86,14 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
<Head title={t('My files')} />
|
||||
|
||||
<div className="px-4 py-4">
|
||||
{/* Above everything the client came here to do, and outside
|
||||
the row below it: as a flex child it shared the line with
|
||||
the heading and the buttons, so the band was never full
|
||||
width and squeezed them into a column beside it. Reads the
|
||||
shared prop itself; core drops anything not aimed at this
|
||||
viewer, so a theme never decides who sees it. */}
|
||||
<ViewerAnnouncement />
|
||||
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<Heading title={folder?.name ?? t('My files')} description={t('The files shared with you')} />
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -222,6 +232,11 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
<VersionBadge version={file.version} variant="compact" />
|
||||
</div>
|
||||
{file.description && <p className="truncate text-[11px] text-neutral-400">{file.description}</p>}
|
||||
{file.downloads !== null && (
|
||||
<p className="truncate text-[11px] text-neutral-400">
|
||||
<FileDownloadStats file={file} />
|
||||
</p>
|
||||
)}
|
||||
<CategoryBadges categories={file.categories} size="xs" className="mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,11 @@ import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { FileDownloadStats } from '@/components/portal/file-download-stats';
|
||||
import { FileRowActions } from '@/components/portal/file-row-actions';
|
||||
import { FolderRowActions } from '@/components/portal/folder-row-actions';
|
||||
import { NewFolderButton } from '@/components/portal/new-folder-button';
|
||||
import { ViewerAnnouncement } from '@/components/announcement';
|
||||
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
|
||||
import { PortalFilesToolbar } from '@/components/portal/portal-files-toolbar';
|
||||
import { RenameFolderDialog } from '@/components/portal/rename-folder-dialog';
|
||||
@@ -93,6 +95,14 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
<Head title={t('My files')} />
|
||||
|
||||
<div className="px-4 py-6">
|
||||
{/* Above everything the client came here to do, and outside
|
||||
the row below it: as a flex child it shared the line with
|
||||
the heading and the buttons, so the band was never full
|
||||
width and squeezed them into a column beside it. Reads the
|
||||
shared prop itself; core drops anything not aimed at this
|
||||
viewer, so a theme never decides who sees it. */}
|
||||
<ViewerAnnouncement />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={folder?.name ?? t('My files')} description={t('The files shared with you')} />
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -206,6 +216,12 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{file.description ?? file.original_name} · {formatBytes(file.size)} · {date(file.created_at)}
|
||||
{file.downloads !== null && (
|
||||
<>
|
||||
{' · '}
|
||||
<FileDownloadStats file={file} />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<CategoryBadges categories={file.categories} className="mt-1" />
|
||||
</div>
|
||||
@@ -331,6 +347,16 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{formatBytes(file.size)} · {date(file.created_at)}
|
||||
</p>
|
||||
{/* Its own line in the grid, for the
|
||||
reason gallery gives: a card's
|
||||
metadata line truncates, and a
|
||||
sentence appended to it takes the
|
||||
size and date with it. */}
|
||||
{file.downloads !== null && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
<FileDownloadStats file={file} />
|
||||
</p>
|
||||
)}
|
||||
<CategoryBadges categories={file.categories} className="mt-1" />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
|
||||
@@ -9,9 +9,11 @@ import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { FileDownloadStats } from '@/components/portal/file-download-stats';
|
||||
import { FileRowActions } from '@/components/portal/file-row-actions';
|
||||
import { FolderRowActions } from '@/components/portal/folder-row-actions';
|
||||
import { NewFolderButton } from '@/components/portal/new-folder-button';
|
||||
import { ViewerAnnouncement } from '@/components/announcement';
|
||||
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
|
||||
import { PortalFilesToolbarDrive } from '@/components/portal/portal-files-toolbar-drive';
|
||||
import { RenameFolderDialog } from '@/components/portal/rename-folder-dialog';
|
||||
@@ -85,6 +87,14 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
<Head title={t('My files')} />
|
||||
|
||||
<div className="px-4 py-6">
|
||||
{/* Above everything the client came here to do, and outside
|
||||
the row below it: as a flex child it shared the line with
|
||||
the heading and the buttons, so the band was never full
|
||||
width and squeezed them into a column beside it. Reads the
|
||||
shared prop itself; core drops anything not aimed at this
|
||||
viewer, so a theme never decides who sees it. */}
|
||||
<ViewerAnnouncement />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={folder?.name ?? t('My files')} description={t('The files shared with you')} />
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -225,6 +235,12 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
</div>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
{file.description ?? file.original_name} · {date(file.created_at)}
|
||||
{file.downloads !== null && (
|
||||
<>
|
||||
{' · '}
|
||||
<FileDownloadStats file={file} />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<CategoryBadges categories={file.categories} className="mt-1" />
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,11 @@ import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { Pagination } from '@/components/pagination';
|
||||
import { FileDownloadStats } from '@/components/portal/file-download-stats';
|
||||
import { FileRowActions } from '@/components/portal/file-row-actions';
|
||||
import { FolderRowActions } from '@/components/portal/folder-row-actions';
|
||||
import { NewFolderButton } from '@/components/portal/new-folder-button';
|
||||
import { ViewerAnnouncement } from '@/components/announcement';
|
||||
import { PortalBreadcrumb } from '@/components/portal/portal-breadcrumb';
|
||||
import { PortalFilesToolbarGallery } from '@/components/portal/portal-files-toolbar-gallery';
|
||||
import { RenameFolderDialog } from '@/components/portal/rename-folder-dialog';
|
||||
@@ -86,6 +88,14 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
<Head title={t('My files')} />
|
||||
|
||||
<div>
|
||||
{/* Above everything the client came here to do, and outside
|
||||
the row below it: as a flex child it shared the line with
|
||||
the heading and the buttons, so the band was never full
|
||||
width and squeezed them into a column beside it. Reads the
|
||||
shared prop itself; core drops anything not aimed at this
|
||||
viewer, so a theme never decides who sees it. */}
|
||||
<ViewerAnnouncement />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<Heading title={folder?.name ?? t('My files')} description={t('The files shared with you')} />
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -217,8 +227,17 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Stacked, not side by side. The actions used
|
||||
to sit in a flex row beside the text, and a
|
||||
card in this grid is around 200px wide — so
|
||||
the icons took what they needed and every line
|
||||
of text truncated to two characters ("Q…",
|
||||
"75 …"), with the badges overlapping them.
|
||||
Giving the text the full width and putting the
|
||||
actions on their own row below fixes all of
|
||||
it. */}
|
||||
<div className="p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
{file.public && (
|
||||
@@ -231,9 +250,19 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{formatBytes(file.size)} · {date(file.created_at)}
|
||||
</p>
|
||||
{/* Its own line, not appended to the one
|
||||
above: a card is narrow and that line
|
||||
truncates, so a sentence on the end of
|
||||
it pushes the size and date out of
|
||||
sight. */}
|
||||
{file.downloads !== null && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
<FileDownloadStats file={file} />
|
||||
</p>
|
||||
)}
|
||||
<CategoryBadges categories={file.categories} className="mt-1" />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="mt-2 flex flex-wrap items-center">
|
||||
{comments_enabled && (
|
||||
<CommentsShellGallery
|
||||
fileId={file.id}
|
||||
|
||||
@@ -43,8 +43,15 @@ export default function Profile({
|
||||
email: auth.user.email,
|
||||
custom_field_values,
|
||||
timezone,
|
||||
current_password: '',
|
||||
});
|
||||
|
||||
// Changing this address is a credential change: it is where a password
|
||||
// reset is sent. So the field appears only when the address actually
|
||||
// differs from the stored one — the rest of the screen keeps saving
|
||||
// with nothing extra, which is what the server asks for too.
|
||||
const emailChanged = data.email.trim().toLowerCase() !== auth.user.email.trim().toLowerCase();
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -93,6 +100,28 @@ export default function Profile({
|
||||
<InputError className="mt-2" message={errors.email} />
|
||||
</div>
|
||||
|
||||
{emailChanged && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current_password">{t('Current password')}</Label>
|
||||
|
||||
<Input
|
||||
id="current_password"
|
||||
type="password"
|
||||
className="mt-1 block w-full"
|
||||
value={data.current_password}
|
||||
onChange={(e) => setData('current_password', e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('A password reset goes to this address, so changing it needs your password.')}
|
||||
</p>
|
||||
|
||||
<InputError className="mt-2" message={errors.current_password} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="timezone">{t('Timezone')}</Label>
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ interface SystemSettingsProps {
|
||||
viewer_timezone: string | null;
|
||||
can_manage_updates: boolean;
|
||||
check_for_updates: boolean | null;
|
||||
fetch_news: boolean | null;
|
||||
can_configure_news: boolean;
|
||||
/** When the release feed was last asked, by anybody. Null until it has been. */
|
||||
last_checked_at: string | null;
|
||||
/** The answer to a "check now" press, for the one render after it. */
|
||||
@@ -36,6 +38,8 @@ export default function SystemSettings({
|
||||
viewer_timezone,
|
||||
can_manage_updates,
|
||||
check_for_updates,
|
||||
fetch_news,
|
||||
can_configure_news,
|
||||
last_checked_at,
|
||||
check_result,
|
||||
}: SystemSettingsProps) {
|
||||
@@ -75,6 +79,7 @@ export default function SystemSettings({
|
||||
site_name: site_name,
|
||||
timezone: timezone,
|
||||
check_for_updates: check_for_updates ?? false,
|
||||
fetch_news: fetch_news ?? true,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
@@ -132,6 +137,30 @@ export default function SystemSettings({
|
||||
<InputError className="mt-2" message={errors.timezone} />
|
||||
</div>
|
||||
|
||||
{/* Its own capability, not can_manage_updates: that block
|
||||
disappears on a managed installation because nobody
|
||||
there can act on an update notice, while this one
|
||||
disappears because the news has to keep arriving
|
||||
whether or not that installation's administrator
|
||||
would have chosen it. */}
|
||||
{can_configure_news && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="fetch_news"
|
||||
checked={data.fetch_news}
|
||||
onCheckedChange={(checked) => setData('fetch_news', checked === true)}
|
||||
/>
|
||||
<Label htmlFor="fetch_news">{t('Show ProjectSend news on the dashboard')}</Label>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
'Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can_manage_updates && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -197,6 +197,11 @@ function ProviderCard({ provider, open, onToggle }: { provider: ProviderSettings
|
||||
'Your own tenant, not "common". Microsoft lets a user change the email address on their account, so a sign-in is only trustworthy when the token came from the tenant you named here.',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
'Also add the "xms_edov" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague\'s address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.',
|
||||
)}
|
||||
</p>
|
||||
<InputError message={form.errors.tenant_id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import AppLayout from '@/layouts/app-layout';
|
||||
interface StorageSettingsProps {
|
||||
active: boolean;
|
||||
provider: string;
|
||||
use_instance_role: boolean;
|
||||
access_key: string;
|
||||
has_secret: boolean;
|
||||
has_key_file: boolean;
|
||||
@@ -33,6 +34,7 @@ const FORM_ID = 'storage-settings-form';
|
||||
export default function StorageSettings({
|
||||
active,
|
||||
provider,
|
||||
use_instance_role,
|
||||
access_key,
|
||||
has_secret,
|
||||
has_key_file,
|
||||
@@ -54,6 +56,7 @@ export default function StorageSettings({
|
||||
const { data, setData, patch, processing, recentlySuccessful, errors } = useForm({
|
||||
active: active,
|
||||
provider: provider,
|
||||
use_instance_role: use_instance_role,
|
||||
access_key: access_key,
|
||||
secret: '',
|
||||
key_file: '',
|
||||
@@ -65,6 +68,7 @@ export default function StorageSettings({
|
||||
});
|
||||
|
||||
const isGcs = data.provider === 'gcs';
|
||||
const usesInstanceRole = !isGcs && data.use_instance_role;
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -83,6 +87,7 @@ export default function StorageSettings({
|
||||
route('system-settings.storage.test'),
|
||||
{
|
||||
provider: data.provider,
|
||||
use_instance_role: data.use_instance_role,
|
||||
access_key: data.access_key,
|
||||
secret: data.secret,
|
||||
key_file: data.key_file,
|
||||
@@ -158,24 +163,56 @@ export default function StorageSettings({
|
||||
<InputError message={errors.key_file} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-4">
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Label htmlFor="storage_access_key">{t('Access key')}</Label>
|
||||
<Input id="storage_access_key" value={data.access_key} onChange={(e) => setData('access_key', e.target.value)} />
|
||||
<InputError message={errors.access_key} />
|
||||
</div>
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Label htmlFor="storage_secret">{t('Secret key')}</Label>
|
||||
<Input
|
||||
id="storage_secret"
|
||||
type="password"
|
||||
placeholder={has_secret ? t('Unchanged') : ''}
|
||||
value={data.secret}
|
||||
onChange={(e) => setData('secret', e.target.value)}
|
||||
<>
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="use_instance_role"
|
||||
checked={data.use_instance_role}
|
||||
onCheckedChange={(checked) => setData('use_instance_role', checked === true)}
|
||||
/>
|
||||
<InputError message={errors.secret} />
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="use_instance_role" className="font-normal">
|
||||
{t("Authenticate as this server's IAM role")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
'For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.',
|
||||
)}
|
||||
</p>
|
||||
{usesInstanceRole && has_secret && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('Saving will delete the access key and secret currently stored here.')}
|
||||
</p>
|
||||
)}
|
||||
<InputError message={errors.use_instance_role} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!usesInstanceRole && (
|
||||
<div className="flex gap-4">
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Label htmlFor="storage_access_key">{t('Access key')}</Label>
|
||||
<Input
|
||||
id="storage_access_key"
|
||||
value={data.access_key}
|
||||
onChange={(e) => setData('access_key', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.access_key} />
|
||||
</div>
|
||||
<div className="grid flex-1 gap-2">
|
||||
<Label htmlFor="storage_secret">{t('Secret key')}</Label>
|
||||
<Input
|
||||
id="storage_secret"
|
||||
type="password"
|
||||
placeholder={has_secret ? t('Unchanged') : ''}
|
||||
value={data.secret}
|
||||
onChange={(e) => setData('secret', e.target.value)}
|
||||
/>
|
||||
<InputError message={errors.secret} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type Announcement } from '@/components/announcement';
|
||||
import { type InstallKind } from '@/components/update-instructions';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
@@ -23,6 +24,12 @@ export interface NavItem {
|
||||
isActive?: boolean;
|
||||
items?: NavItem[];
|
||||
badge?: number;
|
||||
/**
|
||||
* Leaves this installation. Rendered as a plain anchor opening in a
|
||||
* new tab rather than an Inertia <Link>, which would try to fetch a
|
||||
* page component from another origin and fail silently.
|
||||
*/
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
export type Edition = 'community' | 'cloud';
|
||||
@@ -32,6 +39,7 @@ export type Capability =
|
||||
| 'storage.configure'
|
||||
| 'email.transport.configure'
|
||||
| 'system.updates'
|
||||
| 'news.configure'
|
||||
| 'scheduler.monitoring'
|
||||
| 'custom_assets.manage'
|
||||
| 'branding.customize'
|
||||
@@ -39,6 +47,19 @@ export type Capability =
|
||||
| 'captcha.configure'
|
||||
| 'captcha.managed_keys';
|
||||
|
||||
/**
|
||||
* A sidebar entry contributed by a package — see
|
||||
* ResolvingNavigationLinks. Staff-only and already filtered server-side,
|
||||
* so the sidebar renders these without re-deciding who may see them.
|
||||
*/
|
||||
export interface ExtraNavLink {
|
||||
title: string;
|
||||
url: string;
|
||||
/** Opens in a new tab and shows that it leaves this installation. */
|
||||
external: boolean;
|
||||
icon: string | null;
|
||||
}
|
||||
|
||||
export interface SocialLoginProvider {
|
||||
provider: string;
|
||||
label: string;
|
||||
@@ -90,6 +111,14 @@ export interface SharedData {
|
||||
*/
|
||||
attribution: boolean;
|
||||
capabilities: Capability[];
|
||||
/** Sidebar entries contributed by packages, already staff-filtered. */
|
||||
extra_nav_links: ExtraNavLink[];
|
||||
/**
|
||||
* One message to put in front of staff, or null. Rendered as a band
|
||||
* on the dashboard and behind the header icon everywhere else — see
|
||||
* ResolvingAnnouncement. Shared so both say the same thing.
|
||||
*/
|
||||
announcement: Announcement | null;
|
||||
/** Identity providers that are switched on and fully configured. */
|
||||
social_login: SocialLoginProvider[];
|
||||
/** The CAPTCHA in force, or null when this installation has none. */
|
||||
|
||||
@@ -57,6 +57,23 @@ export interface FileRow {
|
||||
* renders it or not; it must never filter it.
|
||||
*/
|
||||
version: VersionLinks;
|
||||
/**
|
||||
* The public URL for a file of this client's own, where the install
|
||||
* made one. Null on a file somebody shared with them — that link is
|
||||
* the person who shared it's decision about who may reach the file,
|
||||
* and handing the recipient the URL would turn "you may download
|
||||
* this" into "you may pass this on to anyone". The server decides;
|
||||
* a theme must never derive this from `is_mine`.
|
||||
*/
|
||||
share_url: string | null;
|
||||
/**
|
||||
* How often this file has been downloaded and when it last was —
|
||||
* present only on files this client uploaded. Null means "not yours
|
||||
* to know", never "nobody has": a count on a file shared with several
|
||||
* clients would tell each of them about the others' activity, so the
|
||||
* server sends nothing rather than a zero.
|
||||
*/
|
||||
downloads: { count: number; last_at: string | null } | null;
|
||||
categories: CategoryTag[];
|
||||
/**
|
||||
* The download cap on this file, already decided for this client by
|
||||
|
||||
+5
-1
@@ -9,7 +9,11 @@ Artisan::command('inspire', function () {
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('projectsend:purge-erasures')->daily();
|
||||
Schedule::command('projectsend:purge-stale-uploads')->daily();
|
||||
// Hourly, not daily: this one frees disk that an account is holding
|
||||
// against its own upload limits, so the gap between a session going stale
|
||||
// and the sweep noticing is a gap where somebody cannot upload. Daily made
|
||||
// that gap up to two days wide.
|
||||
Schedule::command('projectsend:purge-stale-uploads')->hourly();
|
||||
Schedule::command('projectsend:purge-zip-downloads')->daily();
|
||||
Schedule::command('projectsend:check-for-updates')->daily();
|
||||
Schedule::command('projectsend:fetch-news')->daily();
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
/**
|
||||
* An expired reset link should say so before asking for the work, not
|
||||
* after it.
|
||||
*
|
||||
* The scaffolding renders the form without looking at the token, so
|
||||
* somebody opening a link an hour late typed a password, typed it again to
|
||||
* confirm, and was then told "this password reset token is invalid" — a
|
||||
* word nobody outside the code knows, at the end rather than the start.
|
||||
*
|
||||
* store() still validates and is still the rule. This is only the screen
|
||||
* being honest a minute earlier.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create(['email' => 'owner@example.com']);
|
||||
});
|
||||
|
||||
test('a live link still shows the form', function () {
|
||||
$token = Password::broker()->createToken($this->user);
|
||||
|
||||
$this->get("/reset-password/{$token}?email=owner@example.com")
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('auth/reset-password')
|
||||
->where('expired', false));
|
||||
});
|
||||
|
||||
test('a spent link says so instead of asking for a password', function () {
|
||||
$this->get('/reset-password/not-a-real-token?email=owner@example.com')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
|
||||
});
|
||||
|
||||
test('a link whose token has been used is spent', function () {
|
||||
$token = Password::broker()->createToken($this->user);
|
||||
Password::broker()->deleteToken($this->user);
|
||||
|
||||
$this->get("/reset-password/{$token}?email=owner@example.com")
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| It must not answer whether an account exists
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| /forgot-password deliberately says "a link will be sent if the account
|
||||
| exists". This screen must not undo that, and the first version of it did:
|
||||
| a real address answered "expired" and an unknown one drew the form, so
|
||||
| the difference between the two answers was the account.
|
||||
*/
|
||||
|
||||
test('an address nobody has gets the same answer as one that exists', function () {
|
||||
// The oracle, pinned. Not "both are false" or "both are true" — both
|
||||
// are *the same*, which is the property, and it survives somebody
|
||||
// later changing which answer that is.
|
||||
User::factory()->create(['email' => 'known@example.com']);
|
||||
|
||||
$expiredFor = function (string $email): bool {
|
||||
$seen = null;
|
||||
test()->get("/reset-password/not-a-real-token?email={$email}")->assertOk()->assertInertia(
|
||||
function (AssertableInertia $page) use (&$seen) {
|
||||
$seen = $page->toArray()['props']['expired'];
|
||||
},
|
||||
);
|
||||
|
||||
return (bool) $seen;
|
||||
};
|
||||
|
||||
expect($expiredFor('known@example.com'))->toBe($expiredFor('nobody@example.com'));
|
||||
});
|
||||
|
||||
test('the write refuses both the same way', function () {
|
||||
// The GET is not the only way to ask. Laravel answers a failed reset
|
||||
// with passwords.user for an address it cannot find and passwords.token
|
||||
// for a real one whose token is dead — two different sentences, which
|
||||
// is the same oracle through the POST. This one predates the screen
|
||||
// above; it is the scaffolding, shipped in every release.
|
||||
User::factory()->create(['email' => 'known@example.com']);
|
||||
|
||||
$refusalFor = function (string $email): string {
|
||||
return test()->from('/reset-password/not-a-real-token')
|
||||
->post('/reset-password', [
|
||||
'token' => 'not-a-real-token',
|
||||
'email' => $email,
|
||||
'password' => 'a-brand-new-password',
|
||||
'password_confirmation' => 'a-brand-new-password',
|
||||
])
|
||||
->assertSessionHasErrors('email')
|
||||
->getSession()->get('errors')->first('email');
|
||||
};
|
||||
|
||||
expect($refusalFor('known@example.com'))->toBe($refusalFor('nobody@example.com'));
|
||||
});
|
||||
|
||||
test('a missing address reads as expired rather than as a form', function () {
|
||||
// Same rule seen from the other side. The form needs an address to
|
||||
// post, so drawing it here would ask for a password it cannot use.
|
||||
$this->get('/reset-password/not-a-real-token')
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
|
||||
});
|
||||
|
||||
test('the real check still happens on the write', function () {
|
||||
// The screen is a courtesy; store() is the rule. A spent token is
|
||||
// refused there whatever the page decided to draw.
|
||||
$this->post('/reset-password', [
|
||||
'token' => 'not-a-real-token',
|
||||
'email' => 'owner@example.com',
|
||||
'password' => 'a-brand-new-password',
|
||||
'password_confirmation' => 'a-brand-new-password',
|
||||
])->assertSessionHasErrors('email');
|
||||
|
||||
expect(Hash::check('a-brand-new-password', $this->user->fresh()->password))->toBeFalse();
|
||||
});
|
||||
@@ -178,3 +178,42 @@ test('core has no route that can change it', function () {
|
||||
->contains(fn (RouteInstance $route): bool => str_contains($route->uri(), 'branding/attribution')))
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
// The sign-in screens, requested in #1777. One layout serves all of them —
|
||||
// login, registration, the reset pair, the two-factor challenge, first-run
|
||||
// setup and the page a share link opens — so what is asserted here is that
|
||||
// the prop reaches a page nobody has signed in to see, which is the part
|
||||
// the layout could not do for itself.
|
||||
test('a guest at the sign-in screen is given the branding logo', function () {
|
||||
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
|
||||
|
||||
$logoUrl = BrandingSetting::query()->sole()->logoUrl();
|
||||
|
||||
auth()->logout();
|
||||
|
||||
$this->get(route('login'))->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->component('auth/login')->where('branding.logo_url', $logoUrl),
|
||||
);
|
||||
});
|
||||
|
||||
test('the sign-in screen falls back to the product logo when nothing was uploaded', function () {
|
||||
auth()->logout();
|
||||
|
||||
$this->get(route('login'))->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->component('auth/login')->where('branding.logo_url', null),
|
||||
);
|
||||
});
|
||||
|
||||
test('a withheld capability takes the logo off the sign-in screen too', function () {
|
||||
// The screen that would remove it 404s without the capability, so a
|
||||
// login page still wearing somebody's logo would have no way back.
|
||||
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
|
||||
|
||||
config(['projectsend.capabilities_disabled' => 'branding.customize']);
|
||||
forgetRequestState();
|
||||
auth()->logout();
|
||||
|
||||
$this->get(route('login'))->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('branding.logo_url', null),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Clients\ClientAccounts;
|
||||
use App\Modules\Clients\Notifications\ClientWelcomeNotification;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Permissions\SystemRole;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| What a client account is
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Three surfaces make one now -- the staff screens, /api/v1/clients, and
|
||||
| the platform control plane in the private package, which reaches this
|
||||
| class by name because it cannot import a host class. That last one fakes
|
||||
| this class entirely in its own suite, so nothing on that side can show
|
||||
| that a client created through it is really a client. This file is where
|
||||
| that is shown.
|
||||
*/
|
||||
|
||||
function makeAccount(array $arguments = [])
|
||||
{
|
||||
return app(ClientAccounts::class)->create(...array_merge([
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'a-generated-passphrase',
|
||||
], $arguments));
|
||||
}
|
||||
|
||||
test('the account is a client, active, approved and verified', function () {
|
||||
$client = makeAccount();
|
||||
|
||||
expect($client->type)->toBe(UserType::Client)
|
||||
->and($client->active)->toBeTrue()
|
||||
// Created by somebody who already knows who this is: there is
|
||||
// nothing to approve and no address to confirm.
|
||||
->and($client->account_requested)->toBeFalse()
|
||||
->and($client->email_verified_at)->not->toBeNull()
|
||||
->and($client->role_id)->toBe(
|
||||
Role::query()->where('name', SystemRole::Client->value)->value('id')
|
||||
);
|
||||
});
|
||||
|
||||
test('the password is stored hashed, never as it arrived', function () {
|
||||
$client = makeAccount(['password' => 'a-generated-passphrase']);
|
||||
|
||||
expect($client->password)->not->toBe('a-generated-passphrase')
|
||||
->and(Hash::check('a-generated-passphrase', $client->password))->toBeTrue();
|
||||
});
|
||||
|
||||
test('creating an account is written to the activity log', function () {
|
||||
$client = makeAccount();
|
||||
|
||||
expect(ActivityLog::query()
|
||||
->where('action', Action::UserCreated->value)
|
||||
->where('subject_id', $client->id)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The quota
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('an omitted quota is stored as zero, which means inherit', function () {
|
||||
// 0 is not "no space" -- ClientStorageUsage::quotaMb() reads the site
|
||||
// default for it at enforcement time, which is what makes changing a
|
||||
// plan's allowance one setting rather than a sweep over every account.
|
||||
expect(makeAccount()->storage_quota_mb)->toBe(0);
|
||||
});
|
||||
|
||||
test('a quota given is the quota stored', function () {
|
||||
expect(makeAccount(['storageQuotaMb' => 500])->storage_quota_mb)->toBe(500);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The seat cap
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Enforced here rather than left to each caller: the platform sets this cap
|
||||
| and the platform is also what calls the control plane, so this is what
|
||||
| stops a leaked control token minting accounts without limit.
|
||||
*/
|
||||
|
||||
test('a full installation refuses to create another client', function () {
|
||||
config()->set('projectsend.platform.max_clients', 1);
|
||||
makeAccount();
|
||||
|
||||
expect(fn () => makeAccount(['email' => 'grace@example.com']))
|
||||
->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('the refusal happens before anything is written', function () {
|
||||
config()->set('projectsend.platform.max_clients', 1);
|
||||
makeAccount();
|
||||
|
||||
try {
|
||||
makeAccount(['email' => 'grace@example.com']);
|
||||
} catch (ValidationException) {
|
||||
// Expected.
|
||||
}
|
||||
|
||||
expect(User::query()->where('email', 'grace@example.com')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the refusal names the field the caller asked it to name', function () {
|
||||
config()->set('projectsend.platform.max_clients', 1);
|
||||
makeAccount();
|
||||
|
||||
try {
|
||||
makeAccount(['email' => 'grace@example.com', 'emailField' => 'contact_email']);
|
||||
$this->fail('Expected the seat guard to refuse.');
|
||||
} catch (ValidationException $e) {
|
||||
expect($e->errors())->toHaveKey('contact_email');
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The welcome
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('the welcome email is sent by default', function () {
|
||||
Notification::fake();
|
||||
app(Settings::class)->set(Setting::EmailNotificationsEnabled, true);
|
||||
|
||||
$client = makeAccount();
|
||||
|
||||
Notification::assertSentTo($client, ClientWelcomeNotification::class);
|
||||
});
|
||||
|
||||
test('a caller that sends its own welcome can turn this one off', function () {
|
||||
// Two mails about one account read as a mistake. The portal's is the
|
||||
// one that can explain what the customer signed up for.
|
||||
Notification::fake();
|
||||
app(Settings::class)->set(Setting::EmailNotificationsEnabled, true);
|
||||
|
||||
$client = makeAccount(['welcome' => false]);
|
||||
|
||||
Notification::assertNotSentTo($client, ClientWelcomeNotification::class);
|
||||
});
|
||||
|
||||
test('no welcome goes out when this installation sends no mail at all', function () {
|
||||
Notification::fake();
|
||||
app(Settings::class)->set(Setting::EmailNotificationsEnabled, false);
|
||||
|
||||
$client = makeAccount();
|
||||
|
||||
Notification::assertNotSentTo($client, ClientWelcomeNotification::class);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user