mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 13:33:22 +00:00
Compare commits
39 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 |
@@ -4,6 +4,11 @@ PROJECTSEND_EDITION=community
|
||||
# Emergency off switch for the CAPTCHA on public forms, for an operator who
|
||||
# has a shell but no working login. Everything else about the feature is
|
||||
# configured at /system/settings/captcha.
|
||||
#
|
||||
# Only "true" or "1" switches it off. Anything else -- including "no",
|
||||
# "off", and a misspelling -- leaves the CAPTCHA on, deliberately: a flag
|
||||
# that takes a protection away should not do so because a value was typed
|
||||
# wrong.
|
||||
# PROJECTSEND_CAPTCHA_DISABLED=true
|
||||
|
||||
# How downloads leave the server. Left unset (or "auto"), ProjectSend hands
|
||||
|
||||
+66
-4
@@ -6,13 +6,75 @@ Versions follow [SemVer](https://semver.org/): the middle number moves when ther
|
||||
the last one when there are only fixes, and the first one when an upgrade needs more from you than
|
||||
dropping in the new files and running the migrations.
|
||||
|
||||
Anything under **Upgrade notes** is something you have to do, not something we did.
|
||||
Anything under **⚠️ Important — do these yourself** is something you have to do, not something
|
||||
we did. It sits at the top of a release for that reason. Older entries call the same section
|
||||
**Upgrade notes**.
|
||||
|
||||
## Unreleased
|
||||
## 2.4.1 — 11 September 2026
|
||||
|
||||
This section collects changes as they land; the release process turns it into a numbered entry
|
||||
when a version is cut.
|
||||
### ⚠️ Important — do these yourself
|
||||
|
||||
Everything else in this release happens on its own. These do not: each one leaves something working
|
||||
differently from how you expect until you act on it. Nothing here stops the upgrade or the
|
||||
installation from starting.
|
||||
|
||||
- **If you set `PROJECTSEND_CAPTCHA_DISABLED`, check what you set it to.** Only `true` or `1`
|
||||
switches the CAPTCHA off now. Anything else — including `no`, `off`, `yes` and a misspelling —
|
||||
used to be read as "yes, disabled" and is now read as "leave it on". If you meant it off, write
|
||||
`true`.
|
||||
- **If a staff role uploads into public folders, give it "Upload to public folders".** That
|
||||
permission was not being asked of staff, and now is. Roles holding "Upload public files" are
|
||||
unaffected, and ordinary uploads need nothing new.
|
||||
- **If you use Microsoft sign-in, add the `xms_edov` optional claim to your app registration.** In
|
||||
the Entra portal: your app registration → Token configuration → Add optional claim → ID →
|
||||
`xms_edov`. Until you do, Microsoft sign-in keeps working and keeps creating new accounts, but it
|
||||
will no longer attach itself to an account that already exists.
|
||||
|
||||
**Added**
|
||||
|
||||
- **Your logo now appears on the sign-in screen.** Requested by
|
||||
[@Zodiac1978](https://github.com/Zodiac1978) in
|
||||
[#1777](https://github.com/projectsend/projectsend/issues/1777).
|
||||
- **An installation on AWS can authenticate as its own IAM role instead of storing an access key.**
|
||||
- **Clients can now see how often their own files were downloaded, and when.**
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **A lookalike domain can no longer hand somebody else's account to an OIDC sign-in.** Reported by
|
||||
[@choewonwoo1817](https://github.com/choewonwoo1817).
|
||||
- **Changing your own email address now asks for your password.** Reported by
|
||||
[@Noorkhalel](https://github.com/Noorkhalel).
|
||||
- **Microsoft sign-in now checks that the person owns the address they presented.** Reported by
|
||||
[@archnexus707](https://github.com/archnexus707).
|
||||
- **Two people filling in the first-run setup screen at the same moment can no longer both become
|
||||
administrators.** Reported by [@ry2811](https://github.com/ry2811).
|
||||
- **Uploading into a public folder now needs a permission that says so.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **Moving a file into a public folder now needs that same permission.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **A staff member limited to some clients can no longer see or change other people's groups.**
|
||||
Reported by [@Drescargot](https://github.com/Drescargot).
|
||||
- **Deleting a client can no longer hand their files to a client you do not manage.** Reported by
|
||||
[@skeletonsec](https://github.com/skeletonsec).
|
||||
- **Erasing a staff account no longer hands their files to a client.**
|
||||
- **An interrupted upload can no longer park unlimited bytes on the server.** Reported by
|
||||
[@ry2811](https://github.com/ry2811).
|
||||
- **The password reset screen no longer says whether an email address has an account here.**
|
||||
- **An expired password reset link now says so before asking for a new password.**
|
||||
- **A Docker upgrade no longer fails when external storage is already configured.**
|
||||
[#1770](https://github.com/projectsend/projectsend/issues/1770).
|
||||
- **A public gallery no longer renders the same thumbnail several times at once.**
|
||||
- **`PROJECTSEND_CAPTCHA_DISABLED` no longer reads a "no" as a "yes".**
|
||||
|
||||
### Issues closed since 2.4.0
|
||||
|
||||
The summary above is what changed. This is the paper trail, for anyone who wants to read the
|
||||
original report.
|
||||
|
||||
- [#1768](https://github.com/projectsend/projectsend/issues/1768) — Search in file not restricted in directory
|
||||
- [#1773](https://github.com/projectsend/projectsend/issues/1773) — Feature Request : Support AWS IAM roles / default credential provider chain for S3 storage
|
||||
- [#1774](https://github.com/projectsend/projectsend/issues/1774) — HTTP Error by upload on R2098
|
||||
- [#1778](https://github.com/projectsend/projectsend/issues/1778) — [Documentation] Error 500 on install
|
||||
|
||||
## 2.4.0 — 8 September 2026
|
||||
|
||||
|
||||
+57
-2
@@ -324,8 +324,9 @@ like your logo reachable from the web.
|
||||
|
||||
## Step 6 — Point your web server at it
|
||||
|
||||
A complete nginx server block. Change `server_name`, and change `/var/www/projectsend` to wherever
|
||||
you unpacked the files (there are **three** places, including one inside `/protected-files/`):
|
||||
A complete nginx server block below; [Apache is further down](#if-you-are-using-apache). Change
|
||||
`server_name`, and change `/var/www/projectsend` to wherever you unpacked the files (there are
|
||||
**three** places, including one inside `/protected-files/`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -374,6 +375,38 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### If you are using Apache
|
||||
|
||||
Two things matter, and both are easy to get wrong:
|
||||
|
||||
- **The document root is the `public/` directory**, not the directory you unpacked into. Everything
|
||||
above `public/` — your `.env`, your uploaded files, the application code — has to stay out of
|
||||
reach of any URL.
|
||||
- **`AllowOverride All`, and `mod_rewrite` enabled** (`sudo a2enmod rewrite`). ProjectSend ships a
|
||||
`public/.htaccess` that sends every address to the front controller. If Apache is told to ignore
|
||||
it, every page except the home page is a 404.
|
||||
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName files.example.com
|
||||
DocumentRoot /var/www/projectsend/public
|
||||
|
||||
<Directory /var/www/projectsend/public>
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/projectsend-error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/projectsend-access.log combined
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
Downloads work as they are: PHP sends the bytes. If that becomes a capacity problem, `mod_xsendfile`
|
||||
hands the job to Apache — see [How downloads are sent](#how-downloads-are-sent).
|
||||
|
||||
On shared hosting you usually cannot edit any of this, and `public/.htaccess` is all you have. If
|
||||
the site returns a 500 on every page, see [When something goes wrong](#when-something-goes-wrong).
|
||||
|
||||
Then check your PHP settings. Large uploads are sent in 20 MB pieces, so PHP never has to handle a
|
||||
whole 5 GB file at once — but the pieces still need room. In your `php.ini`:
|
||||
|
||||
@@ -581,6 +614,28 @@ names the exact command to run; do that, then reload.
|
||||
Look in `storage/logs/` — open the newest file, the real error is at the bottom. Nine times out of ten it is
|
||||
folder permissions (step 4) or a wrong database password (step 3).
|
||||
|
||||
**Every page is a 500, and `storage/logs/` is empty.**
|
||||
The empty log is the answer, not a dead end: nothing reached PHP, so ProjectSend had nothing to
|
||||
write. The error is your web server's, and it is in your web server's log — on Apache
|
||||
`/var/log/apache2/error.log`, or wherever your host puts it. On Apache two causes account for
|
||||
almost all of these, and both are about `public/.htaccess`:
|
||||
|
||||
- **`Options not allowed here`.** The file starts by turning off directory listings and content
|
||||
negotiation, and your `AllowOverride` does not permit that. Allow it (`AllowOverride All`), or
|
||||
delete the `Options` line — it is hardening, not a requirement.
|
||||
- **`Request exceeded the limit of 10 internal redirects`.** Apache cannot work out which directory
|
||||
the file is serving, so the rule that sends every address to `index.php` rewrites to a path that
|
||||
does not exist, and tries again. Uncomment the `RewriteBase` line in `public/.htaccess` and set it
|
||||
to the path ProjectSend is served from — `/` at the domain root, `/projectsend` in a subdirectory.
|
||||
Reported on IONOS by [@Zodiac1978](https://github.com/Zodiac1978) in
|
||||
[#1778](https://github.com/projectsend/projectsend/issues/1778).
|
||||
|
||||
**If you edit `public/.htaccess`, write down what you changed.** Updating replaces every file the
|
||||
release ships, that one included, so a change that made your site work will be gone after the next
|
||||
update and the 500 will come back. If the Apache configuration is yours to edit, put the directives
|
||||
in a `<Directory>` block in the vhost instead: they do the same job there, and no update can touch
|
||||
them. On shared hosting, where it is not yours, keep the note and re-apply it.
|
||||
|
||||
**"Please provide a valid cache path" or "failed to open stream".**
|
||||
`storage/` or `bootstrap/cache/` is not writable by the web server user. Step 4.
|
||||
|
||||
|
||||
@@ -30,9 +30,53 @@ class NewPasswordController extends Controller
|
||||
return Inertia::render('auth/reset-password', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
'expired' => $this->linkIsSpent(
|
||||
(string) $request->string('email'),
|
||||
(string) $request->route('token'),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this link is one store() is certain to refuse.
|
||||
*
|
||||
* The scaffolding renders the form without looking at the token, so an
|
||||
* expired link asked for a new password, asked for it a second time to
|
||||
* confirm, and only then answered "this password reset token is
|
||||
* invalid" — naming a word nobody outside the code knows, after the
|
||||
* work rather than before it. Reset links last an hour and people open
|
||||
* them late; that is ordinary, not an error to be scolded for.
|
||||
*
|
||||
* store() still validates and remains the rule. This is the screen
|
||||
* being honest a minute earlier.
|
||||
*
|
||||
* **Anything that will not validate reads as expired, whether or not
|
||||
* the address is one we know.** That is the whole of the rule and it
|
||||
* exists for one reason: a page answering "expired" for a real address
|
||||
* and drawing the form for an unknown one tells anybody who types a
|
||||
* guess whether an account is here — the exact property
|
||||
* /forgot-password protects by saying "a link will be sent if the
|
||||
* account exists".
|
||||
*
|
||||
* The first version of this method described that oracle in a comment
|
||||
* and then built it: unknown address returned false and drew the form,
|
||||
* known address returned true and said expired. Two branches, two
|
||||
* answers, and the difference *was* the account. Now both answer the
|
||||
* same, so the page reveals nothing and the message is still right in
|
||||
* every case somebody real will meet — a mistyped address gets "ask
|
||||
* for a new link", which is what they should do anyway.
|
||||
*/
|
||||
private function linkIsSpent(string $email, string $token): bool
|
||||
{
|
||||
$broker = Password::broker();
|
||||
$user = $email === '' ? null : $broker->getUser(['email' => $email]);
|
||||
|
||||
// One answer for "no such account", "wrong token" and "spent
|
||||
// token", because telling them apart is telling somebody which
|
||||
// addresses exist here.
|
||||
return $user === null || ! $broker->tokenExists($user, $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*
|
||||
@@ -113,8 +157,21 @@ class NewPasswordController extends Controller
|
||||
return to_route('login')->with('status', __($status));
|
||||
}
|
||||
|
||||
// One sentence for every way this can fail, and deliberately not
|
||||
// Laravel's own. The scaffolding answers `passwords.user` for an
|
||||
// address it cannot find and `passwords.token` for a real one whose
|
||||
// token is dead — two different sentences, which is the same
|
||||
// account-enumeration oracle the screen above was fixed for,
|
||||
// reachable through the write instead. `passwords.throttled` is the
|
||||
// third and the sharpest: the broker throttles per *user*, so an
|
||||
// address nobody holds can never be throttled, and being told to
|
||||
// wait is being told the account is there.
|
||||
//
|
||||
// Nothing is lost by collapsing them. The action is the same in
|
||||
// every case — ask for a new link — and /forgot-password already
|
||||
// refuses to say whether an address has an account.
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__($status)],
|
||||
'email' => [__('This password reset link is no longer valid. Ask for a new one and try again.')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,6 +350,11 @@ class HandleInertiaRequests extends Middleware
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dispatched for clients too, unlike the sidebar links beside it.
|
||||
// A client is somebody a shared instance may legitimately need to
|
||||
// address — about their own account, not about the installation —
|
||||
// and the event refuses anything not aimed at them, so widening
|
||||
// this does not widen what reaches them.
|
||||
$event = new ResolvingAnnouncement(isStaff: $user->isStaff());
|
||||
|
||||
Event::dispatch($event);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -118,6 +118,25 @@ class LocalPartStore
|
||||
return md5_file($path) ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* What one part number currently weighs on disk, 0 if it has never
|
||||
* arrived. Read before and after a part is received, so the session's
|
||||
* reservation can be settled against what is really there rather than
|
||||
* against what the request claimed it would send.
|
||||
*/
|
||||
public function partSize(UploadSession $session, int $partNumber): int
|
||||
{
|
||||
$path = $this->partPath($session, $partNumber);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
clearstatcache(true, $path);
|
||||
|
||||
return (int) (filesize($path) ?: 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{PartNumber: int, Size: int, ETag: string}>
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Uploads;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Events\FileWasStored;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
@@ -52,6 +54,13 @@ class StoreUploadedFile
|
||||
|
||||
$this->activity->log($action, $uploader, $file);
|
||||
|
||||
// Every upload path converges here — the chunked flow staff and
|
||||
// clients share, and the synchronous POST beside it — so a
|
||||
// listener sees each upload once without knowing which route
|
||||
// produced it. Dispatched after the row exists, so what it
|
||||
// receives is a complete File.
|
||||
Event::dispatch(new FileWasStored($file, $uploader));
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Modules\Files\Models\Folder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* A resumable chunked upload in progress. Parts live on disk under the
|
||||
@@ -20,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property int|null $previous_file_id
|
||||
* @property string $original_name
|
||||
* @property int $size
|
||||
* @property int $staged_bytes
|
||||
* @property string|null $mime_type
|
||||
* @property string|null $description
|
||||
* @property string $status
|
||||
@@ -53,4 +55,71 @@ class UploadSession extends Model
|
||||
{
|
||||
return $this->user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim room on the temporary volume for a part that is about to
|
||||
* arrive, returning false if the session has no room left.
|
||||
*
|
||||
* The claim is made before the bytes are read, and it is one
|
||||
* statement, because neither weaker version holds. Checking the part
|
||||
* directory and then writing leaves a gap that every other part
|
||||
* currently in flight fits through — and the protocol sends parts in
|
||||
* parallel, so the number of them is the caller's choice, not ours.
|
||||
* Charging the real size afterwards is the same gap by another name.
|
||||
*
|
||||
* $replacing is what the part number already holds, since re-sending a
|
||||
* part overwrites it rather than adding to it. That is an ordinary
|
||||
* resume, not an attack.
|
||||
*
|
||||
* The ceiling is the size the session declared. A client cannot stage
|
||||
* more than it said it was sending, which is the invariant the whole
|
||||
* fix rests on: store() has already measured that declaration against
|
||||
* the file-size limit and the storage quota, so bounding staged bytes
|
||||
* by it puts temporary bytes under the same limits as stored ones.
|
||||
*/
|
||||
public function reserveStaged(int $bytes, int $replacing = 0): bool
|
||||
{
|
||||
$delta = $bytes - $replacing;
|
||||
|
||||
$query = static::query()->whereKey($this->getKey());
|
||||
|
||||
// Both bounds are rearranged so that the column is never part of a
|
||||
// subtraction. `staged_bytes + :delta BETWEEN 0 AND size` reads
|
||||
// naturally and is wrong: staged_bytes is BIGINT UNSIGNED, and on
|
||||
// MySQL a negative delta makes that expression underflow and raise
|
||||
// SQLSTATE 22003 — in the comparison, before any row is chosen, so
|
||||
// the bound meant to prevent it is the thing that trips over it.
|
||||
// SQLite has no unsigned integers, so the suite cannot see this at
|
||||
// all; UploadSessionStagedBytesMysqlTest is what covers it.
|
||||
if ($delta >= 0) {
|
||||
if ($delta > $this->size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$query->where('staged_bytes', '<=', $this->size - $delta);
|
||||
} else {
|
||||
// Never give back more than is held.
|
||||
$query->where('staged_bytes', '>=', -$delta);
|
||||
}
|
||||
|
||||
return $query->update(['staged_bytes' => DB::raw(sprintf('staged_bytes + (%d)', $delta))]) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a reservation with what the part actually weighs.
|
||||
*
|
||||
* Always called, whatever happened to the part: a body shorter than
|
||||
* its Content-Length, a client that hung up mid-transfer, a part
|
||||
* refused for being too long and deleted. Whatever is on disk now is
|
||||
* the truth, and the difference goes back to the session — otherwise
|
||||
* a client's own retries would slowly exhaust their room.
|
||||
*/
|
||||
public function settleStaged(int $reserved, int $actual): void
|
||||
{
|
||||
if ($reserved === $actual) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reserveStaged($actual, $reserved);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,13 @@ class GroupsController extends Controller
|
||||
'visibility' => ['nullable', Rule::in(['public', 'private'])],
|
||||
]);
|
||||
|
||||
$query = Group::query()->withCount('members');
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// The API twin of the web listing's narrowing, and it has to be
|
||||
// here rather than only there: the same disclosure through a token
|
||||
// is the same disclosure (GHSA-r3hg-3fxw-rcmr).
|
||||
$query = $this->scope->groups($viewer)->withCount('members');
|
||||
|
||||
if (($filters['search'] ?? null) !== null) {
|
||||
$search = $filters['search'];
|
||||
|
||||
@@ -40,7 +40,14 @@ class GroupsController extends Controller
|
||||
'visibility' => $validated['visibility'] ?? null,
|
||||
];
|
||||
|
||||
$groups = Group::query()
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// Scoped, not Group::query(): a client-scoped staff member is told
|
||||
// about a group because one of their clients is in it. Without
|
||||
// this the listing showed every group on the installation, to a
|
||||
// viewer who could reach nothing of theirs (GHSA-r3hg-3fxw-rcmr).
|
||||
$groups = $this->scope->groups($viewer)
|
||||
->withCount('members')
|
||||
->when($filters['search'], fn (Builder $query, string $search) => $query->where(fn (Builder $q) => $q
|
||||
->where('name', 'like', "%{$search}%")
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -61,12 +62,8 @@ class AccountContentDeletion
|
||||
*/
|
||||
public function candidates(?User $viewer, ?int $excludeId = null): array
|
||||
{
|
||||
return User::query()
|
||||
return $this->reachableTargets($viewer)
|
||||
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
|
||||
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
|
||||
->where('type', UserType::Staff)
|
||||
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
|
||||
->where('active', true)
|
||||
->with('role')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
@@ -99,17 +96,62 @@ class AccountContentDeletion
|
||||
return [];
|
||||
}
|
||||
|
||||
$viewer = $request->user();
|
||||
|
||||
return $request->validate([
|
||||
'content_action' => ['required', Rule::in(['cascade_delete', 'reassign'])],
|
||||
'reassign_to_id' => [
|
||||
'required_if:content_action,reassign',
|
||||
'integer',
|
||||
Rule::exists('users', 'id')->where('active', true),
|
||||
Rule::notIn([$target->id]),
|
||||
// The same question the picker asks, asked again of what
|
||||
// came back from it. It used to be "exists, and is active",
|
||||
// which is not the boundary the picker documents two
|
||||
// methods up: a client-scoped staff member was shown their
|
||||
// own roster and could name anybody, so deleting a roster
|
||||
// client could hand that client's files and folders to a
|
||||
// client on somebody else's roster — who then reads, edits
|
||||
// and deletes them under the own-upload rules
|
||||
// (GHSA-w29w-pj29-x7ww).
|
||||
//
|
||||
// One predicate for both, rather than a matching pair: a
|
||||
// picker that promises a boundary the write does not keep
|
||||
// is exactly what this was.
|
||||
function (string $attribute, mixed $value, Closure $fail) use ($viewer): void {
|
||||
if (! $this->reachableTargets($viewer)->whereKey($value)->exists()) {
|
||||
// Deliberately the message an id that does not
|
||||
// exist at all would get. "Not yours" and "not
|
||||
// there" have to read the same, or refusing is how
|
||||
// a scoped staff member enumerates the accounts
|
||||
// outside their roster.
|
||||
$fail('validation.exists')->translate();
|
||||
}
|
||||
},
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every active account $viewer may hand content to: staff, who are
|
||||
* narrowed nowhere in the application, plus the clients
|
||||
* StaffLibraryScope shows them. An unscoped viewer gets everybody,
|
||||
* because clients() returns everybody for them.
|
||||
*
|
||||
* $viewer is null only where the question is about the installation
|
||||
* rather than about a screen — the erasure default in privacy
|
||||
* settings, which is stored once for everybody.
|
||||
*
|
||||
* @return Builder<User>
|
||||
*/
|
||||
private function reachableTargets(?User $viewer): Builder
|
||||
{
|
||||
return User::query()
|
||||
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
|
||||
->where('type', UserType::Staff)
|
||||
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
|
||||
->where('active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{content_action?: string, reassign_to_id?: int} $validated
|
||||
*/
|
||||
|
||||
@@ -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()) !== ''
|
||||
|
||||
@@ -42,17 +42,58 @@ class ResolvingAnnouncement
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Audience is declared by the listener and enforced here, rather than
|
||||
* each listener remembering to check `isStaff`.
|
||||
*
|
||||
* The first version of this refused clients outright, which was right
|
||||
* for the only message that existed — a hosted instance telling its
|
||||
* administrator about their plan. It stopped being right when a
|
||||
* message needed to reach the *clients* of a shared instance, and the
|
||||
* safe way to allow that is not to drop the guard: it is to make
|
||||
* every caller say who it is talking to, so a listener that forgets
|
||||
* reaches nobody rather than everybody.
|
||||
*/
|
||||
public const AUDIENCE_STAFF = 'staff';
|
||||
|
||||
public const AUDIENCE_CLIENTS = 'clients';
|
||||
|
||||
/**
|
||||
* `audience` is required and has no default. A message for staff and
|
||||
* a message for the people they share with are different messages,
|
||||
* and a signature that let one be mistaken for the other would put
|
||||
* the mistake in the quiet direction.
|
||||
*
|
||||
* `tone` picks the accent the band is drawn in. Two values, because
|
||||
* two is what the difference is worth: `info` for something worth
|
||||
* knowing, `warning` for something worth acting on. Anything else
|
||||
* falls back to `info` rather than rendering unstyled.
|
||||
*/
|
||||
public function show(string $title, string $body, ?string $actionLabel = null, ?string $actionUrl = null, string $tone = 'info'): void
|
||||
{
|
||||
public function show(
|
||||
string $title,
|
||||
string $body,
|
||||
string $audience,
|
||||
?string $actionLabel = null,
|
||||
?string $actionUrl = null,
|
||||
string $tone = 'info',
|
||||
): void {
|
||||
if ($this->announcement !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Silently ignored rather than thrown, and deliberately: a
|
||||
// listener aimed at the wrong audience should show nothing, not
|
||||
// break the page it was trying to decorate. An unrecognised value
|
||||
// reaches nobody for the same reason.
|
||||
$intended = match ($audience) {
|
||||
self::AUDIENCE_STAFF => $this->isStaff,
|
||||
self::AUDIENCE_CLIENTS => ! $this->isStaff,
|
||||
default => false,
|
||||
};
|
||||
|
||||
if (! $intended) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->announcement = [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\RequiredIf;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
@@ -43,6 +44,7 @@ class ExternalStorageSettingsController extends Controller
|
||||
return Inertia::render('system/settings/storage', [
|
||||
'active' => $settings->active,
|
||||
'provider' => $settings->provider->value,
|
||||
'use_instance_role' => $settings->use_instance_role,
|
||||
// Never name a top-level Inertia prop "key" — Inertia's React
|
||||
// renderer spreads page props onto the component via
|
||||
// `{ key: <internal-remount-key>, ...props }`, and a prop
|
||||
@@ -78,10 +80,18 @@ class ExternalStorageSettingsController extends Controller
|
||||
'bucket' => ['required', 'string', 'max:255'],
|
||||
'root' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
// 'sometimes' for the same reason as 'provider' above: absent
|
||||
// means false, which is what every payload written before this
|
||||
// choice existed meant.
|
||||
'use_instance_role' => ['sometimes', 'boolean'],
|
||||
|
||||
// Required only for the provider that uses them, so switching
|
||||
// to GCS does not demand an AWS region that means nothing.
|
||||
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'access_key' => [self::requiredForStaticS3($request), 'nullable', 'string', 'max:255'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
// Still required when the machine's own role is doing the
|
||||
// authenticating: the credential chain resolves credentials,
|
||||
// not which region the bucket is in.
|
||||
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'endpoint' => ['nullable', 'string', 'max:255'],
|
||||
'use_path_style' => ['required', 'boolean'],
|
||||
@@ -94,10 +104,13 @@ class ExternalStorageSettingsController extends Controller
|
||||
|
||||
$settings = ExternalStorageSettings::current();
|
||||
|
||||
$useInstanceRole = (bool) ($validated['use_instance_role'] ?? false);
|
||||
|
||||
$settings->fill([
|
||||
'active' => $validated['active'],
|
||||
'provider' => $validated['provider'],
|
||||
'key' => $validated['access_key'] ?? null,
|
||||
'use_instance_role' => $useInstanceRole,
|
||||
'key' => $useInstanceRole ? null : ($validated['access_key'] ?? null),
|
||||
'bucket' => $validated['bucket'],
|
||||
'region' => $validated['region'] ?? null,
|
||||
'endpoint' => $validated['endpoint'] ?? null,
|
||||
@@ -108,7 +121,16 @@ class ExternalStorageSettingsController extends Controller
|
||||
// A blank credential keeps whatever is already stored — neither
|
||||
// field is ever round-tripped to the browser (only the has_*
|
||||
// flags are), so blank means "unchanged", not "cleared".
|
||||
if (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
|
||||
//
|
||||
// Except when the machine's own role takes over, which is the one
|
||||
// thing that does clear it. The whole point of the setting is that
|
||||
// no long-lived AWS credential is kept here, and a secret left
|
||||
// sitting in the row unused would still be in the next database
|
||||
// dump — and would silently come back the moment the box is
|
||||
// unticked.
|
||||
if ($useInstanceRole) {
|
||||
$settings->secret = null;
|
||||
} elseif (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
|
||||
$settings->secret = $validated['secret'];
|
||||
}
|
||||
|
||||
@@ -144,7 +166,8 @@ class ExternalStorageSettingsController extends Controller
|
||||
$validated = $request->validate([
|
||||
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
|
||||
'bucket' => ['required', 'string', 'max:255'],
|
||||
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'use_instance_role' => ['sometimes', 'boolean'],
|
||||
'access_key' => [self::requiredForStaticS3($request), 'nullable', 'string', 'max:255'],
|
||||
'secret' => ['nullable', 'string', 'max:255'],
|
||||
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
|
||||
'endpoint' => ['nullable', 'string', 'max:255'],
|
||||
@@ -174,13 +197,21 @@ class ExternalStorageSettingsController extends Controller
|
||||
$config = [
|
||||
'version' => 'latest',
|
||||
'region' => $validated['region'],
|
||||
'credentials' => [
|
||||
'key' => $validated['access_key'],
|
||||
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
|
||||
],
|
||||
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
|
||||
];
|
||||
|
||||
// Omitted entirely, not left blank: an S3Client handed a
|
||||
// 'credentials' array is told to use it, so an empty one fails
|
||||
// instead of falling through to the default credential provider
|
||||
// chain. This is what makes the button test the same thing the
|
||||
// uploads will do — see ExternalStorageConfigApplier::applyS3().
|
||||
if (! ($validated['use_instance_role'] ?? false)) {
|
||||
$config['credentials'] = [
|
||||
'key' => $validated['access_key'],
|
||||
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
|
||||
];
|
||||
}
|
||||
|
||||
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
|
||||
$config['endpoint'] = $validated['endpoint'];
|
||||
}
|
||||
@@ -210,6 +241,22 @@ class ExternalStorageSettingsController extends Controller
|
||||
iterator_to_array($bucket->objects(['maxResults' => 1]), false);
|
||||
}
|
||||
|
||||
/**
|
||||
* An access key is only demanded of an S3 backend that is actually
|
||||
* going to authenticate with one. Shared by both the save and the
|
||||
* connection test so the two cannot disagree about what is required.
|
||||
*/
|
||||
private static function requiredForStaticS3(Request $request): RequiredIf
|
||||
{
|
||||
// Rule::requiredIf(), not a closure rule: this has to fire when
|
||||
// the field is missing from the payload altogether, and a closure
|
||||
// rule is not implicit — it never runs on an absent attribute.
|
||||
return Rule::requiredIf(
|
||||
fn (): bool => $request->input('provider') === StorageProvider::S3->value
|
||||
&& ! $request->boolean('use_instance_role')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential field left blank means "keep what is stored" on save,
|
||||
* so the connection test has to read it the same way — otherwise
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Modules\Platform\Installation\Console;
|
||||
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||
use App\Modules\Identity\UserType;
|
||||
@@ -149,6 +150,28 @@ use Throwable;
|
||||
* quota nobody is watching. The installation looks completely healthy
|
||||
* while it happens, to its operator and to its administrator alike.
|
||||
*
|
||||
* ### `settings` holds the three an outsider has to act on, and no more
|
||||
*
|
||||
* Not a dump of the settings table. Everything here leaves the container,
|
||||
* so each field needs a reason somebody outside would act on it, and
|
||||
* these three have one: they are the settings whose wrong value is
|
||||
* invisible from outside and expensive.
|
||||
*
|
||||
* `two_factor_enforcement` is what the platform sold compared against
|
||||
* what the installation applied. The other two are one question in two
|
||||
* halves -- **who can make an account here, and what that account is
|
||||
* allowed** -- and the answer matters most where it is least visible. On
|
||||
* a shared installation every client is a separate customer, so
|
||||
* self-registration switched on means accounts appearing that nobody
|
||||
* provisioned, and a default quota of zero means those accounts have no
|
||||
* ceiling at all. Both defaults are the permissive ones (see Setting),
|
||||
* which is right for a self-hosted install setting itself up and wrong
|
||||
* for an installation somebody else is operating.
|
||||
*
|
||||
* Neither is a secret: both are on the client settings screen any
|
||||
* administrator can open. What the document adds is that a watcher can
|
||||
* see them without one.
|
||||
*
|
||||
* ### A version is a decision, a commit is a fact
|
||||
*
|
||||
* `build` says which commit this installation was built from. A version
|
||||
@@ -206,8 +229,12 @@ class StatusCommand extends Command
|
||||
|
||||
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
|
||||
|
||||
public function handle(CapabilityRegistry $capabilities, SeatAllowance $seats, Settings $settings): int
|
||||
{
|
||||
public function handle(
|
||||
CapabilityRegistry $capabilities,
|
||||
SeatAllowance $seats,
|
||||
Settings $settings,
|
||||
ClientStorageUsage $quotas,
|
||||
): int {
|
||||
$status = [
|
||||
'version' => (string) config('projectsend.version'),
|
||||
'edition' => $capabilities->edition()->value,
|
||||
@@ -252,6 +279,25 @@ class StatusCommand extends Command
|
||||
// the middleware enforces would be worse than reporting
|
||||
// none at all.
|
||||
'two_factor_enforcement' => $this->enforcement($settings),
|
||||
// Whether strangers can make themselves an account here.
|
||||
// Read the way RegistrationController reads it, `=== true`
|
||||
// included: `get()` casts a boolean with `(bool)`, so the
|
||||
// only value that is neither true nor false is null, and
|
||||
// null is what the gate treats as closed.
|
||||
'clients_can_register' => $settings->get(Setting::ClientsCanRegister) === true,
|
||||
// What a client with no quota of their own is allowed, in
|
||||
// megabytes. **Zero means unlimited**, which is the whole
|
||||
// reason this is worth reporting: it is the default, it is
|
||||
// the answer for every account created without one asked
|
||||
// for, and nothing else in this document reveals it.
|
||||
//
|
||||
// The *effective* number, through the same method the
|
||||
// upload check resolves it with, rather than the setting
|
||||
// read on its own. A platform can put a floor under it from
|
||||
// the environment, and a document that reported the setting
|
||||
// while the uploads obeyed the floor would say the ceiling
|
||||
// was missing on an installation that has one.
|
||||
'default_client_storage_quota_mb' => $quotas->defaultQuotaMb(),
|
||||
],
|
||||
// Cast so an installation with no packages emits {} rather
|
||||
// than [] -- an empty PHP array encodes as a list, and a
|
||||
|
||||
@@ -50,7 +50,9 @@ class ExternalStorageConfigApplier
|
||||
// account's private key included — in the same database whose dump
|
||||
// the `encrypted` cast exists to survive. Both are now read straight
|
||||
// from the row, by the provider branch that uses them.
|
||||
private const CACHE_KEY = 'platform.external_storage_settings.v3';
|
||||
// v4: use_instance_role joined the shape. Not a credential — it is
|
||||
// the fact that there isn't one — so it caches like the rest.
|
||||
private const CACHE_KEY = 'platform.external_storage_settings.v4';
|
||||
|
||||
public function __construct(
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
@@ -93,8 +95,16 @@ class ExternalStorageConfigApplier
|
||||
*/
|
||||
private function applyS3(array $resolved): void
|
||||
{
|
||||
Config::set('filesystems.disks.files_external.key', $resolved['key']);
|
||||
Config::set('filesystems.disks.files_external.secret', $this->credential('secret'));
|
||||
// Left null on purpose when the machine's own role is doing the
|
||||
// authenticating. Laravel's FilesystemManager::formatS3Config()
|
||||
// only builds a `credentials` entry when both a key and a secret
|
||||
// are non-empty, and the AWS SDK falls back to its default
|
||||
// credential provider chain — ECS task role, EC2 instance
|
||||
// profile, EKS/IRSA, environment — whenever none is supplied.
|
||||
// Passing an empty string instead of nothing would be a
|
||||
// credential, and would fail rather than fall through.
|
||||
Config::set('filesystems.disks.files_external.key', $resolved['use_instance_role'] ? null : $resolved['key']);
|
||||
Config::set('filesystems.disks.files_external.secret', $resolved['use_instance_role'] ? null : $this->credential('secret'));
|
||||
Config::set('filesystems.disks.files_external.region', $resolved['region']);
|
||||
Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']);
|
||||
Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']);
|
||||
@@ -172,13 +182,14 @@ class ExternalStorageConfigApplier
|
||||
* filled in and active, nothing more. Callers AND the capability check
|
||||
* live and uncached — see class docblock.
|
||||
*
|
||||
* @return array{configured: bool, provider: string, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
||||
* @return array{configured: bool, provider: string, use_instance_role: bool, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
||||
*/
|
||||
private function resolve(): array
|
||||
{
|
||||
$blank = [
|
||||
'configured' => false,
|
||||
'provider' => StorageProvider::S3->value,
|
||||
'use_instance_role' => false,
|
||||
'key' => null,
|
||||
'region' => null, 'bucket' => null,
|
||||
'endpoint' => null, 'use_path_style' => false, 'root' => null,
|
||||
@@ -202,6 +213,7 @@ class ExternalStorageConfigApplier
|
||||
return [
|
||||
'configured' => true,
|
||||
'provider' => $settings->provider->value,
|
||||
'use_instance_role' => $settings->use_instance_role,
|
||||
'key' => $settings->key,
|
||||
'region' => $settings->region,
|
||||
'bucket' => $settings->bucket,
|
||||
|
||||
@@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* @property int $id
|
||||
* @property bool $active
|
||||
* @property StorageProvider $provider
|
||||
* @property bool $use_instance_role
|
||||
* @property string|null $key
|
||||
* @property string|null $secret
|
||||
* @property string|null $key_file
|
||||
@@ -33,6 +34,7 @@ class ExternalStorageSettings extends Model
|
||||
protected $fillable = [
|
||||
'active',
|
||||
'provider',
|
||||
'use_instance_role',
|
||||
'key',
|
||||
'secret',
|
||||
'key_file',
|
||||
@@ -55,6 +57,7 @@ class ExternalStorageSettings extends Model
|
||||
protected $attributes = [
|
||||
'active' => false,
|
||||
'provider' => 's3',
|
||||
'use_instance_role' => false,
|
||||
'use_path_style' => false,
|
||||
];
|
||||
|
||||
@@ -63,6 +66,7 @@ class ExternalStorageSettings extends Model
|
||||
return [
|
||||
'active' => 'boolean',
|
||||
'provider' => StorageProvider::class,
|
||||
'use_instance_role' => 'boolean',
|
||||
'secret' => 'encrypted',
|
||||
'key_file' => 'encrypted',
|
||||
'use_path_style' => 'boolean',
|
||||
@@ -71,7 +75,38 @@ class ExternalStorageSettings extends Model
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
return static::query()->firstOrNew([]);
|
||||
$settings = static::query()->firstOrNew([]);
|
||||
|
||||
// Column defaults — the $attributes array above — apply to a NEW
|
||||
// model, never to one hydrated from a row. So a row written by an
|
||||
// older release, before one of these columns existed, reads that
|
||||
// column as null however sensible its default is.
|
||||
//
|
||||
// That matters here more than it would anywhere else, because
|
||||
// PlatformServiceProvider::boot() reads these settings on every
|
||||
// process boot — and boot happens BEFORE `artisan migrate` runs.
|
||||
// For the length of an upgrade the code is new and the schema is
|
||||
// still old, and every artisan command in that window, including
|
||||
// the one that would run the migrations, boots through here.
|
||||
//
|
||||
// A null `provider` made the match in isConfigured() throw
|
||||
// UnhandledMatchError, which the official image's readiness probe
|
||||
// reported to the operator as "database unreachable" — on a
|
||||
// perfectly reachable database, in a container that then
|
||||
// restart-looped without ever reaching the migration that would
|
||||
// have fixed it (#1770, upgrading from 2.0/2.1 with external
|
||||
// storage configured).
|
||||
//
|
||||
// Applying the defaults to a hydrated row closes that window for
|
||||
// every column that has one, rather than for the single column
|
||||
// where it was found. Inert on any install whose schema is current.
|
||||
foreach ((new self)->getAttributes() as $column => $default) {
|
||||
if (! array_key_exists($column, $settings->getAttributes())) {
|
||||
$settings->setAttribute($column, $default);
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,8 +123,19 @@ class ExternalStorageSettings extends Model
|
||||
// What counts as "filled in" is per provider, because the two
|
||||
// authenticate with different things entirely: S3 wants a key and
|
||||
// a secret, GCS wants a service account key file.
|
||||
//
|
||||
// The match is deliberately left total rather than given a default
|
||||
// arm: current() guarantees a provider even on a row older than the
|
||||
// column, and a default arm here would quietly swallow a genuinely
|
||||
// unhandled case instead of naming it.
|
||||
//
|
||||
// Unless S3 is being asked to authenticate as the machine it is
|
||||
// running on, in which case there is no credential to fill in at
|
||||
// all and demanding one would leave the disk permanently
|
||||
// "unconfigured" — which fails silently, by leaving every new
|
||||
// upload on the local disk rather than by reporting anything.
|
||||
return match ($this->provider) {
|
||||
StorageProvider::S3 => $this->filled('key') && $this->filled('secret'),
|
||||
StorageProvider::S3 => $this->use_instance_role || ($this->filled('key') && $this->filled('secret')),
|
||||
StorageProvider::Gcs => $this->filled('key_file'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.4.0',
|
||||
// How long a request waits for another one that is already rendering
|
||||
// the same thumbnail or preview, before giving up rather than
|
||||
// decoding the same image a second time. See ThumbnailGenerator.
|
||||
// A slow disk or a large source wants longer than the default 15.
|
||||
'rendition_lock_wait_seconds' => env('PROJECTSEND_RENDITION_LOCK_WAIT_SECONDS'),
|
||||
|
||||
'version' => '2.4.1',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
+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..."
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Mostra les novetats de ProjectSend al tauler",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera els anuncis del projecte des de projectsend.org un cop al dia per a la targeta del tauler. Si ho desactives, aquesta instal·lació deixa de contactar amb projectsend.org per a novetats.",
|
||||
"Announcement": "Avís",
|
||||
"More": "Més"
|
||||
"More": "Més",
|
||||
"Copy the public link to this file": "Copia l'enllaç públic d'aquest fitxer",
|
||||
"Downloaded :count times, last on :date": "Descarregat :count vegades, l'última el :date",
|
||||
"Downloaded once, on :date": "Descarregat una vegada, el :date",
|
||||
"Not downloaded yet": "Encara no s'ha descarregat",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Afegeix també la reclamació opcional \"xms_edov\" al registre de l'aplicació, a Configuració de testimoni. Indicar el tenant diu quin directori avala l'inici de sessió; aquesta reclamació diu que el directori ha comprovat que la persona és realment propietària de l'adreça. Sense ella, algú altre dins del teu tenant podria iniciar sessió amb l'adreça d'un company, de manera que ProjectSend crearà comptes nous però mai no vincularà un inici de sessió de Microsoft a un compte que ja existeix.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "El restabliment de contrasenya s'envia a aquesta adreça, així que canviar-la requereix la teva contrasenya.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "La teva adreça de correu prové del directori o del proveïdor d'identitat amb què inicies sessió, i no es pot canviar aquí.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Els enllaços de restabliment duren una hora. Demana'n un de nou i arribarà de seguida.",
|
||||
"Send me a new link": "Envia'm un enllaç nou",
|
||||
"This link has expired": "Aquest enllaç ha caducat",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Aquest enllaç de restabliment ja no és vàlid. Demana'n un de nou i torna-ho a provar.",
|
||||
"Authenticate as this server's IAM role": "Autentica't amb el rol d'IAM d'aquest servidor",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Per a instal·lacions a AWS que ja tinguin un rol assignat: un rol de tasca d'ECS, un perfil d'instància d'EC2, EKS/IRSA. ProjectSend demana credencials temporals al SDK d'AWS en lloc de desar una clau d'accés. Deixa-ho desactivat per a MinIO, Backblaze, Wasabi i qualsevol altre servei que necessiti una clau i un secret.",
|
||||
"Saving will delete the access key and secret currently stored here.": "En desar s'esborraran la clau d'accés i la clau secreta que hi ha desades aquí.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ja hi ha massa pujades en curs. Acaba'n o cancel·la'n una i torna-ho a provar."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Zobrazovat novinky ProjectSendu v přehledu",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Jednou denně načte oznámení projektu z projectsend.org pro kartu v přehledu. Když to vypnete, tato instalace se kvůli novinkám na projectsend.org už vůbec nepřipojí.",
|
||||
"Announcement": "Oznámení",
|
||||
"More": "Další"
|
||||
"More": "Další",
|
||||
"Copy the public link to this file": "Zkopírovat veřejný odkaz na tento soubor",
|
||||
"Downloaded :count times, last on :date": "Stažení: :count — naposledy :date",
|
||||
"Downloaded once, on :date": "Staženo jednou, :date",
|
||||
"Not downloaded yet": "Zatím nestaženo",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Přidejte také volitelný nárok \"xms_edov\" do registrace aplikace v části Konfigurace tokenu. Uvedení tenanta říká, který adresář se za přihlášení zaručil; tento nárok říká, že adresář ověřil, že adresa opravdu patří dané osobě. Bez něj by se někdo jiný ve vašem tenantovi mohl přihlásit adresou kolegy, takže ProjectSend bude vytvářet nové účty, ale nikdy nepřipojí přihlášení přes Microsoft k účtu, který už existuje.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Obnovení hesla se posílá na tuto adresu, takže její změna vyžaduje vaše heslo.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Vaše e-mailová adresa pochází z adresáře nebo poskytovatele identity, přes kterého se přihlašujete, a nelze ji zde změnit.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Odkazy pro obnovení platí jednu hodinu. Požádejte o nový a za chvíli dorazí.",
|
||||
"Send me a new link": "Pošlete mi nový odkaz",
|
||||
"This link has expired": "Platnost tohoto odkazu vypršela",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Tento odkaz pro obnovení už neplatí. Požádejte o nový a zkuste to znovu.",
|
||||
"Authenticate as this server's IAM role": "Ověřovat se rolí IAM tohoto serveru",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Pro instalace běžící na AWS, ke kterým je již přiřazena role – role úlohy ECS, profil instance EC2, EKS/IRSA. ProjectSend si vyžádá dočasné přihlašovací údaje od AWS SDK místo toho, aby ukládal přístupový klíč. Pro MinIO, Backblaze, Wasabi a cokoli dalšího, co vyžaduje klíč a tajný klíč, nechte vypnuté.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Uložením se smaže přístupový klíč i tajný klíč, které jsou zde nyní uložené.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Už probíhá příliš mnoho nahrávání. Dokončete nebo zrušte jedno z nich a zkuste to znovu."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend-Neuigkeiten in der Übersicht anzeigen",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Ruft einmal täglich Projektankündigungen von projectsend.org für die Karte in der Übersicht ab. Ausgeschaltet nimmt diese Installation für Neuigkeiten überhaupt keine Verbindung zu projectsend.org mehr auf.",
|
||||
"Announcement": "Ankündigung",
|
||||
"More": "Mehr"
|
||||
"More": "Mehr",
|
||||
"Copy the public link to this file": "Öffentlichen Link zu dieser Datei kopieren",
|
||||
"Downloaded :count times, last on :date": ":count Mal heruntergeladen, zuletzt am :date",
|
||||
"Downloaded once, on :date": "Einmal heruntergeladen, am :date",
|
||||
"Not downloaded yet": "Noch nicht heruntergeladen",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Fügen Sie außerdem den optionalen Anspruch \"xms_edov\" unter Tokenkonfiguration zu Ihrer App-Registrierung hinzu. Die Angabe des Mandanten sagt, welches Verzeichnis für die Anmeldung bürgt; dieser Anspruch sagt, dass das Verzeichnis geprüft hat, dass die Person die Adresse wirklich besitzt. Ohne ihn könnte sich jemand anderes in Ihrem Mandanten mit der Adresse einer Kollegin anmelden, daher legt ProjectSend zwar neue Konten an, verknüpft eine Microsoft-Anmeldung aber nie mit einem bereits bestehenden Konto.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Eine Kennwortzurücksetzung geht an diese Adresse, daher ist für eine Änderung Ihr Kennwort erforderlich.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Ihre E-Mail-Adresse stammt aus dem Verzeichnis oder Identitätsanbieter, mit dem Sie sich anmelden, und kann hier nicht geändert werden.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Zurücksetzungslinks gelten eine Stunde. Fordern Sie einen neuen an, er trifft gleich ein.",
|
||||
"Send me a new link": "Neuen Link senden",
|
||||
"This link has expired": "Dieser Link ist abgelaufen",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Dieser Link zum Zurücksetzen ist nicht mehr gültig. Fordern Sie einen neuen an und versuchen Sie es erneut.",
|
||||
"Authenticate as this server's IAM role": "Mit der IAM-Rolle dieses Servers authentifizieren",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Für Installationen auf AWS, denen bereits eine Rolle zugewiesen ist – eine ECS-Task-Rolle, ein EC2-Instanzprofil, EKS/IRSA. ProjectSend fordert temporäre Anmeldedaten beim AWS SDK an, statt einen Zugriffsschlüssel zu speichern. Lassen Sie dies für MinIO, Backblaze, Wasabi und alles andere, was Schlüssel und geheimen Schlüssel benötigt, ausgeschaltet.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Beim Speichern werden der hier hinterlegte Zugriffsschlüssel und der geheime Schlüssel gelöscht.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Es laufen bereits zu viele Uploads. Beenden oder brechen Sie einen ab und versuchen Sie es erneut."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Mostrar las noticias de ProjectSend en el panel de control",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Trae los anuncios del proyecto desde projectsend.org una vez al día para la tarjeta del panel. Si lo desactivas, esta instalación deja de contactar a projectsend.org por noticias.",
|
||||
"Announcement": "Aviso",
|
||||
"More": "Más"
|
||||
"More": "Más",
|
||||
"Copy the public link to this file": "Copiar el enlace público a este archivo",
|
||||
"Downloaded :count times, last on :date": "Descargado :count veces, la última el :date",
|
||||
"Downloaded once, on :date": "Descargado una vez, el :date",
|
||||
"Not downloaded yet": "Todavía no se descargó",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Añade también la notificación opcional \"xms_edov\" al registro de tu aplicación, en Configuración de token. Indicar el tenant dice qué directorio avaló el inicio de sesión; esa notificación dice que el directorio comprobó que la persona es realmente dueña de la dirección. Sin ella, alguien más dentro de tu tenant podría entrar con la dirección de un compañero, así que ProjectSend creará cuentas nuevas pero nunca enlazará un inicio de sesión de Microsoft con una cuenta que ya existe.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "El restablecimiento de contraseña se envía a esta dirección, así que cambiarla necesita tu contraseña.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Tu dirección de correo viene del directorio o proveedor de identidad con el que inicias sesión, y no se puede cambiar aquí.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Los enlaces de restablecimiento duran una hora. Pedí uno nuevo y llegará en un momento.",
|
||||
"Send me a new link": "Enviame un enlace nuevo",
|
||||
"This link has expired": "Este enlace ha caducado",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Este enlace de restablecimiento ya no es válido. Pedí uno nuevo y volvé a intentarlo.",
|
||||
"Authenticate as this server's IAM role": "Autenticarse con el rol de IAM de este servidor",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Para instalaciones en AWS que ya tengan un rol asignado: un rol de tarea de ECS, un perfil de instancia de EC2, EKS/IRSA. ProjectSend le pide credenciales temporales al SDK de AWS en lugar de guardar una clave de acceso. Déjalo desactivado para MinIO, Backblaze, Wasabi y cualquier otro servicio que necesite una clave y una clave secreta.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Al guardar se borrarán la clave de acceso y la clave secreta que hay guardadas aquí.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ya hay demasiadas subidas en curso. Termina o cancela una e inténtalo de nuevo."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Afficher les actualités ProjectSend sur le tableau de bord",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Récupère une fois par jour les annonces du projet depuis projectsend.org pour la carte du tableau de bord. Désactivé, cette installation ne contacte plus du tout projectsend.org pour les actualités.",
|
||||
"Announcement": "Annonce",
|
||||
"More": "Plus"
|
||||
"More": "Plus",
|
||||
"Copy the public link to this file": "Copier le lien public vers ce fichier",
|
||||
"Downloaded :count times, last on :date": "Téléchargé :count fois, la dernière le :date",
|
||||
"Downloaded once, on :date": "Téléchargé une fois, le :date",
|
||||
"Not downloaded yet": "Pas encore téléchargé",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Ajoutez également la revendication facultative « xms_edov » à votre inscription d'application, sous Configuration des jetons. Indiquer le locataire dit quel annuaire s'est porté garant de la connexion ; cette revendication dit que l'annuaire a vérifié que la personne possède réellement l'adresse. Sans elle, quelqu'un d'autre dans votre locataire pourrait se connecter avec l'adresse d'un collègue, aussi ProjectSend créera de nouveaux comptes mais ne rattachera jamais une connexion Microsoft à un compte existant.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Une réinitialisation de mot de passe part vers cette adresse, la modifier demande donc votre mot de passe.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Votre adresse e-mail provient de l'annuaire ou du fournisseur d'identité avec lequel vous vous connectez, et ne peut pas être modifiée ici.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Les liens de réinitialisation durent une heure. Demandez-en un nouveau, il arrivera dans un instant.",
|
||||
"Send me a new link": "Envoyez-moi un nouveau lien",
|
||||
"This link has expired": "Ce lien a expiré",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Ce lien de réinitialisation n'est plus valide. Demandez-en un nouveau et réessayez.",
|
||||
"Authenticate as this server's IAM role": "S'authentifier avec le rôle IAM de ce serveur",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Pour les installations sur AWS disposant déjà d'un rôle — un rôle de tâche ECS, un profil d'instance EC2, EKS/IRSA. ProjectSend demande des identifiants temporaires au SDK AWS au lieu de stocker une clé d'accès. Laissez cette option désactivée pour MinIO, Backblaze, Wasabi et tout autre service nécessitant une clé et une clé secrète.",
|
||||
"Saving will delete the access key and secret currently stored here.": "L'enregistrement supprimera la clé d'accès et la clé secrète actuellement stockées ici.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Trop de téléversements sont déjà en cours. Terminez-en un ou annulez-en un, puis réessayez."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Tampilkan kabar terbaru ProjectSend di dasbor",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Mengambil pengumuman proyek dari projectsend.org sekali sehari untuk kartu di dasbor. Jika dimatikan, instalasi ini sama sekali tidak lagi menghubungi projectsend.org untuk kabar terbaru.",
|
||||
"Announcement": "Pengumuman",
|
||||
"More": "Lainnya"
|
||||
"More": "Lainnya",
|
||||
"Copy the public link to this file": "Salin tautan publik ke berkas ini",
|
||||
"Downloaded :count times, last on :date": "Diunduh :count kali, terakhir pada :date",
|
||||
"Downloaded once, on :date": "Diunduh sekali, pada :date",
|
||||
"Not downloaded yet": "Belum pernah diunduh",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Tambahkan juga klaim opsional \"xms_edov\" ke pendaftaran aplikasi Anda, di bagian Token configuration. Menyebutkan tenant memberi tahu direktori mana yang menjamin proses masuk; klaim itu menyatakan bahwa direktori telah memeriksa bahwa orang tersebut benar-benar memiliki alamat itu. Tanpanya, orang lain di dalam tenant Anda dapat masuk dengan alamat rekan kerja, sehingga ProjectSend akan membuat akun baru tetapi tidak pernah menautkan proses masuk Microsoft ke akun yang sudah ada.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Pengaturan ulang kata sandi dikirim ke alamat ini, jadi mengubahnya memerlukan kata sandi Anda.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Alamat email Anda berasal dari direktori atau penyedia identitas tempat Anda masuk, dan tidak dapat diubah di sini.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Tautan atur ulang berlaku satu jam. Minta yang baru dan akan tiba sebentar lagi.",
|
||||
"Send me a new link": "Kirimi saya tautan baru",
|
||||
"This link has expired": "Tautan ini sudah kedaluwarsa",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Tautan atur ulang ini sudah tidak berlaku. Minta yang baru dan coba lagi.",
|
||||
"Authenticate as this server's IAM role": "Autentikasi sebagai peran IAM server ini",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Untuk instalasi yang berjalan di AWS dan sudah memiliki peran terpasang — peran tugas ECS, profil instance EC2, EKS/IRSA. ProjectSend meminta kredensial sementara kepada AWS SDK alih-alih menyimpan kunci akses. Biarkan nonaktif untuk MinIO, Backblaze, Wasabi, dan layanan lain yang memerlukan kunci akses dan kunci rahasia.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Menyimpan akan menghapus kunci akses dan kunci rahasia yang tersimpan di sini.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Sudah terlalu banyak unggahan yang sedang berjalan. Selesaikan atau batalkan salah satunya, lalu coba lagi."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Mostra le novità di ProjectSend nel pannello",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Recupera gli annunci del progetto da projectsend.org una volta al giorno per la scheda del pannello. Disattivandolo, questa installazione smette del tutto di contattare projectsend.org per le novità.",
|
||||
"Announcement": "Avviso",
|
||||
"More": "Altro"
|
||||
"More": "Altro",
|
||||
"Copy the public link to this file": "Copia il link pubblico a questo file",
|
||||
"Downloaded :count times, last on :date": "Scaricato :count volte, l'ultima il :date",
|
||||
"Downloaded once, on :date": "Scaricato una volta, il :date",
|
||||
"Not downloaded yet": "Non ancora scaricato",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Aggiungi anche l'attestazione facoltativa \"xms_edov\" alla registrazione dell'app, in Configurazione token. Indicare il tenant dice quale directory ha garantito per l'accesso; quell'attestazione dice che la directory ha verificato che la persona possieda davvero l'indirizzo. Senza di essa, qualcun altro nel tuo tenant potrebbe accedere con l'indirizzo di un collega, quindi ProjectSend creerà nuovi account ma non collegherà mai un accesso Microsoft a un account già esistente.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "La reimpostazione della password viene inviata a questo indirizzo, quindi cambiarlo richiede la tua password.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Il tuo indirizzo email proviene dalla directory o dal provider di identità con cui accedi e non può essere modificato qui.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "I link di reimpostazione durano un'ora. Chiedine uno nuovo e arriverà tra un momento.",
|
||||
"Send me a new link": "Inviami un nuovo link",
|
||||
"This link has expired": "Questo link è scaduto",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Questo link di reimpostazione non è più valido. Chiedine uno nuovo e riprova.",
|
||||
"Authenticate as this server's IAM role": "Autenticati con il ruolo IAM di questo server",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Per installazioni su AWS a cui è già associato un ruolo — un ruolo attività ECS, un profilo istanza EC2, EKS/IRSA. ProjectSend chiede credenziali temporanee all'SDK di AWS invece di memorizzare una chiave di accesso. Lascialo disattivato per MinIO, Backblaze, Wasabi e qualsiasi altro servizio che richieda una chiave di accesso e una chiave segreta.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Salvando verranno eliminate la chiave di accesso e la chiave segreta memorizzate qui.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Ci sono già troppi caricamenti in corso. Completane o annullane uno e riprova."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "ダッシュボードに ProjectSend のお知らせを表示する",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "ダッシュボードのカード用に、プロジェクトのお知らせを projectsend.org から1日1回取得します。オフにすると、このインストールはお知らせのために projectsend.org へ接続しなくなります。",
|
||||
"Announcement": "お知らせ",
|
||||
"More": "その他"
|
||||
"More": "その他",
|
||||
"Copy the public link to this file": "このファイルの公開リンクをコピー",
|
||||
"Downloaded :count times, last on :date": ":count 回ダウンロードされました。最終 :date",
|
||||
"Downloaded once, on :date": "1 回ダウンロードされました。:date",
|
||||
"Not downloaded yet": "まだダウンロードされていません",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "アプリ登録の「トークン構成」で、オプションの要求「xms_edov」も追加してください。テナントの指定は、どのディレクトリがサインインを保証したかを示します。この要求は、その人が本当にそのアドレスの持ち主であることをディレクトリが確認したことを示します。これがないと、テナント内の別の人が同僚のアドレスでサインインできてしまうため、ProjectSend は新しいアカウントは作成しますが、既存のアカウントに Microsoft サインインを結び付けることはありません。",
|
||||
"A password reset goes to this address, so changing it needs your password.": "パスワードの再設定はこのアドレスに届くため、変更にはパスワードが必要です。",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "メールアドレスはサインインに使用しているディレクトリまたは ID プロバイダーのもので、ここでは変更できません。",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "再設定リンクの有効期間は 1 時間です。新しいリンクを申し込めば、すぐに届きます。",
|
||||
"Send me a new link": "新しいリンクを送る",
|
||||
"This link has expired": "このリンクは有効期限が切れています",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "この再設定リンクは無効になりました。新しいリンクを申し込んでもう一度お試しください。",
|
||||
"Authenticate as this server's IAM role": "このサーバーの IAM ロールとして認証する",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "すでにロールが割り当てられている AWS 上のインストール向けです(ECS タスクロール、EC2 インスタンスプロファイル、EKS/IRSA)。ProjectSend はアクセスキーを保存する代わりに、AWS SDK から一時的な認証情報を取得します。MinIO、Backblaze、Wasabi など、アクセスキーとシークレットキーが必要なサービスではオフのままにしてください。",
|
||||
"Saving will delete the access key and secret currently stored here.": "保存すると、ここに保存されているアクセスキーとシークレットキーは削除されます。",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "すでに進行中のアップロードが多すぎます。どれか一つを完了するか取り消してから、もう一度お試しください。"
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend-nieuws op het overzicht tonen",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Haalt eens per dag projectaankondigingen op van projectsend.org voor de kaart op het overzicht. Uitgeschakeld neemt deze installatie voor nieuws helemaal geen contact meer op met projectsend.org.",
|
||||
"Announcement": "Mededeling",
|
||||
"More": "Meer"
|
||||
"More": "Meer",
|
||||
"Copy the public link to this file": "Kopieer de openbare link naar dit bestand",
|
||||
"Downloaded :count times, last on :date": ":count keer gedownload, laatst op :date",
|
||||
"Downloaded once, on :date": "Eén keer gedownload, op :date",
|
||||
"Not downloaded yet": "Nog niet gedownload",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Voeg ook de optionele claim \"xms_edov\" toe aan je app-registratie, onder Tokenconfiguratie. De tenant noemen zegt welke directory voor de aanmelding instaat; die claim zegt dat de directory heeft gecontroleerd dat de persoon het adres echt bezit. Zonder die claim kan iemand anders binnen je tenant zich aanmelden met het adres van een collega, dus ProjectSend maakt wel nieuwe accounts aan maar koppelt een Microsoft-aanmelding nooit aan een account dat al bestaat.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Een wachtwoordherstel gaat naar dit adres, dus het wijzigen ervan vraagt om je wachtwoord.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Je e-mailadres komt van de directory of identiteitsprovider waarmee je je aanmeldt en kan hier niet worden gewijzigd.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Herstellinks zijn een uur geldig. Vraag een nieuwe aan, die komt zo binnen.",
|
||||
"Send me a new link": "Stuur me een nieuwe link",
|
||||
"This link has expired": "Deze link is verlopen",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Deze herstellink is niet meer geldig. Vraag een nieuwe aan en probeer het opnieuw.",
|
||||
"Authenticate as this server's IAM role": "Verifiëren met de IAM-rol van deze server",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Voor installaties op AWS waaraan al een rol is gekoppeld — een ECS-taakrol, een EC2-instantieprofiel, EKS/IRSA. ProjectSend vraagt tijdelijke inloggegevens op bij de AWS SDK in plaats van een toegangssleutel op te slaan. Laat dit uit staan voor MinIO, Backblaze, Wasabi en al het andere dat een toegangssleutel en geheime sleutel nodig heeft.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Bij het opslaan worden de hier opgeslagen toegangssleutel en geheime sleutel verwijderd.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Er lopen al te veel uploads. Rond er een af of annuleer er een en probeer het opnieuw."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Pokazuj aktualności ProjectSend na pulpicie",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Raz dziennie pobiera ogłoszenia projektu z projectsend.org na kartę pulpitu. Po wyłączeniu ta instalacja w ogóle przestaje łączyć się z projectsend.org po aktualności.",
|
||||
"Announcement": "Ogłoszenie",
|
||||
"More": "Więcej"
|
||||
"More": "Więcej",
|
||||
"Copy the public link to this file": "Skopiuj publiczny link do tego pliku",
|
||||
"Downloaded :count times, last on :date": "Pobrania: :count — ostatnie :date",
|
||||
"Downloaded once, on :date": "Pobrano raz, :date",
|
||||
"Not downloaded yet": "Jeszcze nie pobrano",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Dodaj też opcjonalne oświadczenie \"xms_edov\" do rejestracji aplikacji, w sekcji Konfiguracja tokenu. Wskazanie dzierżawy mówi, który katalog poręczył za logowanie; to oświadczenie mówi, że katalog sprawdził, iż dana osoba naprawdę jest właścicielem adresu. Bez niego ktoś inny w Twojej dzierżawie mógłby zalogować się adresem współpracownika, więc ProjectSend będzie tworzyć nowe konta, ale nigdy nie powiąże logowania Microsoft z kontem, które już istnieje.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Resetowanie hasła trafia na ten adres, więc jego zmiana wymaga Twojego hasła.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Twój adres e-mail pochodzi z katalogu lub dostawcy tożsamości, przez którego się logujesz, i nie można go tu zmienić.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Linki do resetowania są ważne godzinę. Poproś o nowy, dotrze za chwilę.",
|
||||
"Send me a new link": "Wyślij mi nowy link",
|
||||
"This link has expired": "Ten link wygasł",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Ten link do resetowania nie jest już ważny. Poproś o nowy i spróbuj ponownie.",
|
||||
"Authenticate as this server's IAM role": "Uwierzytelniaj się rolą IAM tego serwera",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Dla instalacji działających na AWS, do których przypisano już rolę — rola zadania ECS, profil instancji EC2, EKS/IRSA. ProjectSend prosi AWS SDK o tymczasowe poświadczenia zamiast przechowywać klucz dostępu. Pozostaw wyłączone dla MinIO, Backblaze, Wasabi i wszystkiego innego, co wymaga klucza dostępu i klucza tajnego.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Zapisanie usunie klucz dostępu i klucz tajny obecnie tu przechowywane.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Trwa już zbyt wiele przesyłań. Zakończ lub anuluj jedno z nich i spróbuj ponownie."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Mostrar novidades do ProjectSend no painel",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Busca os anúncios do projeto em projectsend.org uma vez por dia para o cartão do painel. Se você desativar, esta instalação para de contatar o projectsend.org por novidades.",
|
||||
"Announcement": "Aviso",
|
||||
"More": "Mais"
|
||||
"More": "Mais",
|
||||
"Copy the public link to this file": "Copiar o link público para este arquivo",
|
||||
"Downloaded :count times, last on :date": "Baixado :count vezes, a última em :date",
|
||||
"Downloaded once, on :date": "Baixado uma vez, em :date",
|
||||
"Not downloaded yet": "Ainda não foi baixado",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Adicione também a declaração opcional \"xms_edov\" ao registro do aplicativo, em Configuração de token. Informar o locatário diz qual diretório respondeu pelo login; essa declaração diz que o diretório verificou que a pessoa realmente é dona do endereço. Sem ela, outra pessoa dentro do seu locatário poderia entrar com o endereço de um colega, então o ProjectSend criará contas novas mas nunca vinculará um login da Microsoft a uma conta que já existe.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "A redefinição de senha vai para este endereço, então alterá-lo exige a sua senha.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Seu endereço de e-mail vem do diretório ou provedor de identidade com que você entra, e não pode ser alterado aqui.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Os links de redefinição duram uma hora. Peça um novo e ele chega em instantes.",
|
||||
"Send me a new link": "Envie-me um novo link",
|
||||
"This link has expired": "Este link expirou",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Este link de redefinição não é mais válido. Peça um novo e tente de novo.",
|
||||
"Authenticate as this server's IAM role": "Autenticar com a função IAM deste servidor",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Para instalações na AWS que já tenham uma função associada — uma função de tarefa do ECS, um perfil de instância do EC2, EKS/IRSA. O ProjectSend pede credenciais temporárias ao SDK da AWS em vez de armazenar uma chave de acesso. Deixe desativado para MinIO, Backblaze, Wasabi e qualquer outro serviço que precise de chave de acesso e chave secreta.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Ao salvar, a chave de acesso e a chave secreta armazenadas aqui serão excluídas.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Já há envios demais em andamento. Conclua ou cancele um deles e tente novamente."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Показывать новости ProjectSend на панели",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Раз в день загружает анонсы проекта с projectsend.org для карточки на панели. Если выключить, эта установка вообще перестанет обращаться к projectsend.org за новостями.",
|
||||
"Announcement": "Объявление",
|
||||
"More": "Ещё"
|
||||
"More": "Ещё",
|
||||
"Copy the public link to this file": "Скопировать публичную ссылку на этот файл",
|
||||
"Downloaded :count times, last on :date": "Скачиваний: :count — последнее :date",
|
||||
"Downloaded once, on :date": "Скачан один раз, :date",
|
||||
"Not downloaded yet": "Ещё не скачивался",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Также добавьте необязательное утверждение «xms_edov» в регистрацию приложения, в разделе «Конфигурация токена». Указание клиента говорит, какой каталог поручился за вход; это утверждение говорит, что каталог проверил, что адрес действительно принадлежит человеку. Без него кто-то другой внутри вашего клиента смог бы войти с адресом коллеги, поэтому ProjectSend будет создавать новые учётные записи, но никогда не привяжет вход через Microsoft к уже существующей.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Сброс пароля отправляется на этот адрес, поэтому его изменение требует вашего пароля.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Ваш адрес электронной почты берётся из каталога или поставщика удостоверений, через который вы входите, и здесь его изменить нельзя.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Ссылки для сброса действуют один час. Запросите новую — она придёт через мгновение.",
|
||||
"Send me a new link": "Прислать новую ссылку",
|
||||
"This link has expired": "Срок действия ссылки истёк",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Эта ссылка для сброса больше не действует. Запросите новую и попробуйте снова.",
|
||||
"Authenticate as this server's IAM role": "Аутентификация через роль IAM этого сервера",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Для установок на AWS, которым уже назначена роль, — роль задачи ECS, профиль экземпляра EC2, EKS/IRSA. ProjectSend запрашивает временные учётные данные у AWS SDK вместо того, чтобы хранить ключ доступа. Оставьте выключенным для MinIO, Backblaze, Wasabi и всего остального, чему нужны ключ доступа и секретный ключ.",
|
||||
"Saving will delete the access key and secret currently stored here.": "При сохранении хранящиеся здесь ключ доступа и секретный ключ будут удалены.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Уже выполняется слишком много загрузок. Завершите или отмените одну из них и попробуйте снова."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Onyesha habari za ProjectSend kwenye dashibodi",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Huleta matangazo ya mradi kutoka projectsend.org mara moja kwa siku kwa ajili ya kadi ya dashibodi. Ukiizima, usakinishaji huu hautawasiliana kabisa na projectsend.org kwa habari.",
|
||||
"Announcement": "Tangazo",
|
||||
"More": "Zaidi"
|
||||
"More": "Zaidi",
|
||||
"Copy the public link to this file": "Nakili kiungo cha umma cha faili hili",
|
||||
"Downloaded :count times, last on :date": "Imepakuliwa mara :count, mara ya mwisho :date",
|
||||
"Downloaded once, on :date": "Imepakuliwa mara moja, :date",
|
||||
"Not downloaded yet": "Bado haijapakuliwa",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Ongeza pia dai la hiari \"xms_edov\" kwenye usajili wa programu yako, chini ya Token configuration. Kutaja mpangaji kunaeleza ni saraka gani iliyodhamini kuingia; dai hilo linaeleza kuwa saraka imethibitisha kuwa mtu huyo ndiye mmiliki halisi wa anwani. Bila hilo, mtu mwingine ndani ya mpangaji wako anaweza kuingia kwa anwani ya mwenzake, hivyo ProjectSend itaunda akaunti mpya lakini haitaunganisha kamwe kuingia kwa Microsoft na akaunti iliyopo.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Kuweka upya nenosiri hutumwa kwa anwani hii, hivyo kuibadilisha kunahitaji nenosiri lako.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Anwani yako ya barua pepe inatoka kwenye saraka au mtoa utambulisho unaotumia kuingia, na haiwezi kubadilishwa hapa.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Viungo vya kuweka upya hudumu saa moja. Omba kipya na kitafika punde.",
|
||||
"Send me a new link": "Nitumie kiungo kipya",
|
||||
"This link has expired": "Kiungo hiki kimeisha muda",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Kiungo hiki cha kuweka upya hakifanyi kazi tena. Omba kipya kisha ujaribu tena.",
|
||||
"Authenticate as this server's IAM role": "Thibitisha kwa jukumu la IAM la seva hii",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Kwa usakinishaji unaoendeshwa kwenye AWS ambao tayari una jukumu lililoambatishwa — jukumu la kazi la ECS, wasifu wa mfano wa EC2, EKS/IRSA. ProjectSend huomba AWS SDK kitambulisho cha muda badala ya kuhifadhi ufunguo wa ufikiaji. Iache imezimwa kwa MinIO, Backblaze, Wasabi na kitu kingine chochote kinachohitaji ufunguo wa ufikiaji na ufunguo wa siri.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Kuhifadhi kutafuta ufunguo wa ufikiaji na ufunguo wa siri vilivyohifadhiwa hapa.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Tayari kuna upakiaji mwingi mno unaoendelea. Maliza au ghairi mmoja kisha ujaribu tena."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "ProjectSend haberlerini panelde göster",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Panel kartı için proje duyurularını günde bir kez projectsend.org adresinden alır. Kapatıldığında bu kurulum haberler için projectsend.org ile hiç bağlantı kurmaz.",
|
||||
"Announcement": "Duyuru",
|
||||
"More": "Daha fazla"
|
||||
"More": "Daha fazla",
|
||||
"Copy the public link to this file": "Bu dosyanın herkese açık bağlantısını kopyalayın",
|
||||
"Downloaded :count times, last on :date": ":count kez indirildi, son olarak :date",
|
||||
"Downloaded once, on :date": "Bir kez indirildi, :date",
|
||||
"Not downloaded yet": "Henüz indirilmedi",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Uygulama kaydınıza, Belirteç yapılandırması altında \"xms_edov\" isteğe bağlı talebini de ekleyin. Kiracıyı belirtmek, oturum açma için hangi dizinin kefil olduğunu söyler; bu talep ise dizinin, kişinin adresin gerçekten sahibi olduğunu doğruladığını söyler. Bu olmadan, kiracınızdaki başka biri bir iş arkadaşının adresiyle oturum açabilir; bu nedenle ProjectSend yeni hesaplar oluşturur ancak bir Microsoft oturum açma işlemini var olan bir hesaba asla bağlamaz.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Parola sıfırlama bu adrese gönderilir, bu yüzden değiştirmek parolanızı gerektirir.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "E-posta adresiniz, oturum açtığınız dizinden veya kimlik sağlayıcısından gelir ve burada değiştirilemez.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Sıfırlama bağlantıları bir saat geçerlidir. Yenisini isteyin, birazdan ulaşır.",
|
||||
"Send me a new link": "Bana yeni bir bağlantı gönder",
|
||||
"This link has expired": "Bu bağlantının süresi doldu",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Bu sıfırlama bağlantısı artık geçerli değil. Yenisini isteyip tekrar deneyin.",
|
||||
"Authenticate as this server's IAM role": "Bu sunucunun IAM rolüyle kimlik doğrula",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "AWS üzerinde çalışan ve halihazırda bir rol atanmış kurulumlar için — bir ECS görev rolü, bir EC2 örnek profili, EKS/IRSA. ProjectSend, erişim anahtarı saklamak yerine AWS SDK'dan geçici kimlik bilgileri ister. MinIO, Backblaze, Wasabi ve erişim anahtarı ile gizli anahtar gerektiren diğer her şey için kapalı bırakın.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Kaydetmek, burada saklanan erişim anahtarını ve gizli anahtarı silecek.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Zaten çok fazla yükleme sürüyor. Birini tamamlayın veya iptal edin, sonra tekrar deneyin."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "Hiển thị tin tức ProjectSend trên bảng điều khiển",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "Tải thông báo của dự án từ projectsend.org mỗi ngày một lần cho thẻ trên bảng điều khiển. Khi tắt, bản cài đặt này sẽ hoàn toàn không liên hệ với projectsend.org để lấy tin tức.",
|
||||
"Announcement": "Thông báo",
|
||||
"More": "Thêm"
|
||||
"More": "Thêm",
|
||||
"Copy the public link to this file": "Sao chép liên kết công khai tới tệp này",
|
||||
"Downloaded :count times, last on :date": "Đã tải xuống :count lần, lần cuối :date",
|
||||
"Downloaded once, on :date": "Đã tải xuống một lần, :date",
|
||||
"Not downloaded yet": "Chưa được tải xuống",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "Đồng thời hãy thêm xác nhận quyền tùy chọn \"xms_edov\" vào đăng ký ứng dụng của bạn, trong phần Cấu hình mã thông báo. Việc nêu tên tenant cho biết thư mục nào đã bảo đảm cho lần đăng nhập; xác nhận quyền đó cho biết thư mục đã kiểm tra rằng người này thực sự sở hữu địa chỉ. Không có nó, một người khác trong tenant của bạn có thể đăng nhập bằng địa chỉ của đồng nghiệp, nên ProjectSend sẽ tạo tài khoản mới nhưng không bao giờ gắn một lần đăng nhập Microsoft vào tài khoản đã tồn tại.",
|
||||
"A password reset goes to this address, so changing it needs your password.": "Việc đặt lại mật khẩu sẽ gửi tới địa chỉ này, nên thay đổi nó cần mật khẩu của bạn.",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "Địa chỉ email của bạn đến từ thư mục hoặc nhà cung cấp danh tính mà bạn dùng để đăng nhập, và không thể thay đổi tại đây.",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "Liên kết đặt lại có hiệu lực một giờ. Hãy yêu cầu liên kết mới, nó sẽ đến ngay.",
|
||||
"Send me a new link": "Gửi cho tôi liên kết mới",
|
||||
"This link has expired": "Liên kết này đã hết hạn",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "Liên kết đặt lại này không còn hiệu lực. Hãy yêu cầu liên kết mới và thử lại.",
|
||||
"Authenticate as this server's IAM role": "Xác thực bằng vai trò IAM của máy chủ này",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "Dành cho các bản cài đặt chạy trên AWS đã được gắn sẵn một vai trò — vai trò tác vụ ECS, hồ sơ phiên bản EC2, EKS/IRSA. ProjectSend yêu cầu AWS SDK cấp thông tin đăng nhập tạm thời thay vì lưu khóa truy cập. Hãy để tắt với MinIO, Backblaze, Wasabi và bất kỳ dịch vụ nào khác cần khóa truy cập và khóa bí mật.",
|
||||
"Saving will delete the access key and secret currently stored here.": "Việc lưu sẽ xóa khóa truy cập và khóa bí mật đang được lưu ở đây.",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "Đã có quá nhiều lần tải lên đang diễn ra. Hãy hoàn tất hoặc hủy một lần rồi thử lại."
|
||||
}
|
||||
|
||||
+16
-1
@@ -2017,5 +2017,20 @@
|
||||
"Show ProjectSend news on the dashboard": "在仪表板上显示 ProjectSend 动态",
|
||||
"Fetches project announcements from projectsend.org once a day for the dashboard card. Turn it off and this installation stops contacting projectsend.org for news at all.": "每天一次从 projectsend.org 获取项目公告,用于仪表板卡片。关闭后,本安装将完全不再因动态而连接 projectsend.org。",
|
||||
"Announcement": "公告",
|
||||
"More": "更多"
|
||||
"More": "更多",
|
||||
"Copy the public link to this file": "复制此文件的公开链接",
|
||||
"Downloaded :count times, last on :date": "已下载 :count 次,最近一次 :date",
|
||||
"Downloaded once, on :date": "已下载 1 次,:date",
|
||||
"Not downloaded yet": "尚未下载",
|
||||
"Also add the \"xms_edov\" optional claim to your app registration, under Token configuration. Naming the tenant says which directory vouched for the sign-in; that claim says the directory checked the person really owns the address. Without it, someone else inside your tenant could sign in with a colleague's address, so ProjectSend will create new accounts but never attach a Microsoft sign-in to an account that already exists.": "还请在应用注册的“令牌配置”中添加可选声明“xms_edov”。指定租户说明是哪个目录为此次登录背书;该声明则说明目录已核实此人确实拥有该地址。没有它,你租户内的其他人就能用同事的地址登录,因此 ProjectSend 会创建新账户,但绝不会把 Microsoft 登录关联到已存在的账户。",
|
||||
"A password reset goes to this address, so changing it needs your password.": "密码重置会发送到这个地址,所以修改它需要你的密码。",
|
||||
"Your email address comes from the directory or identity provider you sign in with, and cannot be changed here.": "你的邮箱地址来自你用于登录的目录或身份提供方,无法在此处修改。",
|
||||
"Reset links last one hour. Ask for a new one and it will arrive in a moment.": "重置链接有效期为一小时。申请一个新的,稍后即可收到。",
|
||||
"Send me a new link": "给我发送新链接",
|
||||
"This link has expired": "此链接已过期",
|
||||
"This password reset link is no longer valid. Ask for a new one and try again.": "此重置链接已失效。请申请一个新的链接后重试。",
|
||||
"Authenticate as this server's IAM role": "使用此服务器的 IAM 角色进行认证",
|
||||
"For installations running on AWS with a role already attached — an ECS task role, an EC2 instance profile, EKS/IRSA. ProjectSend asks the AWS SDK for temporary credentials instead of storing an access key. Leave this off for MinIO, Backblaze, Wasabi and anything else that needs a key and secret.": "适用于已附加角色的 AWS 上的安装 —— ECS 任务角色、EC2 实例配置文件、EKS/IRSA。ProjectSend 会向 AWS SDK 申请临时凭证,而不是保存访问密钥。对于 MinIO、Backblaze、Wasabi 以及其他需要访问密钥和私有密钥的服务,请保持关闭。",
|
||||
"Saving will delete the access key and secret currently stored here.": "保存后会删除此处当前保存的访问密钥和私有密钥。",
|
||||
"Too many uploads are already in progress. Finish or cancel one and try again.": "正在进行的上传太多了。请先完成或取消一个,然后重试。"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,6 +80,25 @@ export function AnnouncementBand({ announcement }: { announcement: Announcement
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The band, reading the shared prop itself.
|
||||
*
|
||||
* Self-reading so a theme adds it in one line without threading a prop
|
||||
* through a page that has no other reason to know about it — the same
|
||||
* shape as AnnouncementIcon below. Every portal theme renders this, so a
|
||||
* message reaches a client wherever they are looking rather than only in
|
||||
* the theme somebody remembered to wire.
|
||||
*/
|
||||
export function ViewerAnnouncement() {
|
||||
const { announcement } = usePage<SharedData>().props;
|
||||
|
||||
if (!announcement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AnnouncementBand announcement={announcement} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header icon, beside the notification bell.
|
||||
*
|
||||
|
||||
@@ -7,6 +7,13 @@ export interface FileDelivery {
|
||||
method: DeliveryMethod;
|
||||
/** True when nobody set PROJECTSEND_FILE_DELIVERY and the server was detected. */
|
||||
detected: boolean;
|
||||
/**
|
||||
* Whether the detection had anything to work with. Always true in a
|
||||
* request; false only when something asks from a console, where
|
||||
* SERVER_SOFTWARE does not exist and `method` is a default rather
|
||||
* than a finding.
|
||||
*/
|
||||
observed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { useFormatDate } from '@/hooks/use-format-date';
|
||||
import { type FileRow } from '@/types/portal';
|
||||
|
||||
interface FileDownloadStatsProps {
|
||||
file: FileRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Did it arrive?" — how often a client's own file has gone out, and when
|
||||
* it last did.
|
||||
*
|
||||
* Renders nothing at all when `downloads` is null, which is every file
|
||||
* somebody shared *with* this client. That is a privacy rule, not a
|
||||
* tidiness one: a count on a file shared with several people tells each
|
||||
* of them about the others' activity. The server sends null rather than a
|
||||
* zero precisely so this cannot be shown by mistake — so gate on the
|
||||
* field being present and never on `is_mine`.
|
||||
*
|
||||
* Zero *is* rendered, as words. On a link-only account this is the only
|
||||
* evidence a customer has either way, and "nobody has taken it yet" is an
|
||||
* answer they came looking for.
|
||||
*/
|
||||
export function FileDownloadStats({ file }: FileDownloadStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { date } = useFormatDate();
|
||||
|
||||
if (file.downloads === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Whole sentences rather than assembled fragments, and a singular
|
||||
// spelled out rather than a pipe: `t()` does no plural selection —
|
||||
// the catalogues are flat key/value and a "one|many" string would
|
||||
// reach the screen with its pipe intact. A count above zero always
|
||||
// has a date, since the date is the newest of the rows counted.
|
||||
if (file.downloads.count === 0) {
|
||||
return <span>{t('Not downloaded yet')}</span>;
|
||||
}
|
||||
|
||||
const when = date(file.downloads.last_at);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{file.downloads.count === 1
|
||||
? t('Downloaded once, on :date', { date: when })
|
||||
: t('Downloaded :count times, last on :date', { count: file.downloads.count, date: when })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { Pencil, X } from 'lucide-react';
|
||||
import { Check, Link2, Pencil, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -24,12 +25,36 @@ interface FileRowActionsProps {
|
||||
* dialog and each theme owns its own; a file has eight fields behind five
|
||||
* separate permissions, so it gets a page (portal/edit-file.tsx) that every
|
||||
* theme shares rather than a form each theme would have to carry.
|
||||
*
|
||||
* The copy-link control follows the same rule as the other two: it appears
|
||||
* when the server put a `share_url` on the row and never otherwise. That is
|
||||
* not the same question as `is_mine` — a file shared *with* this client can
|
||||
* carry a link that is the sharer's to hand out, not the recipient's — so
|
||||
* this reads the field and derives nothing.
|
||||
*/
|
||||
export function FileRowActions({ file, size = 'sm' }: FileRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyLink = (url: string) => {
|
||||
void navigator.clipboard?.writeText(url);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{file.share_url !== null && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={size}
|
||||
onClick={() => copyLink(file.share_url as string)}
|
||||
title={t('Copy the public link to this file')}
|
||||
>
|
||||
{copied ? <Check className="size-4" /> : <Link2 className="size-4" />}
|
||||
<span className="sr-only">{copied ? t('Copied') : t('Copy link')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{file.can_update && (
|
||||
<Button variant="ghost" size={size} asChild>
|
||||
<Link href={route('my-files.edit', file.id)}>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { AppearanceSwitcher } from '@/components/appearance-switcher';
|
||||
import { LocaleSwitcher } from '@/components/locale-switcher';
|
||||
import { PoweredBy } from '@/components/powered-by';
|
||||
import ProjectSendLogo from '@/components/projectsend-logo';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { type SharedData } from '@/types';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -11,14 +12,34 @@ interface AuthLayoutProps {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every screen somebody sees before they are signed in — the login form,
|
||||
* registration, the password-reset pair, the two-factor challenge, first-run
|
||||
* setup, and the page a share link opens.
|
||||
*
|
||||
* An installation that has uploaded a logo shows it here, for the same
|
||||
* reason it shows on the public listing and in the client portal: these are
|
||||
* the pages that belong to whoever runs the installation, seen by their
|
||||
* clients, and the product's own wordmark is a stand-in for a brand rather
|
||||
* than the brand. Requested in #1777. Unbranded installs are unchanged.
|
||||
*
|
||||
* The `branding` prop is null whenever the Branding capability is absent, so
|
||||
* nothing here needs its own gate — see BrandingServiceProvider.
|
||||
*/
|
||||
export default function AuthSimpleLayout({ children, title, description }: AuthLayoutProps) {
|
||||
const { name, branding } = usePage<SharedData>().props;
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Link href={route('home')} className="flex flex-col items-center gap-2 font-medium">
|
||||
<ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
|
||||
{branding?.logo_url ? (
|
||||
<img src={branding.logo_url} alt={name} className="mb-1 h-12 w-auto object-contain" />
|
||||
) : (
|
||||
<ProjectSendLogo className="text-foreground mb-1 h-12 w-auto" />
|
||||
)}
|
||||
<span className="sr-only">{title}</span>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -84,6 +84,42 @@ test('the default storage quota setting prefills a new client\'s quota field', f
|
||||
);
|
||||
});
|
||||
|
||||
test('both client screens show the quota that will actually be enforced, floor included', function () {
|
||||
// The screens present this as what happens, not as a value being
|
||||
// edited, so they have to show the effective number. The edit screen
|
||||
// in particular mirrors quotaMb()'s resolution client-side to draw the
|
||||
// usage bar: handed the raw setting on a floored installation, it
|
||||
// computes an effective quota of 0, prints "unlimited", and hides the
|
||||
// bar entirely — for a client whose next upload is about to be
|
||||
// rejected for exceeding a limit the screen said did not exist.
|
||||
config()->set('projectsend.platform.default_client_quota_mb', 2048);
|
||||
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 0);
|
||||
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
|
||||
|
||||
$this->actingAs($this->admin)->get('/clients/create')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('default_storage_quota_mb', 2048),
|
||||
);
|
||||
|
||||
$this->actingAs($this->admin)->get("/clients/{$client->id}")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('default_storage_quota_mb', 2048),
|
||||
);
|
||||
});
|
||||
|
||||
test('the settings form still edits the stored setting, never the floor', function () {
|
||||
// The other half, and the reason this is not one change applied
|
||||
// everywhere. That field is read, then written back on save. Prefilled
|
||||
// with the floor, the next save of that page would write the
|
||||
// platform's number into the setting as the administrator's own
|
||||
// choice — where it would outlive the floor being removed.
|
||||
config()->set('projectsend.platform.default_client_quota_mb', 2048);
|
||||
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 0);
|
||||
|
||||
$this->actingAs($this->admin)->get('/system/settings/clients')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->where('default_client_storage_quota_mb', 0),
|
||||
);
|
||||
});
|
||||
|
||||
test('clearing the storage quota field to blank on the edit form resets it to inherit the site default', function () {
|
||||
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 150);
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 100]);
|
||||
@@ -129,20 +165,25 @@ test('a chunked session whose declared size already exceeds quota is rejected at
|
||||
expect(UploadSession::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a client who under-declares size then exceeds quota once assembled is rejected at completion', function () {
|
||||
test('a client whose quota fills while the transfer is running is rejected at completion', function () {
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 1]);
|
||||
grantUploadPermission($client);
|
||||
makeClientFile($client, 1000 * 1024); // ~1000 KB already used, out of a 1 MB quota
|
||||
$this->actingAs($client);
|
||||
|
||||
// Declared size (11 bytes) passes the session-creation check, but the
|
||||
// real assembled bytes (~50 KB) push the client over quota.
|
||||
$sessionId = createChunkedSession(11, 'lied-about-size.txt');
|
||||
// Declared honestly, and there is room for it when the session opens.
|
||||
$sessionId = createChunkedSession(50 * 1024, 'honest.txt');
|
||||
putChunkedPart($sessionId, 1, str_repeat('a', 50 * 1024));
|
||||
|
||||
// The rest of the quota goes while the bytes are in flight — another
|
||||
// device, a staff member uploading on their behalf, a second transfer
|
||||
// finishing first. A big file takes a long time and the check at
|
||||
// session creation is only true of the moment it was made, which is
|
||||
// why completion asks again rather than trusting it.
|
||||
makeClientFile($client, 1000 * 1024);
|
||||
|
||||
$this->postJson("/uploads/{$sessionId}/complete")->assertJsonValidationErrors('size');
|
||||
|
||||
expect(File::query()->where('uploaded_by', $client->id)->count())->toBe(1) // only the pre-existing fixture file
|
||||
expect(File::query()->where('uploaded_by', $client->id)->count())->toBe(1) // only the file that filled the quota
|
||||
->and(UploadSession::query()->find($sessionId))->toBeNull();
|
||||
|
||||
$paths = Storage::disk('files')->allFiles();
|
||||
@@ -170,6 +211,46 @@ test('ClientStorageUsage::quotaMb() resolves a client with no custom quota to th
|
||||
expect(app(ClientStorageUsage::class)->quotaMb($client))->toBe(250);
|
||||
});
|
||||
|
||||
test('a platform floor holds a client the installation never gave a quota to', function () {
|
||||
// The hole this closes: the setting's own default is 0, 0 means
|
||||
// unlimited, and an account that arrived without an explicit quota --
|
||||
// a self-registered one, say -- inherits it. On an installation a
|
||||
// platform runs for other people that is unmetered hosting one account
|
||||
// away, so a platform may put a floor under it from the environment,
|
||||
// exactly as it sets the seat caps.
|
||||
config()->set('projectsend.platform.default_client_quota_mb', 1);
|
||||
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 0);
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
|
||||
grantUploadPermission($client);
|
||||
makeClientFile($client, 1000 * 1024);
|
||||
$this->actingAs($client);
|
||||
|
||||
$this->postJson('/uploads', [
|
||||
'filename' => 'over-the-floor.pdf',
|
||||
'size' => 200 * 1024,
|
||||
'type' => 'application/pdf',
|
||||
])->assertJsonValidationErrors('size');
|
||||
});
|
||||
|
||||
test('a floor is under the setting, never over it', function () {
|
||||
// An administrator who has chosen a number keeps it, including a
|
||||
// larger one. The floor is for the installation that chose nothing.
|
||||
config()->set('projectsend.platform.default_client_quota_mb', 100);
|
||||
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 250);
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
|
||||
|
||||
expect(app(ClientStorageUsage::class)->quotaMb($client))->toBe(250);
|
||||
});
|
||||
|
||||
test('an install with no platform behind it is unaffected', function () {
|
||||
// Nothing set anywhere is still unlimited. This must not become a
|
||||
// ceiling that appears on self-hosted installs by default.
|
||||
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 0);
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
|
||||
|
||||
expect(app(ClientStorageUsage::class)->quotaMb($client))->toBe(0);
|
||||
});
|
||||
|
||||
test('ClientStorageUsage::quotaMb() lets a client\'s own custom quota override the site default', function () {
|
||||
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 250);
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 500]);
|
||||
@@ -216,12 +297,13 @@ test('the same is true when the real byte count is what pushes them over', funct
|
||||
app(Settings::class)->set(Setting::DefaultClientStorageQuotaMb, 1);
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 0]);
|
||||
grantUploadPermission($client);
|
||||
makeClientFile($client, 1000 * 1024);
|
||||
$this->actingAs($client);
|
||||
|
||||
$sessionId = createChunkedSession(11, 'lied-about-size.txt');
|
||||
$sessionId = createChunkedSession(50 * 1024, 'honest.txt');
|
||||
putChunkedPart($sessionId, 1, str_repeat('a', 50 * 1024));
|
||||
|
||||
makeClientFile($client, 1000 * 1024);
|
||||
|
||||
$response = $this->postJson("/uploads/{$sessionId}/complete")->assertJsonValidationErrors('size');
|
||||
|
||||
expect($response->json('errors.size.0'))->toBe('This upload would exceed your storage quota of 1 MB.');
|
||||
|
||||
@@ -164,19 +164,23 @@ test('a staff account without the upload permission cannot create sessions', fun
|
||||
$this->postJson('/uploads', ['filename' => 'x.zip', 'size' => 10])->assertForbidden();
|
||||
});
|
||||
|
||||
test('complete refuses a file whose real assembled size exceeds the limit, even when a tiny size was declared', function () {
|
||||
test('complete refuses a file whose real assembled size exceeds the limit', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
// A 1 MB cap. The session is declared as a single byte, so it sails
|
||||
// through store()'s check against the client-supplied size.
|
||||
app(Settings::class)->set(Setting::MaxFileSizeMb, 1);
|
||||
$sessionId = createSession(1, 'sneaky.zip');
|
||||
// Declared honestly and staged honestly, under a 2 MB cap.
|
||||
app(Settings::class)->set(Setting::MaxFileSizeMb, 2);
|
||||
$sessionId = createSession(1600 * 1024, 'sneaky.zip');
|
||||
|
||||
// But the real parts stream ~1.5 MB.
|
||||
$chunk = str_repeat('a', 800 * 1024);
|
||||
putPart($sessionId, 1, $chunk)->assertOk();
|
||||
putPart($sessionId, 2, $chunk)->assertOk();
|
||||
|
||||
// The cap moves while the transfer is running. Declaring a size is not
|
||||
// the same as being allowed to store it, which is why complete()
|
||||
// re-asks rather than trusting what store() decided — the parts have
|
||||
// been on disk for as long as the upload took.
|
||||
app(Settings::class)->set(Setting::MaxFileSizeMb, 1);
|
||||
|
||||
$this->postJson("/uploads/{$sessionId}/complete")->assertStatus(422);
|
||||
|
||||
// Nothing is kept: no File row, the assembled bytes are removed, and the
|
||||
@@ -286,7 +290,10 @@ test('a part within the size limit is still accepted', function () {
|
||||
|
||||
$session = $this->actingAs($user)->postJson('/uploads', [
|
||||
'filename' => 'ok.pdf',
|
||||
'size' => 1024,
|
||||
// Declared truthfully: a session only holds what it said it would,
|
||||
// so the part below has to fit inside this number as well as
|
||||
// inside the per-part cap.
|
||||
'size' => 512 * 1024,
|
||||
'type' => 'application/pdf',
|
||||
])->assertOk()->json('uploadId');
|
||||
|
||||
@@ -475,3 +482,106 @@ test('a second complete is refused while one is already finalising the session',
|
||||
$this->postJson("/uploads/{$sessionId}/complete")->assertOk();
|
||||
expect(File::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* GHSA-6jh6-gvj5-pv8v. The per-part cap bounded one request and nothing
|
||||
* else: a session could declare one byte, then stage 10,000 parts of twice
|
||||
* the part size, and none of it ever became a File row, so none of it
|
||||
* counted against a quota or showed up anywhere. Sessions were unlimited
|
||||
* too, and the sweeper only came round daily.
|
||||
*/
|
||||
test('a session cannot stage more bytes than it declared', function () {
|
||||
$user = User::factory()->create();
|
||||
grantChunkedUploadPermission($user);
|
||||
$this->actingAs($user);
|
||||
|
||||
$sessionId = createSession(1, 'one-byte.zip');
|
||||
|
||||
// The reporter's shape exactly: one byte declared, a part far under the
|
||||
// per-part cap, and nothing to stop it before this fix.
|
||||
putPart($sessionId, 1, str_repeat('a', 2 * 1024 * 1024))->assertStatus(413);
|
||||
|
||||
expect(Illuminate\Support\Facades\File::exists(partsRoot().'/'.$sessionId.'/1.part'))->toBeFalse()
|
||||
->and(UploadSession::query()->findOrFail($sessionId)->staged_bytes)->toBe(0);
|
||||
|
||||
// The one byte it did declare is still welcome, and the session is
|
||||
// still usable: a refusal must not poison the upload.
|
||||
putPart($sessionId, 1, 'a')->assertOk();
|
||||
expect(UploadSession::query()->findOrFail($sessionId)->staged_bytes)->toBe(1);
|
||||
});
|
||||
|
||||
test('staged bytes are released when a part is replaced, refused or falls short', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$sessionId = createSession(10, 'refunds.zip');
|
||||
$session = fn (): UploadSession => UploadSession::query()->findOrFail($sessionId);
|
||||
|
||||
putPart($sessionId, 1, 'aaaa')->assertOk();
|
||||
expect($session()->staged_bytes)->toBe(4);
|
||||
|
||||
// Re-sending a part replaces it rather than adding to it — an ordinary
|
||||
// resume must not spend the room twice.
|
||||
putPart($sessionId, 1, 'bb')->assertOk();
|
||||
expect($session()->staged_bytes)->toBe(2);
|
||||
|
||||
// A part that does not fit leaves nothing behind, including in the
|
||||
// running total: otherwise a client's own retries would exhaust a
|
||||
// session that has plenty of room left.
|
||||
putPart($sessionId, 2, str_repeat('c', 64))->assertStatus(413);
|
||||
expect($session()->staged_bytes)->toBe(2);
|
||||
|
||||
putPart($sessionId, 2, str_repeat('c', 8))->assertOk();
|
||||
expect($session()->staged_bytes)->toBe(10);
|
||||
});
|
||||
|
||||
test('open sessions count against a client quota, so it cannot be spent twice', function () {
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 1]);
|
||||
grantChunkedUploadPermission($client);
|
||||
$this->actingAs($client);
|
||||
|
||||
// The whole megabyte, declared but not yet sent. Before this fix the
|
||||
// quota only ever looked at finished files, so a second session was
|
||||
// told there was a full megabyte free — and so was a third.
|
||||
createSession(1024 * 1024, 'first.zip');
|
||||
|
||||
$this->postJson('/uploads', [
|
||||
'filename' => 'second.zip',
|
||||
'size' => 1024 * 1024,
|
||||
'type' => 'application/octet-stream',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('size');
|
||||
|
||||
expect(UploadSession::query()->where('user_id', $client->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('an abandoned session gives its room back once it is swept', function () {
|
||||
$client = User::factory()->client()->create(['storage_quota_mb' => 1]);
|
||||
grantChunkedUploadPermission($client);
|
||||
$this->actingAs($client);
|
||||
|
||||
$abandoned = createSession(1024 * 1024, 'abandoned.zip');
|
||||
|
||||
UploadSession::query()->whereKey($abandoned)->update(['created_at' => now()->subDays(2)]);
|
||||
$this->artisan('projectsend:purge-stale-uploads')->assertSuccessful();
|
||||
|
||||
// Held room is only held while the session is: the quota is a ceiling,
|
||||
// not a debt somebody is stuck with because a transfer died.
|
||||
createSession(1024 * 1024, 'second-attempt.zip');
|
||||
});
|
||||
|
||||
test('one account cannot hold unlimited sessions open', function () {
|
||||
config(['projectsend.uploads.max_open_sessions' => 3]);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
createSession(1024, 'a.zip');
|
||||
createSession(1024, 'b.zip');
|
||||
createSession(1024, 'c.zip');
|
||||
|
||||
// Staff have no quota to spend, so the session count is the only thing
|
||||
// bounding what they can hold on the temporary volume.
|
||||
$this->postJson('/uploads', [
|
||||
'filename' => 'd.zip',
|
||||
'size' => 1024,
|
||||
'type' => 'application/octet-stream',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('filename');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
/**
|
||||
* What a client is told about how often a file has been taken.
|
||||
*
|
||||
* "Did it arrive?" is the question, and on a hosted free account — where
|
||||
* a link is the whole of the sharing — the count is the only evidence
|
||||
* either way. But a download entry says somebody fetched the file, so a
|
||||
* count on a file shared with several clients tells each of them about
|
||||
* the others. Only the person who put the file there is entitled to it.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
app(Settings::class)->set(Setting::Theme, 'default');
|
||||
});
|
||||
|
||||
function downloadAt(File $file, ?User $actor, string $when, Action $action = Action::FileDownloaded): void
|
||||
{
|
||||
ActivityLog::query()->create([
|
||||
'actor_id' => $actor?->id,
|
||||
'actor_name' => $actor?->name,
|
||||
'actor_type' => $actor?->type->value,
|
||||
'action' => $action,
|
||||
'subject_type' => $file->getMorphClass(),
|
||||
'subject_id' => $file->id,
|
||||
'created_at' => $when,
|
||||
]);
|
||||
}
|
||||
|
||||
function statsRow(User $client, string $name): array
|
||||
{
|
||||
$row = null;
|
||||
|
||||
test()->actingAs($client)->get(route('my-files.index'))->assertInertia(
|
||||
function (AssertableInertia $page) use ($name, &$row) {
|
||||
$row = collect($page->toArray()['props']['files'])->firstWhere('name', $name);
|
||||
},
|
||||
);
|
||||
|
||||
expect($row)->not->toBeNull("No row named {$name} in the portal listing.");
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
test('a client is told how often their own file went out and when it last did', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine']);
|
||||
|
||||
downloadAt($file, null, '2026-09-01 10:00:00', Action::ShareLinkDownloaded);
|
||||
downloadAt($file, null, '2026-09-06 18:30:00', Action::ShareLinkDownloaded);
|
||||
|
||||
$downloads = statsRow($client, 'Mine')['downloads'];
|
||||
|
||||
expect($downloads['count'])->toBe(2)
|
||||
->and($downloads['last_at'])->toStartWith('2026-09-06T18:30:00');
|
||||
});
|
||||
|
||||
test('every way a file can leave is counted, not just one of them', function () {
|
||||
// The same three actions DownloadAllowance counts. A number that left
|
||||
// out public-site downloads would quietly under-report exactly the
|
||||
// sharing this is meant to report on.
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine']);
|
||||
|
||||
downloadAt($file, $this->admin, '2026-09-01 10:00:00', Action::FileDownloaded);
|
||||
downloadAt($file, null, '2026-09-02 10:00:00', Action::ShareLinkDownloaded);
|
||||
downloadAt($file, null, '2026-09-03 10:00:00', Action::PublicFileDownloaded);
|
||||
|
||||
expect(statsRow($client, 'Mine')['downloads']['count'])->toBe(3);
|
||||
});
|
||||
|
||||
test('an untouched file of their own says so, rather than saying nothing', function () {
|
||||
// Zero is an answer the customer came looking for. It has to be
|
||||
// distinguishable from "not yours to know", which is why the server
|
||||
// sends a zero here and a null below.
|
||||
$client = User::factory()->client()->create();
|
||||
File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Untouched']);
|
||||
|
||||
expect(statsRow($client, 'Untouched')['downloads'])->toBe(['count' => 0, 'last_at' => null]);
|
||||
});
|
||||
|
||||
test('a file shared with them carries no numbers at all', function () {
|
||||
// The disclosure this exists to prevent: a count on a file shared
|
||||
// with several people tells each of them about the others' activity.
|
||||
// Null, not zero — a zero would itself be a claim.
|
||||
$client = User::factory()->client()->create();
|
||||
$other = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Theirs']);
|
||||
shareFileWith($file, $client);
|
||||
shareFileWith($file, $other);
|
||||
|
||||
downloadAt($file, $other, '2026-09-01 10:00:00');
|
||||
|
||||
expect(statsRow($client, 'Theirs')['downloads'])->toBeNull();
|
||||
});
|
||||
|
||||
test('nothing but a download is counted', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine']);
|
||||
|
||||
downloadAt($file, $client, '2026-09-01 10:00:00', Action::FileUpdated);
|
||||
downloadAt($file, $client, '2026-09-02 10:00:00', Action::ShareLinkCreated);
|
||||
|
||||
expect(statsRow($client, 'Mine')['downloads']['count'])->toBe(0);
|
||||
});
|
||||
|
||||
test('one file\'s downloads never land on another\'s', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$one = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'One']);
|
||||
File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Two']);
|
||||
|
||||
downloadAt($one, null, '2026-09-01 10:00:00', Action::ShareLinkDownloaded);
|
||||
|
||||
expect(statsRow($client, 'One')['downloads']['count'])->toBe(1)
|
||||
->and(statsRow($client, 'Two')['downloads']['count'])->toBe(0);
|
||||
});
|
||||
|
||||
test('the listing costs one query for the counts however many rows it has', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
|
||||
foreach (range(1, 6) as $n) {
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => "File {$n}"]);
|
||||
downloadAt($file, null, '2026-09-01 10:00:00', Action::ShareLinkDownloaded);
|
||||
}
|
||||
|
||||
$queries = 0;
|
||||
DB::listen(function ($query) use (&$queries) {
|
||||
if (str_contains($query->sql, 'max(created_at)')) {
|
||||
$queries++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->actingAs($client)->get(route('my-files.index'))->assertOk();
|
||||
|
||||
expect($queries)->toBe(1);
|
||||
});
|
||||
|
||||
test('a client with no files of their own asks nothing at all', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Theirs']);
|
||||
shareFileWith($file, $client);
|
||||
|
||||
$queries = 0;
|
||||
DB::listen(function ($query) use (&$queries) {
|
||||
if (str_contains($query->sql, 'max(created_at)')) {
|
||||
$queries++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->actingAs($client)->get(route('my-files.index'))->assertOk();
|
||||
|
||||
expect($queries)->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use App\Modules\Files\Sharing\CreateShareLink;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
/**
|
||||
* Which public URLs a client is shown in their own portal.
|
||||
*
|
||||
* The rule is narrow on purpose: a link this client created, on a file
|
||||
* this client uploaded. Both halves. A link somebody else minted on a
|
||||
* file shared *with* them is that person's decision about who may reach
|
||||
* the file, and showing the recipient the URL would quietly turn "you may
|
||||
* download this" into "you may pass this on to anyone".
|
||||
*
|
||||
* Asserted on the rendered props rather than on the resolver alone,
|
||||
* because a theme reading `share_url` off the row is trusting that the
|
||||
* narrowing already happened.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
app(Settings::class)->set(Setting::Theme, 'default');
|
||||
});
|
||||
|
||||
function rowFor(User $client, string $name): array
|
||||
{
|
||||
$row = null;
|
||||
|
||||
test()->actingAs($client)->get(route('my-files.index'))->assertInertia(
|
||||
function (AssertableInertia $page) use ($name, &$row) {
|
||||
$row = collect($page->toArray()['props']['files'])->firstWhere('name', $name);
|
||||
},
|
||||
);
|
||||
|
||||
expect($row)->not->toBeNull("No row named {$name} in the portal listing.");
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
test('a client is shown the link on a file they uploaded', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine']);
|
||||
$link = app(CreateShareLink::class)->for($file, $client);
|
||||
|
||||
expect(rowFor($client, 'Mine')['share_url'])->toBe(route('share.show', $link->token));
|
||||
});
|
||||
|
||||
test('a client is not shown a staff link on a file shared with them', function () {
|
||||
// The disclosure this whole class exists to prevent.
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Theirs']);
|
||||
shareFileWith($file, $client);
|
||||
app(CreateShareLink::class)->for($file, $this->admin);
|
||||
|
||||
expect(rowFor($client, 'Theirs')['share_url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('a client is not shown a staff link on a file they uploaded themselves', function () {
|
||||
// The case that isolates the second half of the rule: the file *is*
|
||||
// theirs, so ownership alone would let this through. A link staff
|
||||
// minted is staff's decision about who may reach the file — it may
|
||||
// exist for a reason the customer is not part of, and on the shared
|
||||
// instance it may sit beside the one link they were promised.
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Mine, staff link']);
|
||||
app(CreateShareLink::class)->for($file, $this->admin);
|
||||
|
||||
expect(rowFor($client, 'Mine, staff link')['share_url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('a staff link never displaces the client\'s own', function () {
|
||||
// Ordering is by id, so a staff link minted first would be the one
|
||||
// an unfiltered lookup returned.
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Both links']);
|
||||
app(CreateShareLink::class)->for($file, $this->admin);
|
||||
$own = app(CreateShareLink::class)->for($file, $client);
|
||||
|
||||
expect(rowFor($client, 'Both links')['share_url'])->toBe(route('share.show', $own->token));
|
||||
});
|
||||
|
||||
test('a client is not shown their own link on a file that is no longer theirs', function () {
|
||||
// Both halves of the rule, not either: a link they minted before the
|
||||
// file was reassigned is not a link to a file they still own.
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Reassigned']);
|
||||
app(CreateShareLink::class)->for($file, $client);
|
||||
|
||||
$file->update(['uploaded_by' => $this->admin->id]);
|
||||
shareFileWith($file, $client);
|
||||
|
||||
expect(rowFor($client, 'Reassigned')['share_url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('one client is never shown another client\'s link', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$other = User::factory()->client()->create();
|
||||
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Shared with both']);
|
||||
shareFileWith($file, $client);
|
||||
shareFileWith($file, $other);
|
||||
app(CreateShareLink::class)->for($file, $other);
|
||||
|
||||
expect(rowFor($client, 'Shared with both')['share_url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('a file with no link says so rather than inventing one', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Unlinked']);
|
||||
|
||||
expect(rowFor($client, 'Unlinked')['share_url'])->toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| A link that would not work
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The only thing a client can do with this is copy it. A URL that answers
|
||||
| "this link has expired" is worse than no URL at all.
|
||||
*/
|
||||
|
||||
test('an expired link is left out', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Expired']);
|
||||
app(CreateShareLink::class)->for($file, $client, expiresAt: now()->subDay());
|
||||
|
||||
expect(rowFor($client, 'Expired')['share_url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('a spent link is left out', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Spent']);
|
||||
$link = app(CreateShareLink::class)->for($file, $client, maxDownloads: 1);
|
||||
$link->update(['downloads_count' => 1]);
|
||||
|
||||
expect(rowFor($client, 'Spent')['share_url'])->toBeNull();
|
||||
});
|
||||
|
||||
test('a live link is still shown when a dead one sits beside it', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Two links']);
|
||||
app(CreateShareLink::class)->for($file, $client, expiresAt: now()->subDay());
|
||||
$live = app(CreateShareLink::class)->for($file, $client);
|
||||
|
||||
expect(rowFor($client, 'Two links')['share_url'])->toBe(route('share.show', $live->token));
|
||||
});
|
||||
|
||||
test('the listing costs one query for the links however many rows it has', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
|
||||
foreach (range(1, 5) as $n) {
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => "File {$n}"]);
|
||||
app(CreateShareLink::class)->for($file, $client);
|
||||
}
|
||||
|
||||
$queries = 0;
|
||||
DB::listen(function ($query) use (&$queries) {
|
||||
if (str_contains($query->sql, 'share_links')) {
|
||||
$queries++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->actingAs($client)->get(route('my-files.index'))->assertOk();
|
||||
|
||||
expect($queries)->toBe(1);
|
||||
});
|
||||
|
||||
test('a client with no files of their own asks nothing at all', function () {
|
||||
// The empty case has no ids to look up, so it must not run a query
|
||||
// with an empty IN clause on every listing.
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Theirs']);
|
||||
shareFileWith($file, $client);
|
||||
|
||||
$queries = 0;
|
||||
DB::listen(function ($query) use (&$queries) {
|
||||
if (str_contains($query->sql, 'share_links')) {
|
||||
$queries++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->actingAs($client)->get(route('my-files.index'))->assertOk();
|
||||
|
||||
expect($queries)->toBe(0);
|
||||
});
|
||||
|
||||
test('the link the client is shown really works', function () {
|
||||
// The end of the chain: what is rendered is a URL a stranger can
|
||||
// fetch. Everything above tests who is told; this tests that being
|
||||
// told is worth something.
|
||||
$client = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $client->id, 'name' => 'Fetchable']);
|
||||
app(CreateShareLink::class)->for($file, $client);
|
||||
|
||||
$url = rowFor($client, 'Fetchable')['share_url'];
|
||||
|
||||
$this->post(route('logout'));
|
||||
|
||||
$this->get($url)->assertOk()->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->component('share/show')->where('status', 'active'),
|
||||
);
|
||||
|
||||
expect(ShareLink::query()->count())->toBe(1);
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -203,3 +204,49 @@ test('previews, thumbnails and zips travel the same way downloads do', function
|
||||
$response->assertOk()->assertHeaderMissing('X-Accel-Redirect');
|
||||
expect(strlen($response->streamedContent()))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Asking from a console
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| detect() reads SERVER_SOFTWARE, which only exists inside a request. A
|
||||
| console process therefore has nothing to look at and falls to the `php`
|
||||
| default — correct for that process, and wrong as a statement about the
|
||||
| installation, which is exactly how somebody running `artisan tinker` on
|
||||
| a healthy nginx box will read it. It cost somebody an afternoon before
|
||||
| it was recognised as an artefact of where the question was asked.
|
||||
*/
|
||||
|
||||
test('a console reading says the method was not observed', function () {
|
||||
config()->set('projectsend.file_delivery', null);
|
||||
|
||||
$described = app(FileDelivery::class)->describe();
|
||||
|
||||
expect($described['observed'])->toBeFalse()
|
||||
// Still `php`, because that is what this process would actually do.
|
||||
->and($described['method'])->toBe('php');
|
||||
});
|
||||
|
||||
test('a reading taken during a request is observed', function () {
|
||||
config()->set('projectsend.file_delivery', null);
|
||||
|
||||
request()->server->set('SERVER_SOFTWARE', 'nginx/1.27.0');
|
||||
|
||||
$described = app(FileDelivery::class)->describe();
|
||||
|
||||
expect($described['observed'])->toBeTrue()
|
||||
->and($described['method'])->toBe('nginx');
|
||||
});
|
||||
|
||||
// An explicit setting is somebody's decision and needs nothing observed to
|
||||
// be true — the value stands wherever it is read from.
|
||||
test('a stated method is always observed, console or not', function () {
|
||||
config()->set('projectsend.file_delivery', 'xsendfile');
|
||||
|
||||
$described = app(FileDelivery::class)->describe();
|
||||
|
||||
expect($described['method'])->toBe('xsendfile')
|
||||
->and($described['detected'])->toBeFalse()
|
||||
->and($described['observed'])->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Models\RolePermission;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* GHSA-rxf8-wh8v-jm9j, and it is the sibling of GHSA-237r-jx85-j3hr rather
|
||||
* than a new discovery: that advisory decided that putting content in a
|
||||
* public folder is publication, put the rule in Folder::uploadableBy(), and
|
||||
* wired it into the upload paths. Content arrives in a folder four other
|
||||
* ways — moved, bulk-moved, reparented through the edit form, or carried in
|
||||
* by its own folder being dragged somewhere — and none of them asked. So an
|
||||
* editor deliberately denied the publication permission could publish to the
|
||||
* anonymous site by choosing where things land.
|
||||
*
|
||||
* The fix to a report deserves the scrutiny the report got. These tests are
|
||||
* one per sink for that reason, and each has a private-destination control
|
||||
* beside it: the boundary is about publishing, not about moving.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
|
||||
// A staff user must exist or every request redirects to setup.
|
||||
$this->admin = User::factory()->create();
|
||||
|
||||
$this->publicFolder = Folder::query()->create([
|
||||
'name' => 'Brochures', 'slug' => 'brochures', 'path' => '/', 'public' => true,
|
||||
]);
|
||||
$this->privateFolder = Folder::query()->create([
|
||||
'name' => 'Internal', 'slug' => 'internal', 'path' => '/', 'public' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* An editor: may change files, may not publish them. The exact role the
|
||||
* permission matrix says cannot reach the public site.
|
||||
*/
|
||||
function editorWhoCannotPublish(): User
|
||||
{
|
||||
$role = Role::query()->create(['name' => 'Editor '.Str::random(6)]);
|
||||
|
||||
foreach ([Permission::Upload, Permission::EditFiles, Permission::EditOthersFiles, Permission::CreateOwnFolders] as $permission) {
|
||||
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
|
||||
}
|
||||
|
||||
return User::factory()->create(['role_id' => $role->id]);
|
||||
}
|
||||
|
||||
function editorWhoCanPublish(): User
|
||||
{
|
||||
$user = editorWhoCannotPublish();
|
||||
RolePermission::query()->create(['role_id' => $user->role_id, 'permission' => Permission::UploadPublic->value]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
function privateFileOwnedBy(User $owner, ?Folder $folder = null): File
|
||||
{
|
||||
return File::factory()->create([
|
||||
'name' => 'Salaries',
|
||||
'slug' => 'salaries-'.Str::random(6),
|
||||
'uploaded_by' => $owner->id,
|
||||
'folder_id' => $folder?->id,
|
||||
'public' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
test('the drag-and-drop move cannot publish', function () {
|
||||
$editor = editorWhoCannotPublish();
|
||||
$file = privateFileOwnedBy($editor, $this->privateFolder);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch("/files/{$file->id}/move", ['folder_id' => $this->publicFolder->id])
|
||||
->assertForbidden();
|
||||
|
||||
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
|
||||
});
|
||||
|
||||
test('the same move into a private folder still works', function () {
|
||||
$editor = editorWhoCannotPublish();
|
||||
$file = privateFileOwnedBy($editor);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch("/files/{$file->id}/move", ['folder_id' => $this->privateFolder->id])
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
|
||||
});
|
||||
|
||||
test('an editor who may publish can still move into a public folder', function () {
|
||||
$editor = editorWhoCanPublish();
|
||||
$file = privateFileOwnedBy($editor, $this->privateFolder);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch("/files/{$file->id}/move", ['folder_id' => $this->publicFolder->id])
|
||||
->assertRedirect();
|
||||
|
||||
expect($file->fresh()->folder_id)->toBe($this->publicFolder->id);
|
||||
});
|
||||
|
||||
test('bulk edit cannot publish a selection', function () {
|
||||
$editor = editorWhoCannotPublish();
|
||||
$one = privateFileOwnedBy($editor, $this->privateFolder);
|
||||
$two = privateFileOwnedBy($editor, $this->privateFolder);
|
||||
|
||||
$this->actingAs($editor)->patch('/files/bulk-edit', [
|
||||
'file_ids' => [$one->id, $two->id],
|
||||
'folder_action' => 'move',
|
||||
'folder_id' => $this->publicFolder->id,
|
||||
'description_action' => 'no_change',
|
||||
'expiration_action' => 'no_change',
|
||||
])->assertForbidden();
|
||||
|
||||
expect($one->fresh()->folder_id)->toBe($this->privateFolder->id)
|
||||
->and($two->fresh()->folder_id)->toBe($this->privateFolder->id);
|
||||
});
|
||||
|
||||
test('bulk edit into a private folder still works', function () {
|
||||
$editor = editorWhoCannotPublish();
|
||||
$file = privateFileOwnedBy($editor);
|
||||
|
||||
$this->actingAs($editor)->patch('/files/bulk-edit', [
|
||||
'file_ids' => [$file->id],
|
||||
'folder_action' => 'move',
|
||||
'folder_id' => $this->privateFolder->id,
|
||||
'description_action' => 'no_change',
|
||||
'expiration_action' => 'no_change',
|
||||
])->assertRedirect();
|
||||
|
||||
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
|
||||
});
|
||||
|
||||
test('the edit form cannot publish by reparenting', function () {
|
||||
$editor = editorWhoCannotPublish();
|
||||
$file = privateFileOwnedBy($editor, $this->privateFolder);
|
||||
|
||||
$this->actingAs($editor)->patch("/files/{$file->id}", [
|
||||
'name' => 'Salaries',
|
||||
'folder_id' => $this->publicFolder->id,
|
||||
])->assertForbidden();
|
||||
|
||||
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
|
||||
});
|
||||
|
||||
test('the API twin cannot publish by reparenting either', function () {
|
||||
$editor = editorWhoCannotPublish();
|
||||
$file = privateFileOwnedBy($editor, $this->privateFolder);
|
||||
|
||||
// The abilities a token may hold are bounded by the role behind it, so
|
||||
// this token is exactly as unable to publish as its owner.
|
||||
$token = $editor->createToken('t', [
|
||||
Permission::EditFiles->value,
|
||||
Permission::EditOthersFiles->value,
|
||||
])->plainTextToken;
|
||||
|
||||
$this->withToken($token)
|
||||
->patchJson("/api/v1/files/{$file->id}", ['folder_id' => $this->publicFolder->id])
|
||||
->assertForbidden();
|
||||
|
||||
expect($file->fresh()->folder_id)->toBe($this->privateFolder->id);
|
||||
});
|
||||
|
||||
test('dragging a whole folder into a public one cannot publish its contents', function () {
|
||||
$editor = editorWhoCannotPublish();
|
||||
$file = privateFileOwnedBy($editor, $this->privateFolder);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch("/folders/{$this->privateFolder->id}/move", ['parent_id' => $this->publicFolder->id])
|
||||
->assertForbidden();
|
||||
|
||||
expect($this->privateFolder->fresh()->parent_id)->toBeNull()
|
||||
->and($file->fresh()->isEffectivelyPublic())->toBeFalse();
|
||||
});
|
||||
|
||||
test('moving a folder somewhere private still works', function () {
|
||||
$editor = editorWhoCannotPublish();
|
||||
$nest = Folder::query()->create(['name' => 'Nested', 'slug' => 'nested', 'path' => '/', 'public' => false]);
|
||||
|
||||
$this->actingAs($editor)
|
||||
->patch("/folders/{$nest->id}/move", ['parent_id' => $this->privateFolder->id])
|
||||
->assertRedirect();
|
||||
|
||||
expect($nest->fresh()->parent_id)->toBe($this->privateFolder->id);
|
||||
});
|
||||
|
||||
test('a private file stays unreachable to a stranger across every reparent path', function () {
|
||||
// The end of the chain the advisory follows, and the only assertion that
|
||||
// is really about impact: placement makes isEffectivelyPublic() true, and
|
||||
// the anonymous routes treat that as the whole of the authorization.
|
||||
app(Settings::class)->set(Setting::PublicListingEnabled, true);
|
||||
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
|
||||
|
||||
$editor = editorWhoCannotPublish();
|
||||
$file = privateFileOwnedBy($editor, $this->privateFolder);
|
||||
|
||||
$this->get("/public/files/{$file->slug}/download")->assertNotFound();
|
||||
|
||||
foreach ([
|
||||
fn () => $this->actingAs($editor)->patch("/files/{$file->id}/move", ['folder_id' => $this->publicFolder->id]),
|
||||
fn () => $this->actingAs($editor)->patch("/files/{$file->id}", ['name' => 'Salaries', 'folder_id' => $this->publicFolder->id]),
|
||||
fn () => $this->actingAs($editor)->patch("/folders/{$this->privateFolder->id}/move", ['parent_id' => $this->publicFolder->id]),
|
||||
] as $attempt) {
|
||||
$attempt();
|
||||
|
||||
// The anonymous fetch first, because that is the claim: not that a
|
||||
// flag stayed off, but that a stranger with no session, no token
|
||||
// and no assignment still cannot read the bytes.
|
||||
$this->get("/public/files/{$file->slug}/download")->assertNotFound();
|
||||
expect($file->fresh()->isEffectivelyPublic())->toBeFalse();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Models\RolePermission;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Putting a file in a public folder publishes it, because
|
||||
* File::isEffectivelyPublic() is "my own flag, or my folder's". So the
|
||||
* destination is a way to publish without ever touching the switch that
|
||||
* `upload_public` guards.
|
||||
*
|
||||
* The client half of this has always been gated, on
|
||||
* `upload_to_public_folders` — see the picker in MyFilesController, whose
|
||||
* comment calls that the established meaning of the two keys. The staff
|
||||
* half never asked, so on a staff role that key did nothing at all.
|
||||
*
|
||||
* Reported as GHSA-237r-jx85-j3hr.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
|
||||
$this->public = Folder::query()->create([
|
||||
'name' => 'Brochures', 'slug' => 'brochures', 'path' => '/', 'public' => true,
|
||||
]);
|
||||
$this->private = Folder::query()->create([
|
||||
'name' => 'Internal', 'slug' => 'internal', 'path' => '/', 'public' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
function publicFolderStaff(array $permissions): User
|
||||
{
|
||||
$role = Role::query()->create(['name' => 'Role '.Str::random(6)]);
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
|
||||
}
|
||||
|
||||
return User::factory()->create(['role_id' => $role->id]);
|
||||
}
|
||||
|
||||
function uploadInto(User $staff, Folder $folder)
|
||||
{
|
||||
return test()->actingAs($staff)->post('/files', [
|
||||
'file' => UploadedFile::fake()->create('brochure.pdf', 8),
|
||||
'folder_id' => $folder->id,
|
||||
]);
|
||||
}
|
||||
|
||||
test('an uploader without either public key cannot publish through a folder', function () {
|
||||
$staff = publicFolderStaff([Permission::Upload]);
|
||||
|
||||
uploadInto($staff, $this->public)->assertForbidden();
|
||||
});
|
||||
|
||||
test('the same uploader can still upload into a private folder', function () {
|
||||
// The tightening is about publishing, not about uploading.
|
||||
$staff = publicFolderStaff([Permission::Upload]);
|
||||
|
||||
uploadInto($staff, $this->private)->assertRedirect();
|
||||
});
|
||||
|
||||
test('upload_to_public_folders is what opens it', function () {
|
||||
// The key already exists and already means this for clients. It simply
|
||||
// did nothing on a staff role.
|
||||
$staff = publicFolderStaff([Permission::Upload, Permission::UploadToPublicFolders]);
|
||||
|
||||
uploadInto($staff, $this->public)->assertRedirect();
|
||||
});
|
||||
|
||||
test('upload_public opens it too', function () {
|
||||
// Somebody who may set a file public may certainly put one where
|
||||
// everything is.
|
||||
$staff = publicFolderStaff([Permission::Upload, Permission::UploadPublic]);
|
||||
|
||||
uploadInto($staff, $this->public)->assertRedirect();
|
||||
});
|
||||
|
||||
test('a public ancestor counts, not just the folder itself', function () {
|
||||
// isEffectivelyPublic() walks up, so a private folder inside a public
|
||||
// one is still published. A check on the folder's own flag would miss
|
||||
// exactly this.
|
||||
$child = Folder::query()->create([
|
||||
'name' => 'Drafts', 'slug' => 'drafts', 'path' => '/'.$this->public->id.'/',
|
||||
'parent_id' => $this->public->id, 'public' => false,
|
||||
]);
|
||||
|
||||
$staff = publicFolderStaff([Permission::Upload]);
|
||||
|
||||
uploadInto($staff, $child)->assertForbidden();
|
||||
});
|
||||
|
||||
test('an administrator is unaffected', function () {
|
||||
expect(Folder::uploadableBy(User::factory()->create(), $this->public))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the chunked and API paths answer the same way', function () {
|
||||
// Every upload route asks Folder::uploadableBy(), which is the point
|
||||
// of it being there — one answer rather than four.
|
||||
$staff = publicFolderStaff([Permission::Upload]);
|
||||
|
||||
expect(Folder::uploadableBy($staff, $this->public))->toBeFalse()
|
||||
->and(Folder::uploadableBy($staff, $this->private))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| One render, however many ask at once
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Renditions are generated on demand and cached by existence, and nothing
|
||||
| between the callers stopped two requests decoding the same image at the
|
||||
| same time — the atomic rename settled which file survived, not whether
|
||||
| both had done the work.
|
||||
|
|
||||
| The trigger is not an attack. A public listing emits one thumbnail URL
|
||||
| per file, a browser opens six or more connections at once, and the first
|
||||
| visit to a gallery of ordinary camera images was six simultaneous
|
||||
| decodes, each holding four bytes per source pixel, on a container sized
|
||||
| for one. PublicGroupsController reaches the generator with no account.
|
||||
*/
|
||||
|
||||
function sourceImage(int $width = 400, int $height = 300): string
|
||||
{
|
||||
$path = sys_get_temp_dir().'/single-flight-'.bin2hex(random_bytes(6)).'.jpg';
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, imagecolorallocate($image, 30, 90, 150));
|
||||
imagejpeg($image, $path, 70);
|
||||
imagedestroy($image);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
function destinationPath(): string
|
||||
{
|
||||
$path = sys_get_temp_dir().'/rendition-'.bin2hex(random_bytes(6)).'.jpg';
|
||||
@unlink($path);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
afterEach(function () {
|
||||
foreach (glob(sys_get_temp_dir().'/single-flight-*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
foreach (glob(sys_get_temp_dir().'/rendition-*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
});
|
||||
|
||||
test('it renders when nothing else holds the lock', function () {
|
||||
$source = sourceImage();
|
||||
$destination = destinationPath();
|
||||
|
||||
app(ThumbnailGenerator::class)->generate(
|
||||
$source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
|
||||
);
|
||||
|
||||
expect(is_file($destination))->toBeTrue()
|
||||
->and(filesize($destination))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// The whole point: a waiter that gets in after the winner finished must
|
||||
// read the file rather than decode the source a second time.
|
||||
test('a request that waited serves what the winner made instead of rendering again', function () {
|
||||
$source = sourceImage();
|
||||
$destination = destinationPath();
|
||||
|
||||
// Stand in for the winner: the rendition is already there.
|
||||
file_put_contents($destination, 'already rendered');
|
||||
$before = filemtime($destination);
|
||||
|
||||
app(ThumbnailGenerator::class)->generate(
|
||||
$source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
|
||||
);
|
||||
|
||||
// Untouched — not re-encoded over the top.
|
||||
expect(file_get_contents($destination))->toBe('already rendered')
|
||||
->and(filemtime($destination))->toBe($before);
|
||||
});
|
||||
|
||||
// An empty file is what a render killed mid-flight leaves behind. It is
|
||||
// not a rendition, and treating it as one serves a broken image for as
|
||||
// long as the file lives, because nothing invalidates a cached rendition.
|
||||
test('an empty file is not treated as somebody else\'s finished work', function () {
|
||||
$source = sourceImage();
|
||||
$destination = destinationPath();
|
||||
|
||||
file_put_contents($destination, '');
|
||||
|
||||
app(ThumbnailGenerator::class)->generate(
|
||||
$source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
|
||||
);
|
||||
|
||||
expect(filesize($destination))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Deliberately not rendering anyway on timeout: falling through would
|
||||
// reinstate the pile-on at the moment the system is already struggling.
|
||||
// One failed thumbnail beats a container that dies and takes the warm
|
||||
// cache with it.
|
||||
test('it refuses rather than piling on when the wait times out', function () {
|
||||
// Shortened so the suite does not pay the real wait; the behaviour
|
||||
// under test is what happens when it elapses, not its length.
|
||||
config()->set('projectsend.rendition_lock_wait_seconds', 1);
|
||||
|
||||
$source = sourceImage();
|
||||
$destination = destinationPath();
|
||||
|
||||
// Somebody else is mid-render and has not finished.
|
||||
$held = Cache::lock('rendition:'.sha1($destination), 120);
|
||||
expect($held->get())->toBeTrue();
|
||||
|
||||
$generator = app(ThumbnailGenerator::class);
|
||||
|
||||
expect(fn () => $generator->generate(
|
||||
$source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
|
||||
))->toThrow(RuntimeException::class);
|
||||
|
||||
// And it did not decode the source behind the holder's back.
|
||||
expect(is_file($destination))->toBeFalse();
|
||||
|
||||
$held->release();
|
||||
});
|
||||
|
||||
// The lock is per rendition, not global: two different images must not
|
||||
// queue behind each other.
|
||||
test('two different renditions do not block one another', function () {
|
||||
$source = sourceImage();
|
||||
$mine = destinationPath();
|
||||
$theirs = destinationPath();
|
||||
|
||||
$held = Cache::lock('rendition:'.sha1($theirs), 120);
|
||||
expect($held->get())->toBeTrue();
|
||||
|
||||
app(ThumbnailGenerator::class)->generate(
|
||||
$source, $mine, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
|
||||
);
|
||||
|
||||
expect(is_file($mine))->toBeTrue();
|
||||
|
||||
$held->release();
|
||||
});
|
||||
|
||||
test('the lock is released, so the next request is not blocked by the last', function () {
|
||||
$source = sourceImage();
|
||||
$destination = destinationPath();
|
||||
$generator = app(ThumbnailGenerator::class);
|
||||
|
||||
$generator->generate($source, $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail);
|
||||
|
||||
expect(Cache::lock('rendition:'.sha1($destination), 5)->get())->toBeTrue();
|
||||
});
|
||||
|
||||
// A render that throws must not leave the lock held, or one oversized
|
||||
// image would wedge that rendition for everybody until the TTL expired.
|
||||
test('a failed render still releases the lock', function () {
|
||||
$destination = destinationPath();
|
||||
$generator = app(ThumbnailGenerator::class);
|
||||
|
||||
expect(fn () => $generator->generate(
|
||||
'/nonexistent/source.jpg', $destination, 'image/jpeg', ImageAudience::External, ImageRendition::Thumbnail,
|
||||
))->toThrow(RuntimeException::class);
|
||||
|
||||
expect(Cache::lock('rendition:'.sha1($destination), 5)->get())->toBeTrue();
|
||||
});
|
||||
|
||||
// A stray empty environment variable would otherwise make every
|
||||
// concurrent request fail instantly instead of waiting — the exact
|
||||
// opposite of what this is for, produced by doing nothing wrong.
|
||||
//
|
||||
// Asserted on the resolved value rather than on the clock: Laravel's
|
||||
// block() measures in whole seconds, so a one-second wait can elapse on
|
||||
// the very next tick and a timing assertion here would be flaky rather
|
||||
// than wrong.
|
||||
test('a zero, empty or nonsense wait still waits', function () {
|
||||
$resolve = function ($value): int {
|
||||
config()->set('projectsend.rendition_lock_wait_seconds', $value);
|
||||
|
||||
$method = new ReflectionMethod(ThumbnailGenerator::class, 'lockWaitSeconds');
|
||||
|
||||
return $method->invoke(app(ThumbnailGenerator::class));
|
||||
};
|
||||
|
||||
// Never zero, whatever arrives.
|
||||
expect($resolve(0))->toBe(1)
|
||||
->and($resolve(-5))->toBe(1)
|
||||
// Not a number at all: fall back to the default rather than to
|
||||
// nothing, which is what an unset or misspelled variable gives.
|
||||
->and($resolve(''))->toBe(15)
|
||||
->and($resolve(null))->toBe(15)
|
||||
->and($resolve('nonsense'))->toBe(15)
|
||||
// And an honest value is honoured.
|
||||
->and($resolve(30))->toBe(30)
|
||||
->and($resolve('45'))->toBe(45);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Events\FileWasStored;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Sharing\CreateShareLink;
|
||||
use App\Modules\Identity\Models\RolePermission;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| One seam for every upload path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Dispatched from StoreUploadedFile rather than from a controller, because
|
||||
| that is where the chunked flow and the synchronous POST converge. A
|
||||
| listener registered elsewhere — a package, say — should not have to know
|
||||
| which route a file arrived by.
|
||||
*/
|
||||
|
||||
test('storing a file announces it, whichever path stored it', function () {
|
||||
Event::fake([FileWasStored::class]);
|
||||
|
||||
$this->actingAs($this->admin)->post('/files', [
|
||||
'file' => Illuminate\Http\UploadedFile::fake()->create('report.pdf', 12, 'application/pdf'),
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
])->assertRedirect();
|
||||
|
||||
Event::assertDispatched(FileWasStored::class, function (FileWasStored $event): bool {
|
||||
return $event->file->original_name === 'report.pdf'
|
||||
&& $event->uploader->is($this->admin);
|
||||
});
|
||||
});
|
||||
|
||||
test('a client uploading through the portal announces it too', function () {
|
||||
// The title above says "whichever path stored it" and only the plain
|
||||
// staff POST proved it. This is the path the hosted free tier hangs
|
||||
// on: a *client*, through the resumable flow, whose upload is what
|
||||
// cloud-modules listens for to mint the public link.
|
||||
//
|
||||
// Worth a test of its own rather than trusting the shared
|
||||
// StoreUploadedFile, because the package's own suite cannot tell us —
|
||||
// it fakes both the event and the link-minting, so a chunked path that
|
||||
// stopped dispatching would leave every one of its tests green and the
|
||||
// free tier silently inert.
|
||||
Event::fake([FileWasStored::class]);
|
||||
|
||||
$client = User::factory()->client()->create();
|
||||
RolePermission::query()->firstOrCreate([
|
||||
'role_id' => $client->role_id,
|
||||
'permission' => Permission::Upload->value,
|
||||
]);
|
||||
|
||||
$this->actingAs($client);
|
||||
|
||||
$session = $this->postJson('/uploads', [
|
||||
'filename' => 'holiday.jpg',
|
||||
'size' => 11,
|
||||
'type' => 'image/jpeg',
|
||||
])->assertOk()->json('uploadId');
|
||||
|
||||
$signed = $this->getJson("/uploads/{$session}/parts/1/sign")->assertOk()->json('url');
|
||||
$this->call('PUT', $signed, [], [], [], [], 'hello world');
|
||||
|
||||
$this->postJson("/uploads/{$session}/complete")->assertOk();
|
||||
|
||||
Event::assertDispatched(FileWasStored::class, function (FileWasStored $event) use ($client): bool {
|
||||
return $event->file->original_name === 'holiday.jpg'
|
||||
&& $event->uploader->is($client);
|
||||
});
|
||||
});
|
||||
|
||||
// The row has to be complete when a listener sees it, or a listener that
|
||||
// reads the file back gets a half-built one.
|
||||
test('the file it carries is already stored and readable', function () {
|
||||
$seen = null;
|
||||
Event::listen(FileWasStored::class, function (FileWasStored $event) use (&$seen): void {
|
||||
$seen = File::query()->find($event->file->id);
|
||||
});
|
||||
|
||||
$this->actingAs($this->admin)->post('/files', [
|
||||
'file' => Illuminate\Http\UploadedFile::fake()->create('a.pdf', 5, 'application/pdf'),
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
])->assertRedirect();
|
||||
|
||||
expect($seen)->not->toBeNull()
|
||||
->and($seen->uploaded_by)->toBe($this->admin->id)
|
||||
->and(Storage::disk($seen->disk)->exists($seen->path))->toBeTrue();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Minting a link, from outside a request
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('it mints a long random token and never a chosen one by default', function () {
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
||||
|
||||
$link = app(CreateShareLink::class)->for($file, $this->admin);
|
||||
|
||||
// The token is the whole authorization for /s/{token} — there is
|
||||
// nothing behind it — so its only defence is being unguessable.
|
||||
// 32 characters is about 190 bits, more than a UUID's 122.
|
||||
expect(strlen($link->token))->toBe(32)
|
||||
->and($link->expires_at)->toBeNull()
|
||||
->and($link->max_downloads)->toBeNull()
|
||||
->and($link->created_by)->toBe($this->admin->id);
|
||||
});
|
||||
|
||||
test('two links for the same file never share a token', function () {
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
||||
$action = app(CreateShareLink::class);
|
||||
|
||||
expect($action->for($file, $this->admin)->token)
|
||||
->not->toBe($action->for($file, $this->admin)->token);
|
||||
});
|
||||
|
||||
// The controller still owns the permission questions — whether this
|
||||
// person may set an expiry or a cap is a fact about them, and the action
|
||||
// has no viewer to ask.
|
||||
test('the staff form still refuses an expiry to somebody without the permission', function () {
|
||||
$file = File::factory()->create(['uploaded_by' => $this->admin->id]);
|
||||
$limited = staffWithPermissions(['upload', 'edit_files']);
|
||||
$file->forceFill(['uploaded_by' => $limited->id])->save();
|
||||
|
||||
$this->actingAs($limited)->post("/files/{$file->id}/share-links", [
|
||||
'expires_at' => now()->addWeek()->toDateString(),
|
||||
'max_downloads' => 3,
|
||||
])->assertRedirect();
|
||||
|
||||
$link = $file->shareLinks()->sole();
|
||||
|
||||
expect($link->expires_at)->toBeNull()
|
||||
->and($link->max_downloads)->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Files\Uploads\UploadSession;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* The part of GHSA-6jh6-gvj5-pv8v's fix the ordinary suite cannot see.
|
||||
*
|
||||
* A session's staged-byte total is a running count that goes up when a
|
||||
* part is claimed and down when the part turns out smaller, is replaced,
|
||||
* or never arrives. The column is `BIGINT UNSIGNED`, and on MySQL that
|
||||
* type does not clamp: an expression that would go below zero raises
|
||||
* SQLSTATE 22003 — and it raises it in a `WHERE` as readily as in a `SET`,
|
||||
* so the bound written to prevent the underflow is itself the statement
|
||||
* that underflows.
|
||||
*
|
||||
* **SQLite has no unsigned integers.** Every one of these cases passes
|
||||
* there whether the arithmetic is arranged correctly or not, which is why
|
||||
* this file skips loudly instead of passing quietly.
|
||||
*
|
||||
* To run it:
|
||||
*
|
||||
* docker compose exec -T -e DB_CONNECTION=mysql -e DB_HOST=db \
|
||||
* -e DB_DATABASE=staged_bytes_check -e DB_USERNAME=root -e DB_PASSWORD=root \
|
||||
* app vendor/bin/pest tests/Feature/Files/UploadSessionStagedBytesMysqlTest.php
|
||||
*/
|
||||
beforeEach(function () {
|
||||
if (DB::connection()->getDriverName() !== 'mysql') {
|
||||
test()->markTestSkipped('Needs MySQL: SQLite has no unsigned integers and cannot show an underflow.');
|
||||
}
|
||||
|
||||
$this->session = UploadSession::query()->create([
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'original_name' => 'staged.zip',
|
||||
'size' => 100,
|
||||
]);
|
||||
});
|
||||
|
||||
function stagedBytes(): int
|
||||
{
|
||||
return (int) UploadSession::query()->findOrFail(test()->session->id)->staged_bytes;
|
||||
}
|
||||
|
||||
test('the column really is unsigned', function () {
|
||||
// Asserted rather than assumed: everything below only means something
|
||||
// if the column in use is the one that cannot go negative.
|
||||
// information_schema rather than SHOW COLUMNS: the latter takes no
|
||||
// bound parameter for its LIKE, and interpolating a table name into a
|
||||
// query is not a habit worth keeping for a test's convenience.
|
||||
$type = DB::selectOne(
|
||||
'select column_type as type from information_schema.columns
|
||||
where table_schema = database() and table_name = ? and column_name = ?',
|
||||
['upload_sessions', 'staged_bytes'],
|
||||
)->type ?? '';
|
||||
|
||||
expect(strtolower((string) $type))->toContain('unsigned');
|
||||
});
|
||||
|
||||
test('a session fills to its declared size and no further', function () {
|
||||
expect($this->session->reserveStaged(60))->toBeTrue()
|
||||
->and(stagedBytes())->toBe(60);
|
||||
|
||||
expect($this->session->reserveStaged(60))->toBeFalse()
|
||||
->and(stagedBytes())->toBe(60);
|
||||
|
||||
expect($this->session->reserveStaged(40))->toBeTrue()
|
||||
->and(stagedBytes())->toBe(100);
|
||||
|
||||
expect($this->session->reserveStaged(1))->toBeFalse()
|
||||
->and(stagedBytes())->toBe(100);
|
||||
});
|
||||
|
||||
test('a claim larger than the whole session is refused rather than attempted', function () {
|
||||
// The upper bound is rearranged to `staged_bytes <= size - delta`, and
|
||||
// that rearrangement is only valid while the right-hand side is not
|
||||
// negative. Without this early exit a claim of 400 against a session of
|
||||
// 100 compares against 0, which an empty session satisfies.
|
||||
expect($this->session->reserveStaged(400))->toBeFalse()
|
||||
->and(stagedBytes())->toBe(0);
|
||||
});
|
||||
|
||||
test('a refund larger than what is held changes nothing instead of erroring', function () {
|
||||
$this->session->reserveStaged(40);
|
||||
|
||||
// The shape that raised SQLSTATE 22003: settling a 100-byte claim that
|
||||
// was never fully counted. It has to be a refusal, not an exception —
|
||||
// this runs in putPart()'s finally, where a throw would replace the
|
||||
// real response with a 500.
|
||||
$this->session->settleStaged(100, 0);
|
||||
|
||||
expect(stagedBytes())->toBe(40);
|
||||
});
|
||||
|
||||
test('a refund of exactly what is held empties the session', function () {
|
||||
$this->session->reserveStaged(40);
|
||||
$this->session->settleStaged(40, 0);
|
||||
|
||||
expect(stagedBytes())->toBe(0);
|
||||
|
||||
// And the room really is free again, not merely reported as zero.
|
||||
expect($this->session->reserveStaged(100))->toBeTrue()
|
||||
->and(stagedBytes())->toBe(100);
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use App\Modules\Identity\Models\RolePermission;
|
||||
use App\Modules\Identity\Permissions\Permission;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
/**
|
||||
* A group belongs to the people in it, and a client-scoped staff member
|
||||
* holds only some of them.
|
||||
*
|
||||
* GroupMembershipScopeTest beside this one covers *reach*: what joining a
|
||||
* group would hand somebody. This covers the group object itself — being
|
||||
* told it exists, and renaming, deleting or publishing it. The two are
|
||||
* different questions and were answered by the same predicate, which only
|
||||
* asked the first.
|
||||
*
|
||||
* Reported as GHSA-r3hg-3fxw-rcmr.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
|
||||
$role = Role::query()->create(['name' => 'Reps '.Str::random(6), 'client_scoped' => true]);
|
||||
foreach ([Permission::ManageGroups, Permission::EditGroups, Permission::DeleteGroups, Permission::CreateGroups] as $permission) {
|
||||
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission->value]);
|
||||
}
|
||||
|
||||
$this->rep = User::factory()->create(['role_id' => $role->id]);
|
||||
$this->mine = User::factory()->client()->create(['name' => 'Mine']);
|
||||
$this->rep->assignedClients()->sync([$this->mine->id]);
|
||||
|
||||
$this->stranger = User::factory()->client()->create(['name' => 'Not Mine']);
|
||||
|
||||
// The group at the centre of the report: somebody else's client is its
|
||||
// only member, and nothing has been shared with it yet — so every
|
||||
// "does this group reach past my library" check answers no, vacuously.
|
||||
$this->theirs = Group::query()->create(['name' => 'Theirs Only', 'slug' => 'theirs-only', 'public' => false]);
|
||||
$this->theirs->members()->syncWithoutDetaching([$this->stranger->id]);
|
||||
|
||||
$this->ours = Group::query()->create(['name' => 'Ours', 'slug' => 'ours', 'public' => false]);
|
||||
$this->ours->members()->syncWithoutDetaching([$this->mine->id]);
|
||||
});
|
||||
|
||||
function listedGroupNames(User $viewer): array
|
||||
{
|
||||
$names = [];
|
||||
|
||||
test()->actingAs($viewer)->get('/groups')->assertOk()->assertInertia(
|
||||
function (AssertableInertia $page) use (&$names) {
|
||||
$names = collect($page->toArray()['props']['groups']['data'] ?? $page->toArray()['props']['groups'])
|
||||
->pluck('name')->all();
|
||||
},
|
||||
);
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Being told it exists
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('the listing hides a group made entirely of other people\'s clients', function () {
|
||||
expect(listedGroupNames($this->rep))->not->toContain('Theirs Only');
|
||||
});
|
||||
|
||||
test('the listing still shows a group holding one of their own clients', function () {
|
||||
expect(listedGroupNames($this->rep))->toContain('Ours');
|
||||
});
|
||||
|
||||
test('an unscoped administrator still sees every group', function () {
|
||||
expect(listedGroupNames($this->admin))->toContain('Theirs Only')->toContain('Ours');
|
||||
});
|
||||
|
||||
test('the API listing hides it too', function () {
|
||||
Sanctum::actingAs($this->rep, ['manage_groups']);
|
||||
|
||||
$names = collect($this->getJson('/api/v1/groups')->assertOk()->json('data'))->pluck('name')->all();
|
||||
|
||||
expect($names)->not->toContain('Theirs Only')->toContain('Ours');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Changing it
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The higher half of the report. Renaming, deleting or publishing a group
|
||||
| lands on every member, and none of this group's members are theirs.
|
||||
*/
|
||||
|
||||
test('they cannot open the edit form for it', function () {
|
||||
$this->actingAs($this->rep)->get("/groups/{$this->theirs->id}")->assertNotFound();
|
||||
});
|
||||
|
||||
test('they cannot rename it', function () {
|
||||
$this->actingAs($this->rep)
|
||||
->patch("/groups/{$this->theirs->id}", ['name' => 'Renamed', 'public' => false])
|
||||
->assertNotFound();
|
||||
|
||||
expect($this->theirs->fresh()->name)->toBe('Theirs Only');
|
||||
});
|
||||
|
||||
test('they cannot publish it', function () {
|
||||
// The consequence the report calls the serious one: a public group
|
||||
// becomes an anonymous listing for whatever is shared with it later.
|
||||
$this->actingAs($this->rep)
|
||||
->patch("/groups/{$this->theirs->id}", ['name' => 'Theirs Only', 'public' => true])
|
||||
->assertNotFound();
|
||||
|
||||
expect($this->theirs->fresh()->public)->toBeFalse();
|
||||
});
|
||||
|
||||
test('they cannot delete it out from under its members', function () {
|
||||
$this->actingAs($this->rep)->delete("/groups/{$this->theirs->id}")->assertNotFound();
|
||||
|
||||
expect(Group::query()->whereKey($this->theirs->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the API refuses the same three', function () {
|
||||
Sanctum::actingAs($this->rep, ['edit_groups', 'delete_groups']);
|
||||
|
||||
$this->getJson("/api/v1/groups/{$this->theirs->id}")->assertNotFound();
|
||||
$this->patchJson("/api/v1/groups/{$this->theirs->id}", ['name' => 'Renamed'])->assertNotFound();
|
||||
$this->deleteJson("/api/v1/groups/{$this->theirs->id}")->assertNotFound();
|
||||
|
||||
expect($this->theirs->fresh()->name)->toBe('Theirs Only');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| What must keep working
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The pins. The fix suggested in the report puts the membership check
|
||||
| inside groupReachesNoFurther(), which allowsGroupMembership() also
|
||||
| calls — and that would have broken both of these.
|
||||
*/
|
||||
|
||||
test('they can still rename a group of their own client', function () {
|
||||
$this->actingAs($this->rep)
|
||||
->patch("/groups/{$this->ours->id}", ['name' => 'Ours Renamed', 'public' => false])
|
||||
->assertRedirect();
|
||||
|
||||
expect($this->ours->fresh()->name)->toBe('Ours Renamed');
|
||||
});
|
||||
|
||||
test('they can still put the first member into a group they just made', function () {
|
||||
// A group nobody has joined belongs to nobody, and this is the case
|
||||
// StaffLibraryScope's own docblock says must keep working.
|
||||
$fresh = Group::query()->create(['name' => 'Brand New', 'slug' => 'brand-new', 'public' => false]);
|
||||
|
||||
$this->actingAs($this->rep)
|
||||
->post("/groups/{$fresh->id}/members", ['user_id' => $this->mine->id])
|
||||
->assertRedirect();
|
||||
|
||||
expect($fresh->members()->pluck('users.id')->all())->toContain($this->mine->id);
|
||||
});
|
||||
|
||||
test('they can still rename an empty group', function () {
|
||||
$fresh = Group::query()->create(['name' => 'Brand New', 'slug' => 'brand-new', 'public' => false]);
|
||||
|
||||
$this->actingAs($this->rep)
|
||||
->patch("/groups/{$fresh->id}", ['name' => 'Named At Last', 'public' => false])
|
||||
->assertRedirect();
|
||||
|
||||
expect($fresh->fresh()->name)->toBe('Named At Last');
|
||||
});
|
||||
|
||||
test('a mixed group stays changeable, with its stranger unnamed', function () {
|
||||
// The boundary between this report and GHSA-whmp-p9hv-r7j7. "Every
|
||||
// member must be mine" is the obvious reading of the fix and it is
|
||||
// wrong: it turns that advisory's narrowing back into a 404. A group
|
||||
// holding one of their clients is theirs to work with; what protects
|
||||
// the stranger in it is that they are never named, and that anything
|
||||
// shared with the group beyond this viewer's library still refuses
|
||||
// the change.
|
||||
$mixed = Group::query()->create(['name' => 'Mixed', 'slug' => 'mixed', 'public' => false]);
|
||||
$mixed->members()->syncWithoutDetaching([$this->mine->id, $this->stranger->id]);
|
||||
|
||||
$this->actingAs($this->rep)
|
||||
->patch("/groups/{$mixed->id}", ['name' => 'Mixed Renamed', 'public' => false])
|
||||
->assertRedirect();
|
||||
|
||||
expect($mixed->fresh()->name)->toBe('Mixed Renamed');
|
||||
});
|
||||
|
||||
test('an unscoped administrator can still change anything', function () {
|
||||
$this->actingAs($this->admin)
|
||||
->patch("/groups/{$this->theirs->id}", ['name' => 'Admin Renamed', 'public' => false])
|
||||
->assertRedirect();
|
||||
|
||||
expect($this->theirs->fresh()->name)->toBe('Admin Renamed');
|
||||
});
|
||||
@@ -183,3 +183,51 @@ test('reassign falls back to cascade when the target is no longer valid', functi
|
||||
expect(File::find($file->id))->toBeNull()
|
||||
->and(ActivityLog::query()->where('action', Action::AccountContentCascadeDeleted->value)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a staff member\'s content is never handed to a client', function () {
|
||||
// The installation-wide reassignment target is one id for every
|
||||
// erasure, and the picker offers clients — legitimately, because
|
||||
// erasing a client and handing their files to another client is what
|
||||
// that setting is for. Applied to a *staff* account it means something
|
||||
// else entirely: a staff library is usually everything, and a client
|
||||
// named as the target inherits the lot.
|
||||
$client = User::factory()->client()->create(['name' => 'Inheriting Client']);
|
||||
app(Settings::class)->set(Setting::AccountErasureContentAction, 'reassign');
|
||||
app(Settings::class)->set(Setting::AccountErasureReassignTo, $client->id);
|
||||
|
||||
$leaving = User::factory()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $leaving->id]);
|
||||
|
||||
app(AccountEraser::class)->erase($leaving);
|
||||
|
||||
// Cascade, not reassign: never orphaned, and never disclosed either.
|
||||
expect(File::find($file->id))->toBeNull()
|
||||
->and(ActivityLog::query()->where('action', Action::AccountContentCascadeDeleted->value)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a client\'s content can still go to a client', function () {
|
||||
// Unchanged, and deliberately: this is the case the setting exists for.
|
||||
$inheritor = User::factory()->client()->create();
|
||||
app(Settings::class)->set(Setting::AccountErasureContentAction, 'reassign');
|
||||
app(Settings::class)->set(Setting::AccountErasureReassignTo, $inheritor->id);
|
||||
|
||||
$leaving = User::factory()->client()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $leaving->id]);
|
||||
|
||||
app(AccountEraser::class)->erase($leaving);
|
||||
|
||||
expect(File::find($file->id)?->uploaded_by)->toBe($inheritor->id);
|
||||
});
|
||||
|
||||
test('a staff member\'s content still goes to another staff member', function () {
|
||||
$inheritor = User::factory()->create();
|
||||
app(Settings::class)->set(Setting::AccountErasureContentAction, 'reassign');
|
||||
app(Settings::class)->set(Setting::AccountErasureReassignTo, $inheritor->id);
|
||||
|
||||
$leaving = User::factory()->create();
|
||||
$file = File::factory()->create(['uploaded_by' => $leaving->id]);
|
||||
|
||||
app(AccountEraser::class)->erase($leaving);
|
||||
|
||||
expect(File::find($file->id)?->uploaded_by)->toBe($inheritor->id);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\AccountLookup;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* The one test that can see GHSA-wgxf-v8cr-37mj.
|
||||
*
|
||||
* The defect was never in PHP: `where('email', $address)` asked the
|
||||
* database what equality means, and the documented collation —
|
||||
* `utf8mb4_unicode_ci`, in INSTALL.md and in config/database.php — folds
|
||||
* accents. `administrator@example.com` and `administrator@éxample.com`
|
||||
* compare equal, and those are two different domains: the second is
|
||||
* `xn--xample-9ua.com`, which somebody else can own and honestly verify at
|
||||
* an OIDC provider.
|
||||
*
|
||||
* **The suite runs on SQLite, whose `=` is byte-exact, so none of this
|
||||
* exists there.** That is why the vulnerability lived through six releases
|
||||
* with a green suite, and why this file skips rather than passing: a test
|
||||
* that silently proves nothing is worse than one that says it did not run.
|
||||
*
|
||||
* To run it:
|
||||
*
|
||||
* docker compose exec -T -e DB_CONNECTION=mysql -e DB_HOST=db \
|
||||
* -e DB_DATABASE=collation_check -e DB_USERNAME=root -e DB_PASSWORD=root \
|
||||
* app vendor/bin/pest tests/Feature/Identity/AccountLookupCollationTest.php
|
||||
*/
|
||||
beforeEach(function () {
|
||||
if (DB::connection()->getDriverName() !== 'mysql') {
|
||||
test()->markTestSkipped('Needs MySQL: SQLite compares byte-exactly and cannot show a collation fault.');
|
||||
}
|
||||
});
|
||||
|
||||
test('the database really does fold the accent', function () {
|
||||
// Asserted rather than assumed, because everything below is only
|
||||
// meaningful if this is true of the connection actually in use. An
|
||||
// installation on utf8mb4_bin has never had the bug.
|
||||
$folds = DB::selectOne("SELECT _utf8mb4'a@example.com' COLLATE utf8mb4_unicode_ci = _utf8mb4'a@éxample.com' COLLATE utf8mb4_unicode_ci AS c")->c;
|
||||
|
||||
expect((int) $folds)->toBe(1);
|
||||
});
|
||||
|
||||
test('the raw query matches an address it should not', function () {
|
||||
// The defect itself, shown rather than described: this is exactly what
|
||||
// SocialAuthenticator used to run.
|
||||
User::factory()->create(['email' => 'administrator@example.com']);
|
||||
|
||||
$found = User::query()->where('email', 'administrator@éxample.com')->first();
|
||||
|
||||
expect($found)->not->toBeNull('the collation no longer folds — the rest of this file is moot');
|
||||
});
|
||||
|
||||
test('the lookup refuses the address the collation would have accepted', function () {
|
||||
// The fix. Same database, same collation, same row present.
|
||||
User::factory()->create(['email' => 'administrator@example.com']);
|
||||
|
||||
expect(app(AccountLookup::class)->byEmail('administrator@éxample.com'))->toBeNull();
|
||||
});
|
||||
|
||||
test('and still finds the real one', function () {
|
||||
$user = User::factory()->create(['email' => 'administrator@example.com']);
|
||||
|
||||
expect(app(AccountLookup::class)->byEmail('administrator@example.com')?->id)->toBe($user->id)
|
||||
->and(app(AccountLookup::class)->byEmail('ADMINISTRATOR@Example.com')?->id)->toBe($user->id);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\AccountLookup;
|
||||
|
||||
/**
|
||||
* Which account an address names.
|
||||
*
|
||||
* **This file cannot, on its own, prove the bug it was written for.** The
|
||||
* suite runs on SQLite (`phpunit.xml`), whose `=` is byte-exact, so the
|
||||
* collation that folds `é` into `e` does not exist here and the takeover
|
||||
* never reproduces. A test written the obvious way — seed an account,
|
||||
* sign in through OIDC with the accented address, assert refused — passes
|
||||
* on unfixed code, for the wrong reason.
|
||||
*
|
||||
* So the split is deliberate. These pin the *comparison*, which is where
|
||||
* the decision now lives and which is driver-independent.
|
||||
* AccountLookupCollationTest beside this one exercises the whole chain and
|
||||
* skips unless the connection is MySQL; it is the only one that can see
|
||||
* the original defect, and it has to be run deliberately.
|
||||
*
|
||||
* Reported as GHSA-wgxf-v8cr-37mj.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->lookup = app(AccountLookup::class);
|
||||
});
|
||||
|
||||
test('an accented domain is a different address', function () {
|
||||
// The whole finding in one line. éxample.com is xn--xample-9ua.com,
|
||||
// a name somebody else can register and honestly prove they own.
|
||||
expect($this->lookup->isSameAddress('administrator@example.com', 'administrator@éxample.com'))
|
||||
->toBeFalse();
|
||||
});
|
||||
|
||||
test('case is still folded, because that is a real requirement', function () {
|
||||
// Addresses are stored lowercased and a provider may send any case.
|
||||
// Folding case without folding accents is the line being drawn.
|
||||
expect($this->lookup->isSameAddress('admin@example.com', 'ADMIN@Example.com'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('surrounding whitespace does not make it a different mailbox', function () {
|
||||
expect($this->lookup->isSameAddress('admin@example.com', ' admin@example.com '))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a null on either side matches nothing', function () {
|
||||
expect($this->lookup->isSameAddress(null, 'admin@example.com'))->toBeFalse()
|
||||
->and($this->lookup->isSameAddress('admin@example.com', null))->toBeFalse();
|
||||
});
|
||||
|
||||
test('other confusables are refused too', function (string $lookalike) {
|
||||
expect($this->lookup->isSameAddress('admin@example.com', $lookalike))->toBeFalse();
|
||||
})->with([
|
||||
'accented o' => ['admin@exämple.com'],
|
||||
'cyrillic a' => ['аdmin@example.com'],
|
||||
'trailing dot' => ['admin@example.com.'],
|
||||
'different tld' => ['admin@example.co'],
|
||||
]);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| The lookup itself
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These pass on SQLite whether or not the fix is present, and are here for
|
||||
| the ordinary behaviour rather than for the defect.
|
||||
*/
|
||||
|
||||
test('it finds the account that holds the address', function () {
|
||||
$user = User::factory()->create(['email' => 'owner@example.com']);
|
||||
|
||||
expect($this->lookup->byEmail('owner@example.com')?->id)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('it finds nothing for an address nobody holds', function () {
|
||||
User::factory()->create(['email' => 'owner@example.com']);
|
||||
|
||||
expect($this->lookup->byEmail('somebody@example.com'))->toBeNull();
|
||||
});
|
||||
|
||||
test('a deleted account is out of sight unless asked for', function () {
|
||||
// A deleted account keeps its address until erasure, which is what
|
||||
// AvailableEmailRule is built on — so the erasure command has to be
|
||||
// able to reach it and the sign-in paths must not.
|
||||
$user = User::factory()->create(['email' => 'gone@example.com']);
|
||||
$user->delete();
|
||||
|
||||
expect($this->lookup->byEmail('gone@example.com'))->toBeNull()
|
||||
->and($this->lookup->byEmail('gone@example.com', withTrashed: true)?->id)->toBe($user->id);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user