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