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