14 Commits

Author SHA1 Message Date
ignacionelson 55e17498a2 Log which bucket an upload could not be written to
The failure message names the disk, which reads as a credentials problem
even when the real cause is a bucket name that was never changed — the
exact confusion produced by switching an existing S3 configuration over
to Google and leaving the old bucket in the field.

Logged rather than shown, because 'throw' => false means the reason is
already gone by the time this code runs, and because the message goes to
whoever was uploading. That can be a client, and a bucket name is not
theirs to see.
2026-08-24 19:56:10 -03:00
ignacionelson a459d45c87 Store the bytes, or say you did not
Two bugs a green suite could not find, both from pointing the
application at a real Google Cloud Storage bucket.

The adapter attaches a legacy per-object ACL to every write, and a
bucket with uniform bucket-level access — which our own setup
instructions require, and which Google recommends — refuses it:
"Cannot insert legacy ACL for an object when uniform bucket-level access
is enabled". So the default configuration could not write to the
recommended bucket. The library ships
UniformBucketLevelAccessVisibility for exactly this, and nothing is
lost by never setting an ACL: every object here is private and every
read is a signed URL.

The second is worse and was never about Google. Both file disks are
configured 'throw' => false, so a refused write returns false rather
than raising, and LocalPartStore ignored the return. The upload reported
success, the File row was written, and the bytes were nowhere — the
listing showed a file whose download could never work. An expired S3
credential did the same thing. It now checks, and the controller already
turns that into a validation error rather than a 500, so the person
uploading is told.

Verified against a live bucket with a key scoped to
roles/storage.objectAdmin: the probe lists, writes land, reads
round-trip byte for byte, and a signed URL comes back 200 carrying
"Informe año.pdf" intact through both the ASCII and RFC 8187 forms of
Content-Disposition.
2026-08-24 19:52:05 -03:00
ignacionelson b22d3cf33c Translate the storage provider strings into all sixteen locales
Eight new strings from the Google Cloud Storage work, and nothing else:
the scan reported the same eight missing everywhere, so this is a
translation pass rather than a backlog.

Each locale keeps the word for a bucket it was already using — kova in
Turkish, бакет in Russian, 存储桶 in Chinese — and its own level of
formality, Sie in German and vous in French against tú in Spanish and
Italian. "Google Cloud Storage" is a product name and stays as it is in
all sixteen, the way API and OK already do. The :field placeholder
survives verbatim, which is asserted rather than assumed.

Entries are inserted in place rather than appended, so each file shows
eight added lines and nothing else moved. Verified by re-running the
scan to zero missing, the Locale suite, and reading the settings screen
in Spanish in a browser — a file that parses is not evidence that a
sentence fits its button.
2026-08-24 19:40:58 -03:00
ignacionelson f7db586c7e Say that files can live in Google Cloud Storage too
Three lines still told readers S3 was the only option, which stopped
being true and is the sort of thing somebody chooses a different product
over. The install guide's storage section now says what each backend is
for, that Test connection exists and is worth using before switching
uploads over, and — the part people actually get wrong — that choosing a
backend applies to new uploads and moves nothing that is already stored.
2026-08-24 19:40:58 -03:00
ignacionelson 23b7dc0d11 Declare the capability a managed installation's storage hangs off
Cloud instances are given a bucket rather than configuring one, which is
the counterpart of StorageConfigure above it rather than a contradiction
of it: one edition points itself at storage, the other is pointed.

Only the declaration lives here. The behaviour is in the private
cloud-modules package, the same division Branding already uses, and
without that package the capability is inert and files stay on local
disk — so a self-hosted installation that somehow holds it is unchanged.
2026-08-24 18:35:44 -03:00
ignacionelson daec0a877e Offer Google Cloud Storage as a storage backend
External storage meant S3 and nothing else, which is an odd hole for a
product whose users are as likely to be standing on Google Cloud as on
AWS — and paying to move bytes between two clouds to use this. The
Storage screen now asks which provider first, and the answer decides
which fields it shows, which it validates, and which driver the
files_external disk resolves to.

One disk, not two. files.disk is a stored column, so a third disk name
would fragment the data model and make every $file->disk consumer know
three names instead of two; the driver is swapped instead. A service
account key gets its own encrypted column rather than sharing `secret`,
because the two are validated, labelled and displayed differently and
one column meaning two things is how that goes wrong later.

Three things do not work by simply adding the adapter, and all three
fail quietly:

Laravel's temporaryUrl() looks for getTemporaryUrl() on the adapter,
while League's GCS adapter names it temporaryUrl(), so without the
registered callback every download and preview is a 500.

The two SDKs spell the signing options differently, and an unrecognised
one is dropped in silence — the symptom is a download named after the
storage key, not an exception. GoogleCloudStorageDriver translates, so
callers keep speaking one vocabulary, and the test asserts on the URL's
contents rather than on "a redirect happened", which is what would let
it regress.

That callback is also re-bound to the FilesystemAdapter before it runs,
so the translation is captured before registering rather than called as
$this->

`provider` is validated with 'sometimes', not 'required': absent means
S3, which is what every payload written before this choice meant, and
stops a browser holding a stale bundle from failing to save on a field
it cannot see.

Verified in a browser as well as in tests — which is how the null
provider on an unmigrated row was found, since the suite migrates and
never sees that state.
2026-08-24 16:38:13 -03:00
ignacionelson 57540164fa Read a file from the disk it is actually on, everywhere
Two routes still assumed every file sits on local disk, which stopped
being true the moment external storage was switched on. A share link
answered with X-Accel-Redirect whatever the file's disk said, pointing
nginx at a path it has nothing behind; a public listing built a
thumbnail from Storage::disk('files')->path(), which for an externally
stored file is a path nobody ever wrote. Both fail only for installs
using S3, and only on those two routes, so the same file downloading
correctly from the file manager made the share link look like the
broken thing rather than where the file lives.

Neither is a new rule. FileDownloadController and
FileThumbnailController already did it right, which is the actual
finding: the knowledge was sitting in a private method on one class and
inline in another, so the next caller could not inherit it and did not.
Both are now objects with one job.

StoredFileResponse replaces InlineFileResponse and grows an
attachment() alongside inline(), since the two differ only by
disposition. LocalSourceFile takes a closure rather than returning a
path: the version that returned one also left the caller to unlink it,
and both of those are exactly the mistakes made here.

The regression tests fail against the previous controllers — checked in
both directions rather than assumed.
2026-08-24 16:24:39 -03:00
ignacionelson 457fed0c86 Stop spending CI time on checks that check nothing
The tests job spent 191 of its 270 seconds running the suite one process
at a time; --parallel runs the same 1763 tests across the runner's cores
with nothing skipped. paratest is already a dev dependency.

The linter job was worse: 150 of its 195 seconds went to `pint` with no
--test and its auto-commit step commented out, so it reformatted the
runner's checkout, exited 0 and threw the result away. `npm run format`
is `prettier --write` and did the same. Both are gone, with a note on
what reinstating them as real gates would take -- a formatting sweep
first, then the flag. What remains is eslint, now read-only so it can
actually fail, and the job no longer needs PHP at all.

Both workflows now cancel superseded runs, and neither runs for a change
that only touches prose nobody's code reads. CHANGELOG.md and docs/ are
deliberately absent from that list: ReleaseNotes parses one and two
controllers serve the other.
2026-08-23 23:59:53 -03:00
ignacionelson 4f38c9adee Show one confirmation toast, not two
Every page wraps itself in AppLayout, so a flashed redirect that lands on
a different page component tears the layout down and builds it again --
Toaster with it. The fresh Toaster then reads the flash at mount *and*
catches the router success event for the same visit, and every "Client
created." arrived twice. Saves that stay on the same component never
remount, which is why this survived unnoticed.

Deduping on the flash object's identity rather than its text is what
keeps the success listener doing its job: two genuine identical messages
in a row are separate objects and still both toast.

Verified in a real browser rather than by types: create a client, two
toasts before, one after, and two consecutive creates over SPA
navigation still toast once each.

Reported and diagnosed by @denkfabrik-li in #1675.
2026-08-23 23:44:07 -03:00
Ignacio Nelson f7d6fe929e Merge pull request #1676 from denkfabrik-li/fix/social-connect-inertia-location
Send the browser to the provider, not the XHR
2026-08-23 23:39:44 -03:00
ignacionelson 1030f719fc Record the connect-a-provider fix in the changelog
The Connect button on Settings → Connected accounts did nothing at all,
which is the kind of thing somebody upgrading needs to see written down.

Found and fixed by @denkfabrik-li in #1676.
2026-08-23 23:28:42 -03:00
denkfabrik-li fbd6c3603d Add tests for the connect redirect navigation 2026-08-23 22:15:36 +02:00
denkfabrik-li 5bfc5a0883 Send the browser to the provider, not the XHR 2026-08-23 22:13:36 +02:00
Eliana Bracciaforte 94e4aa36e4 Merge pull request #1674 from projectsend/fix/trusted-proxies-read-from-env
Read TRUSTED_PROXIES late enough for it to be seen
2026-08-22 15:56:31 -03:00
48 changed files with 2175 additions and 282 deletions
+45 -25
View File
@@ -5,13 +5,33 @@ on:
branches:
- develop
- main
paths:
# Only the frontend is actually checked here, so only the frontend
# needs to trigger it.
- 'resources/**'
- 'package.json'
- 'package-lock.json'
- 'eslint.config.js'
- '.prettierrc*'
- 'tsconfig.json'
- '.github/workflows/lint.yml'
pull_request:
branches:
- develop
- main
paths:
- 'resources/**'
- 'package.json'
- 'package-lock.json'
- 'eslint.config.js'
- '.prettierrc*'
- 'tsconfig.json'
- '.github/workflows/lint.yml'
permissions:
contents: write
# A second push supersedes the first.
concurrency:
group: linter-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
@@ -19,32 +39,32 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
- uses: actions/setup-node@v4
with:
php-version: '8.4'
node-version: '22'
cache: 'npm'
# community-modules resolves from its public GitHub repository (the vcs
# entry in composer.json); cloud-modules is not required. COMPOSER_AUTH
# just lifts the anonymous GitHub API rate limit for the fetch.
- name: Install Dependencies
env:
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GITHUB_TOKEN }}"}}'
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
npm install
- name: Run Pint
run: vendor/bin/pint
- name: Format Frontend
run: npm run format
run: npm ci
# `eslint .` rather than `npm run lint`, which is `eslint . --fix`:
# a formatter that rewrites the checkout and throws the result away
# cannot fail a build, so it was never a gate. This one is.
- name: Lint Frontend
run: npm run lint
run: npx eslint .
# - name: Commit Changes
# uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: fix code style
# commit_options: '--no-verify'
# Two steps used to live here and were removed on 2026-08-23, because
# neither could ever fail:
#
# - `vendor/bin/pint`, without `--test` and with the auto-commit step
# commented out. It reformatted the runner's checkout, exited 0 and
# threw the result away — 150 seconds of this job's 195, gating
# nothing. Reinstating it as a real gate means `pint --test`, which
# today reports around a hundred pre-existing failures; the honest
# order is a formatting sweep first, then the flag.
#
# - `npm run format`, which is `prettier --write`, for the same reason.
# `prettier --check` currently reports 44 files, so the same applies:
# sweep, then switch. `npm run format:check` is the command.
#
# Dropping them also let the PHP toolchain go: nothing left here needs it.
+53 -1
View File
@@ -5,10 +5,50 @@ on:
branches:
- develop
- main
paths-ignore:
# Files no code reads and no test covers. Deliberately NOT listed:
# CHANGELOG.md, which ReleaseNotes parses and ReleaseNotesTest
# covers, and docs/, whose only two tracked files are served by
# ApiDocsController and OpenApiController. A malformed edit to
# either is exactly the thing that must not skip the suite.
#
# Repeated verbatim under pull_request: GitHub Actions does not
# support YAML anchors.
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'LICENSING.md'
- 'CLA-ENTITY.md'
- 'CLA-INDIVIDUAL.md'
- 'INSTALL.md'
- 'UPDATE.md'
- 'DOCKER.md'
- 'MIGRATING-FROM-V1.md'
- 'docker/production/dockerhub-overview.md'
- '.github/screenshots/**'
pull_request:
branches:
- develop
- main
paths-ignore:
- 'README.md'
- 'CONTRIBUTING.md'
- 'SECURITY.md'
- 'LICENSING.md'
- 'CLA-ENTITY.md'
- 'CLA-INDIVIDUAL.md'
- 'INSTALL.md'
- 'UPDATE.md'
- 'DOCKER.md'
- 'MIGRATING-FROM-V1.md'
- 'docker/production/dockerhub-overview.md'
- '.github/screenshots/**'
# A second push supersedes the first: there is no value in finishing a run
# for a commit nobody will look at again.
concurrency:
group: tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
@@ -87,5 +127,17 @@ jobs:
- name: Static Analysis
run: ./vendor/bin/phpstan analyse --no-progress
# `--parallel` rather than a shorter suite. One process took 191s of
# this job's 4m30s; the same 1763 tests across the runner's cores
# take about a third of that, with nothing skipped. paratest is
# already a dev dependency (via Pest), so this needs no new install.
#
# `:memory:` explicitly: parallel testing gives each process its own
# database, and an in-memory one per process is what the suite is
# verified against locally. The job-level DB_DATABASE above is a file
# path, which parallel workers would have to create and migrate
# individually — a difference in behaviour with nothing to gain.
- name: Tests
run: ./vendor/bin/pest
run: ./vendor/bin/pest --parallel
env:
DB_DATABASE: ':memory:'
+49
View File
@@ -13,8 +13,57 @@ Anything under **Upgrade notes** is something you have to do, not something we d
This section collects changes as they land; the release process turns it into a numbered entry when
a version is cut.
### Added
- **Google Cloud Storage as a storage backend.** External storage used to mean S3 and nothing else.
The Storage settings screen now asks which provider you are using first, and offers Google Cloud
Storage alongside the S3-compatible option: choose it, paste a service account key with read and
write access to your bucket, and new uploads go there. The key is stored encrypted and never shown
again, and **Test connection** checks it can actually reach the bucket before you switch anything
over — using a probe that works with a least-privilege key, rather than one that needs permission
to read the bucket's own settings. Downloads and previews are handed to the visitor as a
short-lived signed link, exactly as they already were for S3.
Nothing changes for an existing installation. Configurations saved before this release are S3, are
still S3, and are not asked to say so. Files already stored stay where they are — the setting
applies to new uploads, and there is still no migration between backends.
### Fixed
- **An upload that cannot be stored now fails instead of disappearing.** When files are kept in
object storage and the storage backend refuses a write — an expired key, a bucket that has been
renamed or removed, a permission that changed underneath you — the upload used to report success
and record the file anyway. The entry appeared in the file list, and the download it promised was
never going to work, because the bytes had gone nowhere. The upload now stops and says so, and no
file is recorded. Installations keeping files on local disk were never affected.
- **Downloads and thumbnails for installations using external storage.** Two places assumed every
file sat on the server's own disk, which stopped being true the moment S3-compatible storage was
switched on. A share link to a file held in a bucket produced a broken download, and a public
listing could not draw a thumbnail for one at all — while the same file downloaded and previewed
correctly everywhere else, which made it look like the share link or the listing was at fault
rather than where the file lived. Both now read the file from wherever it actually is. Nothing
changes for installations keeping files on local disk, which is most of them.
- **One confirmation message instead of two.** Saving a new client, system user or role showed the
same green "Client created." twice, stacked. So did deleting one. It was only ever cosmetic —
nothing happened twice — but it read as though something had, which is the last thing a
confirmation should do. Saves that stay on the same screen, such as the email settings, were never
affected.
([#1675](https://github.com/projectsend/projectsend/issues/1675), reported and diagnosed by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **Connecting a provider to an account that already has one.** Signing in with Google, Microsoft or
a custom provider worked, but attaching one to an existing account did not: the **Connect** button
on Settings → Connected accounts appeared to do nothing at all. The button asks the server in the
background, and the server answered by redirecting to the provider — a redirect a browser will not
follow out of a background request to another site. The page sat there with no consent screen and
no error to explain it, so the only reading available was that the button was dead. The server now
tells the browser to go to the provider itself, and the flow starts as it should. Signing in from
the login page was never affected, and neither is it now.
([#1676](https://github.com/projectsend/projectsend/pull/1676), found and fixed by
[@denkfabrik-li](https://github.com/denkfabrik-li))
- **Downloads on a host where the web server is not PHP's user.** A download is not served by PHP:
PHP checks permissions and then hands the web server the path to stream. Where the two run as
different users — cPanel and Plesk commonly arrange it that way — the web server could not open
+15 -3
View File
@@ -84,7 +84,7 @@ Two ways out, if nginx really is impossible on your hosting:
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
([#1664](https://github.com/projectsend/projectsend/issues/1664))
- Store your files in S3-compatible object storage instead (see
- Store your files in object storage instead — S3-compatible or Google Cloud Storage (see
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
to the storage provider and the web server is not involved at all. This is a genuine, supported
@@ -447,8 +447,20 @@ the worker afterwards.
### Storing files somewhere other than this server
Out of the box, uploads live in `storage/app/files/` on this machine. You can point ProjectSend at
S3-compatible object storage instead from **System → Settings → Storage** — useful when the files
outgrow the server's disk.
object storage instead from **System → Settings → Storage** — useful when the files outgrow the
server's disk.
Two backends are offered. **S3-compatible** covers AWS S3 and everything speaking that API: MinIO,
Backblaze B2, Wasabi, DigitalOcean Spaces. Leave the endpoint blank for AWS itself, or set it to the
service's own address and turn on path-style addressing, which most of them need. **Google Cloud
Storage** takes a service account key with read and write access to the bucket, pasted in as the JSON
file Google issues; it is stored encrypted and never shown again.
Whichever you choose, use **Test connection** before switching uploads over — it checks the
credentials actually reach the bucket, rather than leaving you to find out at the first upload.
The setting applies to new uploads. Files already on local disk stay there and keep working, and
there is no migration between backends.
### Making it faster
+1 -1
View File
@@ -47,7 +47,7 @@ per-seat pricing. It runs on your server, and the files stay there.
- 16 languages
- A REST API with scoped tokens and generated OpenAPI docs
- Privacy controls, including GDPR-grade account erasure with a grace period
- Local disk or S3-compatible storage
- Local disk, S3-compatible storage, or Google Cloud Storage
## Screenshots
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Delivery;
use App\Modules\Files\Models\File;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
/**
* A stored file's own bytes, served to be looked at rather than saved.
*
* The two preview endpoints — FileThumbnailController::preview for
* someone signed in, PublicGroupsController::preview for a visitor —
* reach this after they have each authorized in their own way. It
* authorizes nothing itself; it only knows how to put bytes on the wire
* for whichever disk the file lives on.
*
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
* touches the bytes. That matters more here than it does for a download,
* because a <video> seeking through an hour of footage issues a long tail
* of Range requests; nginx's static handler answers those with 206s on
* its own, and drops the Content-Length below in favour of the range it
* actually served. Anything else — S3 and friends — gets a short-lived
* presigned URL carrying an inline disposition, which the object store
* ranges just as well.
*
* Callers must have established that the mime type is inline-safe first;
* PreviewKind is the allowlist, and the reason there is one.
*/
class InlineFileResponse
{
public function make(File $file): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addHour(),
['ResponseContentDisposition' => ContentDisposition::inline($file->original_name)],
);
return redirect()->away($url);
}
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::inline($file->original_name),
'Content-Length' => (string) $file->size,
]);
}
}
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Delivery;
use App\Modules\Files\Models\File;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
/**
* A stored file's own bytes, put on the wire for whichever disk it lives
* on.
*
* Every route that hands over a file reaches this after authorizing in
* its own way — a policy, a share token, a public-listing check. It
* authorizes nothing itself, and deliberately knows nothing about who is
* asking. The one thing it knows is the thing each caller kept getting
* wrong on its own: that `$file->disk` decides how the bytes travel.
*
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
* touches the bytes. Anything else — S3, GCS and friends — gets a
* short-lived presigned URL carrying the disposition, which an object
* store ranges just as well.
*
* That distinction matters most for inline(): a <video> seeking through
* an hour of footage issues a long tail of Range requests, and nginx's
* static handler answers those with 206s on its own, dropping the
* Content-Length below in favour of the range it actually served.
*
* Callers of inline() must have established that the mime type is
* inline-safe first; PreviewKind is the allowlist, and the reason there
* is one.
*/
class StoredFileResponse
{
/** Shown in place — a preview. */
public function inline(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::inline($file->original_name));
}
/** Handed over — a download. */
public function attachment(File $file): Response|RedirectResponse
{
return $this->make($file, ContentDisposition::attachment($file->original_name));
}
private function make(File $file, string $disposition): Response|RedirectResponse
{
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addHour(),
['ResponseContentDisposition' => $disposition],
);
return redirect()->away($url);
}
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => $disposition,
'Content-Length' => (string) $file->size,
]);
}
}
@@ -8,28 +8,26 @@ use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\File;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
/**
* Authorized downloads without the bytes ever traversing PHP: for a file
* on the local disk, the app checks the policy and answers with
* X-Accel-Redirect; nginx streams the file from the protected location
* (brief §3). The cloud edition swaps this for presigned URLs behind the
* same route. A file on the community-only external storage disk already
* gets exactly that — a presigned URL redirect — since nginx has no way
* to serve bytes it doesn't have on disk.
* Authorized downloads without the bytes ever traversing PHP: the app
* checks the policy, and StoredFileResponse answers with either an
* X-Accel-Redirect for nginx to stream from the protected location
* (brief §3) or a presigned URL when the file lives on external storage,
* since nginx has no way to serve bytes it doesn't have on disk.
*/
class FileDownloadController extends Controller
{
public function __construct(
private readonly ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
) {}
public function __invoke(Request $request, File $file): Response|RedirectResponse
@@ -44,21 +42,6 @@ class FileDownloadController extends Controller
$this->activity->log(Action::FileDownloaded, subject: $file);
if ($file->disk !== 'files') {
$url = Storage::disk($file->disk)->temporaryUrl(
$file->path,
now()->addHour(),
['ResponseContentDisposition' => ContentDisposition::attachment($file->original_name)],
);
return redirect()->away($url);
}
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
'Content-Length' => (string) $file->size,
]);
return $this->bytes->attachment($file);
}
}
@@ -8,12 +8,13 @@ use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\InlineFileResponse;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\File;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
use App\Modules\Files\Thumbnails\ImageAudience;
use App\Modules\Files\Thumbnails\ImageRendition;
use App\Modules\Files\Thumbnails\LocalSourceFile;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
@@ -73,7 +74,8 @@ class FileThumbnailController extends Controller
private readonly ThumbnailGenerator $thumbnails,
private readonly ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly InlineFileResponse $inline,
private readonly StoredFileResponse $bytes,
private readonly LocalSourceFile $source,
private readonly Settings $settings,
) {}
@@ -159,7 +161,7 @@ class FileThumbnailController extends Controller
}
}
return $this->inline->make($file);
return $this->bytes->inline($file);
}
/**
@@ -205,15 +207,14 @@ class FileThumbnailController extends Controller
}
$disk->makeDirectory(dirname($path));
$sourcePath = $this->localSourcePathFor($file);
try {
$this->thumbnails->generate($sourcePath, $disk->path($path), $file->mime_type, $audience, $rendition);
} finally {
if ($file->disk !== 'files') {
@unlink($sourcePath);
}
}
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
$sourcePath,
$disk->path($path),
$file->mime_type,
$audience,
$rendition,
));
return $path;
}
@@ -226,38 +227,4 @@ class FileThumbnailController extends Controller
'Content-Disposition' => ContentDisposition::inline($file->original_name),
]);
}
/**
* A local-disk file's real path (fast path). Anything else is
* stream-copied to a temp file first — the caller unlinks it once
* rendering is done.
*/
private function localSourcePathFor(File $file): string
{
if ($file->disk === 'files') {
return Storage::disk('files')->path($file->path);
}
$tempPath = tempnam(sys_get_temp_dir(), 'thumb-src-');
if ($tempPath === false) {
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
}
$stream = Storage::disk($file->disk)->readStream($file->path);
$out = fopen($tempPath, 'wb');
if ($stream === null || $out === false) {
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
}
stream_copy_to_stream($stream, $out);
fclose($out);
if (is_resource($stream)) {
fclose($stream);
}
return $tempPath;
}
}
@@ -8,10 +8,10 @@ use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Inertia\Inertia;
@@ -28,6 +28,7 @@ class PublicShareController extends Controller
public function __construct(
private readonly ActivityLogger $activity,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
) {}
public function show(string $token): InertiaResponse
@@ -101,11 +102,6 @@ class PublicShareController extends Controller
$this->activity->log(Action::ShareLinkDownloaded, subject: $file);
return response('', 200, [
'X-Accel-Redirect' => '/protected-files/'.$file->path,
'Content-Type' => $file->mime_type,
'Content-Disposition' => ContentDisposition::attachment($file->original_name),
'Content-Length' => (string) $file->size,
]);
return $this->bytes->attachment($file);
}
}
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Thumbnails;
use App\Modules\Files\Models\File;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
/**
* A real path on this machine for a stored file, so that something which
* can only work on local bytes — image and video rendering, all of which
* shells out or hands a path to a C library — can work on any file
* whatever disk it lives on.
*
* A local file is used where it lies. Anything else is stream-copied to a
* temp file and removed afterwards.
*
* The callback shape is the point. This started as a private method on
* one controller that returned a path and left the caller to unlink it,
* and the second place that needed it did not call it at all — it passed
* the *local* disk's path() for a file on external storage, which is a
* path that does not exist, so every public-listing thumbnail of an
* externally stored file failed. Handing back a path is an invitation to
* both of those mistakes; a closure that owns the lifetime is not.
*/
class LocalSourceFile
{
/**
* @template TReturn
*
* @param callable(string): TReturn $work
* @return TReturn
*/
public function use(File $file, callable $work): mixed
{
if ($file->disk === 'files') {
return $work(Storage::disk('files')->path($file->path));
}
$tempPath = tempnam(sys_get_temp_dir(), 'thumb-src-');
if ($tempPath === false) {
throw new RuntimeException('Could not create a temp file for '.$file->original_name);
}
try {
$this->copyDown($file, $tempPath);
return $work($tempPath);
} finally {
@unlink($tempPath);
}
}
private function copyDown(File $file, string $tempPath): void
{
$stream = Storage::disk($file->disk)->readStream($file->path);
$out = fopen($tempPath, 'wb');
if ($stream === null || $out === false) {
if (is_resource($out)) {
fclose($out);
}
throw new RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
}
try {
stream_copy_to_stream($stream, $out);
} finally {
fclose($out);
if (is_resource($stream)) {
fclose($stream);
}
}
}
}
+30 -1
View File
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Uploads;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File as FileSystem;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use RuntimeException;
@@ -203,12 +204,40 @@ class LocalPartStore
Event::dispatch($diskEvent);
$disk = $diskEvent->disk;
Storage::disk($disk)->writeStream($targetPath, $readStream);
$written = Storage::disk($disk)->writeStream($targetPath, $readStream);
if (is_resource($readStream)) {
fclose($readStream);
}
// The disks are configured with 'throw' => false, so a refused
// write is a `false` return rather than an exception — and the
// caller goes on to record a File row for bytes that were never
// stored. Losing an upload silently is worse than failing it, and
// this is the only place that can tell the difference: a real
// instance of it was a GCS bucket rejecting the adapter's ACL,
// which looked exactly like a successful upload.
if ($written === false) {
// The reason is lost by the time it gets here — 'throw' => false
// means Flysystem swallowed the exception rather than passing it
// on — so log what was attempted. Which bucket it was is the
// difference between reading this as "my credentials expired"
// and "I typed the wrong bucket name", and only the log can say
// it: the message below is shown to whoever was uploading, which
// includes clients, and a bucket name is not theirs to see.
Log::error('Upload could not be written to storage.', [
'disk' => $disk,
'bucket' => config('filesystems.disks.'.$disk.'.bucket'),
'driver' => config('filesystems.disks.'.$disk.'.driver'),
'path' => $targetPath,
]);
throw new RuntimeException(
'Could not write the assembled upload to the "'.$disk.'" disk. '
.'Check the storage backend is reachable and its credentials are still valid.'
);
}
$this->abort($session);
return [
@@ -9,13 +9,14 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Comments\CommentingRules;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\InlineFileResponse;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Thumbnails\ImageAudience;
use App\Modules\Files\Thumbnails\ImageRendition;
use App\Modules\Files\Thumbnails\LocalSourceFile;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
use App\Modules\Files\Versions\FileVersionLinks;
use App\Modules\Groups\Http\Controllers\Concerns\InteractsWithPublicListing;
@@ -81,7 +82,8 @@ class PublicGroupsController extends Controller
private readonly PublicThemeRegistry $themes,
private readonly CapabilityRegistry $capabilities,
private readonly CommentingRules $commenting,
private readonly InlineFileResponse $inline,
private readonly StoredFileResponse $bytes,
private readonly LocalSourceFile $source,
) {}
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
@@ -251,7 +253,18 @@ class PublicGroupsController extends Controller
if (! $disk->exists($thumbnailPath)) {
$disk->makeDirectory(dirname($thumbnailPath));
$this->thumbnails->generate($disk->path($file->path), $disk->path($thumbnailPath), $file->mime_type, ImageAudience::External, ImageRendition::Thumbnail);
// Never $disk->path($file->path): the rendition is cached on
// the local disk, but the *source* lives on whichever disk the
// file was uploaded to, and a local path for an externally
// stored file is a path that does not exist.
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
$sourcePath,
$disk->path($thumbnailPath),
$file->mime_type,
ImageAudience::External,
ImageRendition::Thumbnail,
));
}
return response('', 200, [
@@ -287,7 +300,7 @@ class PublicGroupsController extends Controller
$this->activity->log(Action::PublicFilePreviewed, subject: $file);
return $this->inline->make($file);
return $this->bytes->inline($file);
}
/**
@@ -15,7 +15,8 @@ use App\Modules\Identity\Social\SocialProvider;
use App\Modules\Identity\Social\SocialSettings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
@@ -57,13 +58,13 @@ class SocialLoginController extends Controller
}
/** Begin a sign-in. */
public function redirect(Request $request, string $provider): SymfonyRedirectResponse|RedirectResponse
public function redirect(Request $request, string $provider): Response
{
return $this->begin($request, $provider, 'login');
}
/** Begin connecting a provider to the signed-in account. */
public function connect(Request $request, string $provider): SymfonyRedirectResponse|RedirectResponse
public function connect(Request $request, string $provider): Response
{
return $this->begin($request, $provider, 'link');
}
@@ -130,7 +131,7 @@ class SocialLoginController extends Controller
return redirect()->intended(route('dashboard', absolute: false));
}
private function begin(Request $request, string $provider, string $intent): SymfonyRedirectResponse|RedirectResponse
private function begin(Request $request, string $provider, string $intent): Response
{
$case = $this->provider($provider);
$settings = SocialSettings::for($case);
@@ -142,7 +143,13 @@ class SocialLoginController extends Controller
$request->session()->put([self::INTENT => $intent, self::PROVIDER => $case->value]);
return $this->gateway()->redirect($settings);
// Inertia::location(), not the redirect itself. Connecting starts
// as an Inertia XHR from the settings screen, and an XHR follows a
// 302 to the provider cross-origin, where CORS kills it before the
// person ever leaves the page. The 409 + X-Inertia-Location pair
// makes the client navigate top-level instead; a plain browser
// request — the login flow — passes through unchanged.
return Inertia::location($this->gateway()->redirect($settings));
}
private function completeLink(Request $request, SocialSettings $settings, SocialIdentity $identity): RedirectResponse
@@ -43,6 +43,15 @@ enum Capability: string
// package (github.com/projectsend/cloud-modules), never in this repo.
case Branding = 'branding.customize';
// Cloud-only — the storage backend is ours, supplied by the
// environment when the instance is provisioned and not the customer's
// to see or change. The counterpart of StorageConfigure above rather
// than a contradiction of it: one edition configures its own bucket,
// the other is given one. Behaviour lives in the private
// projectsend/cloud-modules package; without it this capability is
// simply inert and files stay on local disk.
case StorageManaged = 'storage.managed';
// Cloud-only — managed installations supply CAPTCHA keys centrally, so
// protection is on before anybody finds the settings screen. The
// feature itself is in both editions and behind no capability: this
@@ -64,6 +73,7 @@ enum Capability: string
self::CustomAssets => [Edition::Community],
self::Branding,
self::StorageManaged,
self::CaptchaManagedKeys => [Edition::Cloud],
};
}
@@ -9,12 +9,17 @@ use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLogger;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
use App\Modules\Platform\Settings\StorageProvider;
use Aws\S3\S3Client;
use Closure;
use Google\Cloud\Storage\StorageClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
use Throwable;
/**
@@ -37,6 +42,7 @@ class ExternalStorageSettingsController extends Controller
return Inertia::render('system/settings/storage', [
'active' => $settings->active,
'provider' => $settings->provider->value,
// Never name a top-level Inertia prop "key" — Inertia's React
// renderer spreads page props onto the component via
// `{ key: <internal-remount-key>, ...props }`, and a prop
@@ -45,6 +51,10 @@ class ExternalStorageSettingsController extends Controller
// the component as an actual prop (React always strips `key`).
'access_key' => $settings->key ?? '',
'has_secret' => $settings->secret !== null && $settings->secret !== '',
// Same treatment as the secret: never round-tripped, only
// whether one is stored. A service account key file is more
// sensitive than an access key, not less.
'has_key_file' => $settings->key_file !== null && $settings->key_file !== '',
'bucket' => $settings->bucket ?? '',
'region' => $settings->region ?? '',
'endpoint' => $settings->endpoint ?? '',
@@ -56,35 +66,56 @@ class ExternalStorageSettingsController extends Controller
public function update(Request $request): RedirectResponse
{
$request->merge(['provider' => $request->input('provider', StorageProvider::S3->value)]);
$validated = $request->validate([
'active' => ['required', 'boolean'],
'access_key' => ['required', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
// 'sometimes', not 'required': absent means S3, which is what
// every payload written before this choice existed meant, and
// stops a browser holding a stale bundle from failing to save
// on a field it cannot see.
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
'bucket' => ['required', 'string', 'max:255'],
'region' => ['required', 'string', 'max:255'],
'root' => ['nullable', 'string', 'max:255'],
// Required only for the provider that uses them, so switching
// to GCS does not demand an AWS region that means nothing.
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'endpoint' => ['nullable', 'string', 'max:255'],
'use_path_style' => ['required', 'boolean'],
'root' => ['nullable', 'string', 'max:255'],
// Checked for shape here rather than left to fail at the first
// upload: a key file is pasted, and a paste that lost its last
// line is the likeliest way this goes wrong.
'key_file' => ['nullable', 'string', self::serviceAccountKeyRule()],
]);
$settings = ExternalStorageSettings::current();
$settings->fill([
'active' => $validated['active'],
'key' => $validated['access_key'],
'provider' => $validated['provider'],
'key' => $validated['access_key'] ?? null,
'bucket' => $validated['bucket'],
'region' => $validated['region'],
'region' => $validated['region'] ?? null,
'endpoint' => $validated['endpoint'] ?? null,
'use_path_style' => $validated['use_path_style'],
'root' => $validated['root'] ?? null,
]);
// A blank secret keeps whatever is already stored — the field is
// never round-tripped to the browser (only `has_secret` is).
// A blank credential keeps whatever is already stored — neither
// field is ever round-tripped to the browser (only the has_*
// flags are), so blank means "unchanged", not "cleared".
if (is_string($validated['secret'] ?? null) && $validated['secret'] !== '') {
$settings->secret = $validated['secret'];
}
if (is_string($validated['key_file'] ?? null) && $validated['key_file'] !== '') {
$settings->key_file = $validated['key_file'];
}
$settings->save();
$this->configApplier->flush();
@@ -101,43 +132,31 @@ class ExternalStorageSettingsController extends Controller
}
/**
* Verifies the submitted (or, if the secret field was left blank, the
* already-stored) credentials can actually reach the bucket, mirroring
* v1's connection test — this exists specifically to catch a typo'd
* key/bucket/region before switching uploads over to it.
* Verifies the submitted (or, where a credential field was left
* blank, the already-stored) details can actually reach the bucket,
* mirroring v1's connection test — this exists specifically to catch
* a typo'd key/bucket/region before switching uploads over to it.
*/
public function testConnection(Request $request): RedirectResponse
{
$request->merge(['provider' => $request->input('provider', StorageProvider::S3->value)]);
$validated = $request->validate([
'access_key' => ['required', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
'provider' => ['sometimes', Rule::enum(StorageProvider::class)],
'bucket' => ['required', 'string', 'max:255'],
'region' => ['required', 'string', 'max:255'],
'access_key' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'secret' => ['nullable', 'string', 'max:255'],
'region' => ['required_if:provider,s3', 'nullable', 'string', 'max:255'],
'endpoint' => ['nullable', 'string', 'max:255'],
'use_path_style' => ['nullable', 'boolean'],
'key_file' => ['nullable', 'string', self::serviceAccountKeyRule()],
]);
$settings = ExternalStorageSettings::current();
$secret = (is_string($validated['secret'] ?? null) && $validated['secret'] !== '')
? $validated['secret']
: $settings->secret;
try {
$config = [
'version' => 'latest',
'region' => $validated['region'],
'credentials' => [
'key' => $validated['access_key'],
'secret' => (string) $secret,
],
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
];
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
$config['endpoint'] = $validated['endpoint'];
}
(new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]);
match (StorageProvider::from($validated['provider'])) {
StorageProvider::S3 => $this->probeS3($validated),
StorageProvider::Gcs => $this->probeGcs($validated),
};
$result = __('Success: connected to bucket ":bucket".', ['bucket' => $validated['bucket']]);
} catch (Throwable $e) {
@@ -146,4 +165,91 @@ class ExternalStorageSettingsController extends Controller
return back()->with('storage_test_result', $result);
}
/**
* @param array<string, mixed> $validated
*/
private function probeS3(array $validated): void
{
$config = [
'version' => 'latest',
'region' => $validated['region'],
'credentials' => [
'key' => $validated['access_key'],
'secret' => (string) $this->storedIfBlank($validated, 'secret'),
],
'use_path_style_endpoint' => (bool) ($validated['use_path_style'] ?? false),
];
if (is_string($validated['endpoint'] ?? null) && $validated['endpoint'] !== '') {
$config['endpoint'] = $validated['endpoint'];
}
(new S3Client($config))->headBucket(['Bucket' => $validated['bucket']]);
}
/**
* @param array<string, mixed> $validated
*/
private function probeGcs(array $validated): void
{
$keyFile = json_decode((string) $this->storedIfBlank($validated, 'key_file'), true);
if (! is_array($keyFile)) {
throw new RuntimeException(__('No service account key has been saved yet.'));
}
$bucket = (new StorageClient(['keyFile' => $keyFile]))->bucket($validated['bucket']);
// Listing one object rather than asking whether the bucket exists.
// A least-privilege key — roles/storage.objectAdmin scoped to this
// bucket, which is what the whole design rests on — can read and
// write objects but cannot read the bucket's own metadata, so
// $bucket->exists() reports failure for a key that works perfectly.
// An empty bucket is a valid answer here, and returns no rows.
iterator_to_array($bucket->objects(['maxResults' => 1]), false);
}
/**
* A credential field left blank means "keep what is stored" on save,
* so the connection test has to read it the same way — otherwise
* testing an unchanged configuration would always fail.
*
* @param array<string, mixed> $validated
*/
private function storedIfBlank(array $validated, string $field): ?string
{
$submitted = $validated[$field] ?? null;
if (is_string($submitted) && $submitted !== '') {
return $submitted;
}
return ExternalStorageSettings::current()->{$field};
}
/**
* A pasted service account key, checked for the parts that have to be
* there. Not a credential check — that is what Test connection is for.
*/
private static function serviceAccountKeyRule(): Closure
{
return function (string $attribute, mixed $value, Closure $fail): void {
$decoded = json_decode((string) $value, true);
if (! is_array($decoded)) {
$fail(__('That does not look like a service account key file: it is not valid JSON.'));
return;
}
foreach (['client_email', 'private_key'] as $required) {
if (! isset($decoded[$required]) || ! is_string($decoded[$required]) || $decoded[$required] === '') {
$fail(__('That service account key file is missing its :field.', ['field' => $required]));
return;
}
}
};
}
}
@@ -15,14 +15,15 @@ use App\Modules\Platform\Localization\LocaleRegistry;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\News\Console\FetchNewsCommand;
use App\Modules\Platform\Notifications\ThemedMailChannel;
use App\Modules\Platform\Scheduling\Console\PurgeFailedJobsCommand;
use App\Modules\Platform\Scheduling\RecordsScheduledTaskRuns;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\MailConfigApplier;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Storage\GoogleCloudStorageDriver;
use App\Modules\Platform\Theming\Console\GenerateThemePreviewDataCommand;
use App\Modules\Platform\Theming\EmailThemeRegistry;
use App\Modules\Platform\Theming\PublicThemeRegistry;
use App\Modules\Platform\Scheduling\Console\PurgeFailedJobsCommand;
use App\Modules\Platform\Updates\Console\CheckForUpdatesCommand;
use App\Modules\Platform\Updates\Console\UpdateCommand;
use Illuminate\Console\Events\ScheduledTaskFailed;
@@ -89,6 +90,13 @@ class PlatformServiceProvider extends ServiceProvider
// any — a no-op until the Email settings page is actually saved.
$this->app->make(MailConfigApplier::class)->apply();
// Laravel ships no 'gcs' driver, so the disk config the applier
// is about to write would resolve to nothing without this. Cheap
// and inert on an install that never selects it: extend() only
// records a factory, and nothing calls it until something asks
// for a disk whose driver is 'gcs'.
$this->app->make(GoogleCloudStorageDriver::class)->register();
// Same idea for the admin-configured external storage backend —
// a no-op until the Storage settings page is actually saved. The
// listener is what actually redirects new uploads away from the
@@ -44,7 +44,7 @@ class ExternalStorageConfigApplier
// Bumped on any shape change to the resolved array below — a stale
// rememberForever value under an old key would otherwise crash every
// boot with "Undefined array key" (apply() calls resolve() unconditionally).
private const CACHE_KEY = 'platform.external_storage_settings.v1';
private const CACHE_KEY = 'platform.external_storage_settings.v2';
public function __construct(
private readonly CapabilityRegistry $capabilities,
@@ -57,17 +57,60 @@ class ExternalStorageConfigApplier
}
$resolved = $this->resolve();
$provider = StorageProvider::from($resolved['provider']);
// The driver is part of what gets overwritten, not a constant:
// config/filesystems.php ships the disk as an inert 's3' stub, and
// this is the only thing that ever makes it anything else.
Config::set('filesystems.disks.files_external.driver', $provider->driver());
Config::set('filesystems.disks.files_external.bucket', $resolved['bucket']);
match ($provider) {
StorageProvider::S3 => $this->applyS3($resolved),
StorageProvider::Gcs => $this->applyGcs($resolved),
};
if ($resolved['root'] !== null) {
// Two names for one idea, because the two adapters disagree:
// Laravel's S3 driver reads 'root', Flysystem's GCS adapter is
// constructed with a 'prefix'. Setting both keeps the settings
// screen able to speak of one "folder inside the bucket".
Config::set('filesystems.disks.files_external.root', $resolved['root']);
Config::set('filesystems.disks.files_external.prefix', $resolved['root']);
}
}
/**
* @param array<string, mixed> $resolved
*/
private function applyS3(array $resolved): void
{
Config::set('filesystems.disks.files_external.key', $resolved['key']);
Config::set('filesystems.disks.files_external.secret', $resolved['secret']);
Config::set('filesystems.disks.files_external.region', $resolved['region']);
Config::set('filesystems.disks.files_external.bucket', $resolved['bucket']);
Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']);
Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']);
}
if ($resolved['root'] !== null) {
Config::set('filesystems.disks.files_external.root', $resolved['root']);
}
/**
* @param array<string, mixed> $resolved
*/
private function applyGcs(array $resolved): void
{
// Decoded here rather than stored decoded: the column holds the
// key file verbatim, exactly as Google issued it, so that what an
// administrator pasted is what can be handed back to them and
// compared against the console.
$keyFile = json_decode((string) $resolved['key_file'], true);
Config::set('filesystems.disks.files_external.key_file', is_array($keyFile) ? $keyFile : null);
// Left over from the S3 stub in config/filesystems.php, and
// meaningless to the GCS adapter — cleared rather than left
// sitting there looking like configuration.
Config::set('filesystems.disks.files_external.key', null);
Config::set('filesystems.disks.files_external.secret', null);
Config::set('filesystems.disks.files_external.endpoint', null);
}
public function flush(): void
@@ -99,13 +142,15 @@ class ExternalStorageConfigApplier
* filled in and active, nothing more. Callers AND the capability check
* live and uncached — see class docblock.
*
* @return array{configured: bool, key: string|null, secret: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
* @return array{configured: bool, provider: string, key: string|null, secret: string|null, key_file: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
*/
private function resolve(): array
{
$blank = [
'configured' => false,
'key' => null, 'secret' => null, 'region' => null, 'bucket' => null,
'provider' => StorageProvider::S3->value,
'key' => null, 'secret' => null, 'key_file' => null,
'region' => null, 'bucket' => null,
'endpoint' => null, 'use_path_style' => false, 'root' => null,
];
@@ -126,8 +171,10 @@ class ExternalStorageConfigApplier
return [
'configured' => true,
'provider' => $settings->provider->value,
'key' => $settings->key,
'secret' => $settings->secret,
'key_file' => $settings->key_file,
'region' => $settings->region,
'bucket' => $settings->bucket,
'endpoint' => $settings->endpoint,
@@ -7,16 +7,19 @@ namespace App\Modules\Platform\Settings;
use Illuminate\Database\Eloquent\Model;
/**
* Admin-configured S3-compatible external storage backend, editable from
* the Storage settings page. Single row (id 1 in practice, never
* Admin-configured external storage backend — S3-compatible or Google
* Cloud Storage, see StorageProvider — editable from the Storage
* settings page. Single row (id 1 in practice, never
* enforced) — same reasoning as MailProviderSettings: `secret` needs real
* Eloquent encryption, which the generic settings table can't offer
* per-key.
*
* @property int $id
* @property bool $active
* @property StorageProvider $provider
* @property string|null $key
* @property string|null $secret
* @property string|null $key_file
* @property string|null $bucket
* @property string|null $region
* @property string|null $endpoint
@@ -29,8 +32,10 @@ class ExternalStorageSettings extends Model
protected $fillable = [
'active',
'provider',
'key',
'secret',
'key_file',
'bucket',
'region',
'endpoint',
@@ -38,11 +43,28 @@ class ExternalStorageSettings extends Model
'root',
];
/**
* current() builds this with firstOrNew(), which does not apply the
* column defaults — so on an install that has never opened the
* Storage screen, `provider` would be null and the match in
* isConfigured() would throw rather than answer. Defaults here are
* what make an unsaved row a coherent object.
*
* @var array<string, mixed>
*/
protected $attributes = [
'active' => false,
'provider' => 's3',
'use_path_style' => false,
];
protected function casts(): array
{
return [
'active' => 'boolean',
'provider' => StorageProvider::class,
'secret' => 'encrypted',
'key_file' => 'encrypted',
'use_path_style' => 'boolean',
];
}
@@ -59,9 +81,23 @@ class ExternalStorageSettings extends Model
*/
public function isConfigured(): bool
{
return $this->active
&& $this->key !== null && $this->key !== ''
&& $this->secret !== null && $this->secret !== ''
&& $this->bucket !== null && $this->bucket !== '';
if (! $this->active || ! $this->filled('bucket')) {
return false;
}
// What counts as "filled in" is per provider, because the two
// authenticate with different things entirely: S3 wants a key and
// a secret, GCS wants a service account key file.
return match ($this->provider) {
StorageProvider::S3 => $this->filled('key') && $this->filled('secret'),
StorageProvider::Gcs => $this->filled('key_file'),
};
}
private function filled(string $attribute): bool
{
$value = $this->{$attribute};
return is_string($value) && $value !== '';
}
}
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Settings;
/**
* Which object store the external `files_external` disk is talking to.
*
* Unlike MailProvider, this is not a preset picker over one transport:
* the two cases are genuinely different Flysystem drivers, authenticated
* differently — an access key and secret against an S3 API, a service
* account key against Google's. Which fields the Storage settings screen
* shows, which of them are validated, and what
* ExternalStorageConfigApplier writes into the disk config all follow
* from this.
*
* S3 keeps its endpoint and path-style settings because "S3" here means
* the whole S3-compatible family — AWS itself, MinIO, Backblaze,
* Wasabi, and Google's own interoperability endpoint for anyone who
* would rather use HMAC keys than a service account.
*/
enum StorageProvider: string
{
case S3 = 's3';
case Gcs = 'gcs';
public function label(): string
{
return match ($this) {
self::S3 => 'S3-compatible',
self::Gcs => 'Google Cloud Storage',
};
}
/** The Laravel filesystem driver this provider is served by. */
public function driver(): string
{
return match ($this) {
self::S3 => 's3',
self::Gcs => 'gcs',
};
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Modules\Platform\Storage;
use DateTimeInterface;
use Google\Cloud\Storage\StorageClient;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\Config;
use League\Flysystem\Filesystem;
use League\Flysystem\GoogleCloudStorage\GoogleCloudStorageAdapter;
use League\Flysystem\GoogleCloudStorage\UniformBucketLevelAccessVisibility;
/**
* The `gcs` filesystem driver, which Laravel does not ship.
*
* Three things here are not boilerplate, and all of them are the kind
* that fail quietly rather than loudly.
*
* **Laravel will not find the adapter's own method.** FilesystemAdapter
* ::temporaryUrl() looks for a method named `getTemporaryUrl` on the
* adapter, falls back to a registered callback, and otherwise throws
* "This driver does not support creating temporary URLs". League's
* adapter implements Flysystem's TemporaryUrlGenerator and names the
* method `temporaryUrl`. The names do not meet, so without the
* buildTemporaryUrlsUsing() below every download and every preview of a
* GCS-stored file is a 500.
*
* **The default visibility handler cannot write to a correctly
* configured bucket.** See the constructor argument below: it attaches a
* legacy ACL to every object, and uniform bucket-level access — which the
* setup instructions require — rejects the write outright. Combined with
* `'throw' => false` on the disk, that failure was completely silent.
*
* **The two SDKs spell the signing options differently.** The callers —
* StoredFileResponse, and anything else that hands options to
* temporaryUrl() — speak the AWS vocabulary, because S3 came first and
* one vocabulary is better than two. GCS wants `responseDisposition`
* where S3 says `ResponseContentDisposition`, and an option it does not
* recognise is ignored in silence: no exception, just downloads that
* arrive named after the storage key and previews that download instead
* of displaying. Translating here is what keeps every caller
* provider-agnostic, and keeps the failure from being invisible.
*/
class GoogleCloudStorageDriver
{
/**
* AWS option name => Google option name, for the subset this
* application actually sends. Anything absent is passed through
* untouched, so a caller can still reach a Google-specific option by
* its real name.
*/
private const OPTION_NAMES = [
'ResponseContentDisposition' => 'responseDisposition',
'ResponseContentType' => 'responseType',
];
public function register(): void
{
Storage::extend('gcs', fn ($app, array $config): FilesystemAdapter => $this->make($config));
}
/**
* @param array<string, mixed> $config
*/
public function make(array $config): FilesystemAdapter
{
$client = new StorageClient(array_filter([
// The key file carries its own project_id, so there is
// nothing else to configure. Absent, the client falls back to
// Application Default Credentials — which is how a self-hosted
// install on a Google VM can work with no key at all, at the
// cost of an IAM round trip per signature.
'keyFile' => is_array($config['key_file'] ?? null) ? $config['key_file'] : null,
]));
$adapter = new GoogleCloudStorageAdapter(
$client->bucket((string) ($config['bucket'] ?? '')),
(string) ($config['prefix'] ?? ''),
// Never write a per-object ACL. The adapter's default handler
// attaches one to every object, and a bucket with uniform
// bucket-level access turned on rejects the whole write:
// "Cannot insert legacy ACL for an object when uniform
// bucket-level access is enabled". Uniform access is what the
// setup instructions ask for and what Google recommends, so
// the default handler fails on a correctly configured bucket.
//
// Nothing is lost by never setting one. Every object this
// application stores is private and every read of it is a
// signed URL, so per-object visibility has nothing to say
// here — and on a bucket *without* uniform access, an object
// written with no ACL simply inherits the bucket's defaults,
// which is the same answer.
new UniformBucketLevelAccessVisibility,
);
$disk = new FilesystemAdapter(new Filesystem($adapter), $adapter, $config);
// Bound and captured before registering, not called as
// $this->signingOptions() inside the closure: Laravel re-binds the
// callback to the FilesystemAdapter before invoking it
// (bindTo($this, static::class)), so `$this` in there is the disk,
// not this class, and the call fails at the first download rather
// than here.
$signingOptions = $this->signingOptions(...);
$disk->buildTemporaryUrlsUsing(
fn (string $path, DateTimeInterface $expiration, array $options): string => $adapter->temporaryUrl(
$path,
$expiration,
new Config(['gcp_signing_options' => $signingOptions($options)]),
)
);
return $disk;
}
/**
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
private function signingOptions(array $options): array
{
$translated = [];
foreach ($options as $name => $value) {
$translated[self::OPTION_NAMES[$name] ?? $name] = $value;
}
// V4 explicitly rather than by default: v2 signatures are the
// library's historical default in some paths, they are deprecated,
// and the difference only shows up as a rejected URL at the moment
// somebody tries to download something.
return ['version' => 'v4', ...$translated];
}
}
+1
View File
@@ -20,6 +20,7 @@
"laravel/socialite": "^5.29",
"laravel/tinker": "^2.10.1",
"league/flysystem-aws-s3-v3": "^3.29",
"league/flysystem-google-cloud-storage": "^3.34",
"pragmarx/google2fa": "^9.0",
"projectsend/community-modules": "^1.0",
"stevebauman/purify": "^6.3",
Generated
+650 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8aeea042b113838ea5a2a2dcfb0965c5",
"content-hash": "3d1b9f16a86d22643087b0b575807b6e",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -1168,6 +1168,450 @@
],
"time": "2025-12-03T09:33:47+00:00"
},
{
"name": "google/auth",
"version": "v1.53.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-auth-library-php.git",
"reference": "d677d0b0c4bd52ab222a85df8e74e0d491c0cc5a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-auth-library-php/zipball/d677d0b0c4bd52ab222a85df8e74e0d491c0cc5a",
"reference": "d677d0b0c4bd52ab222a85df8e74e0d491c0cc5a",
"shasum": ""
},
"require": {
"firebase/php-jwt": "^6.0||^7.0",
"guzzlehttp/guzzle": "^7.8.2||^8.0",
"guzzlehttp/psr7": "^2.6.3||^3.0",
"php": "^8.1",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-message": "^1.1||^2.0",
"psr/log": "^2.0||^3.0"
},
"require-dev": {
"guzzlehttp/promises": "^2.0.3||^3.0",
"kelvinmo/simplejwt": "^1.1.0",
"phpseclib/phpseclib": "^3.0.35",
"phpspec/prophecy-phpunit": "^2.1",
"phpunit/phpunit": "^9.6",
"sebastian/comparator": ">=1.2.3",
"squizlabs/php_codesniffer": "^4.0",
"symfony/filesystem": "^6.3||^7.3",
"symfony/process": "^6.0||^7.0",
"webmozart/assert": "^1.11||^2.0"
},
"suggest": {
"phpseclib/phpseclib": "May be used in place of OpenSSL for signing strings or for token management. Please require version ^2."
},
"type": "library",
"autoload": {
"psr-4": {
"Google\\Auth\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google Auth Library for PHP",
"homepage": "https://github.com/google/google-auth-library-php",
"keywords": [
"Authentication",
"google",
"oauth2"
],
"support": {
"docs": "https://cloud.google.com/php/docs/reference/auth/latest",
"issues": "https://github.com/googleapis/google-auth-library-php/issues",
"source": "https://github.com/googleapis/google-auth-library-php/tree/v1.53.0"
},
"time": "2026-07-22T22:36:10+00:00"
},
{
"name": "google/cloud-core",
"version": "v1.73.2",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-cloud-php-core.git",
"reference": "883bc97bdcd5e09552eb82cb47a752271a310c7a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-cloud-php-core/zipball/883bc97bdcd5e09552eb82cb47a752271a310c7a",
"reference": "883bc97bdcd5e09552eb82cb47a752271a310c7a",
"shasum": ""
},
"require": {
"google/auth": "^1.53",
"google/gax": "^1.38.0",
"guzzlehttp/guzzle": "^7.8.2||^8.0",
"guzzlehttp/promises": "^2.0.3||^3.0",
"guzzlehttp/psr7": "^2.6.3||^3.0",
"monolog/monolog": "^2.9||^3.0",
"php": "^8.1",
"psr/http-message": "^1.0||^2.0",
"rize/uri-template": "~0.3||~0.4"
},
"require-dev": {
"erusev/parsedown": "^1.6",
"google/cloud-common-protos": "~0.5||^1.0",
"nikic/php-parser": "^5.6",
"opis/closure": "^3.7|^4.0",
"phpdocumentor/reflection": "^6.0",
"phpdocumentor/reflection-docblock": "^5.3.3||^6.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "3.*"
},
"suggest": {
"opis/closure": "May be used to serialize closures to process jobs in the batch daemon. Please require version ^3.",
"symfony/lock": "Required for the Spanner cached based session pool. Please require the following commit: 3.3.x-dev#1ba6ac9"
},
"bin": [
"bin/google-cloud-batch"
],
"type": "library",
"extra": {
"component": {
"id": "cloud-core",
"path": "Core",
"entry": "src/ServiceBuilder.php",
"target": "googleapis/google-cloud-php-core.git"
}
},
"autoload": {
"psr-4": {
"Google\\Cloud\\Core\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google Cloud PHP shared dependency, providing functionality useful to all components.",
"support": {
"source": "https://github.com/googleapis/google-cloud-php-core/tree/v1.73.2"
},
"time": "2026-08-14T23:32:22+00:00"
},
{
"name": "google/cloud-storage",
"version": "v2.5.2",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-cloud-php-storage.git",
"reference": "1e90d4a1bebd8cef366addce9e57bc4d9ac9c760"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-cloud-php-storage/zipball/1e90d4a1bebd8cef366addce9e57bc4d9ac9c760",
"reference": "1e90d4a1bebd8cef366addce9e57bc4d9ac9c760",
"shasum": ""
},
"require": {
"google/cloud-core": "^1.72.0",
"php": "^8.1",
"ramsey/uuid": "^4.2.3"
},
"require-dev": {
"erusev/parsedown": "^1.6",
"google/cloud-pubsub": "^2.0",
"nikic/php-parser": "^5",
"phpdocumentor/reflection": "^6.0",
"phpdocumentor/reflection-docblock": "^5.3.3",
"phpseclib/phpseclib": "^2.0||^3.0",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.0",
"squizlabs/php_codesniffer": "3.*"
},
"suggest": {
"google/cloud-pubsub": "May be used to register a topic to receive bucket notifications.",
"phpseclib/phpseclib": "May be used in place of OpenSSL for creating signed Cloud Storage URLs. Please require version ^2."
},
"type": "library",
"extra": {
"component": {
"id": "cloud-storage",
"path": "Storage",
"entry": "src/StorageClient.php",
"target": "googleapis/google-cloud-php-storage.git"
}
},
"autoload": {
"psr-4": {
"Google\\Cloud\\Storage\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Cloud Storage Client for PHP",
"support": {
"source": "https://github.com/googleapis/google-cloud-php-storage/tree/v2.5.2"
},
"time": "2026-08-14T23:32:22+00:00"
},
{
"name": "google/common-protos",
"version": "4.14.1",
"source": {
"type": "git",
"url": "https://github.com/googleapis/common-protos-php.git",
"reference": "4eb6813b8068653e055fc8a63dbda3446f3e8869"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/common-protos-php/zipball/4eb6813b8068653e055fc8a63dbda3446f3e8869",
"reference": "4eb6813b8068653e055fc8a63dbda3446f3e8869",
"shasum": ""
},
"require": {
"google/protobuf": "^4.31||^5.0",
"php": "^8.1"
},
"require-dev": {
"phpunit/phpunit": "^9.6"
},
"type": "library",
"extra": {
"component": {
"id": "common-protos",
"path": "CommonProtos",
"entry": "README.md",
"target": "googleapis/common-protos-php.git"
}
},
"autoload": {
"psr-4": {
"Google\\Api\\": "src/Api",
"Google\\Iam\\": "src/Iam",
"Google\\Rpc\\": "src/Rpc",
"Google\\Type\\": "src/Type",
"Google\\Cloud\\": "src/Cloud",
"GPBMetadata\\Google\\Api\\": "metadata/Api",
"GPBMetadata\\Google\\Iam\\": "metadata/Iam",
"GPBMetadata\\Google\\Rpc\\": "metadata/Rpc",
"GPBMetadata\\Google\\Type\\": "metadata/Type",
"GPBMetadata\\Google\\Cloud\\": "metadata/Cloud",
"GPBMetadata\\Google\\Logging\\": "metadata/Logging"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google API Common Protos for PHP",
"homepage": "https://github.com/googleapis/common-protos-php",
"keywords": [
"google"
],
"support": {
"source": "https://github.com/googleapis/common-protos-php/tree/v4.14.1"
},
"time": "2026-06-17T23:07:32+00:00"
},
{
"name": "google/gax",
"version": "v1.48.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/gax-php.git",
"reference": "637096f2c70f6bc903ba24aee3a0d974d739aba8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/gax-php/zipball/637096f2c70f6bc903ba24aee3a0d974d739aba8",
"reference": "637096f2c70f6bc903ba24aee3a0d974d739aba8",
"shasum": ""
},
"require": {
"google/auth": "^1.53",
"google/common-protos": "^4.9",
"google/grpc-gcp": "^0.4",
"google/longrunning": "~0.4",
"google/protobuf": "^4.31||^5.34",
"grpc/grpc": "^1.13",
"guzzlehttp/promises": "^2.0.3||^3.0",
"guzzlehttp/psr7": "^2.6.3||^3.0",
"php": "^8.1",
"ramsey/uuid": "^4.0"
},
"conflict": {
"ext-protobuf": "<4.31.0"
},
"require-dev": {
"google/cloud-tools": "^0.16.1",
"phpspec/prophecy-phpunit": "^2.1",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^9.6"
},
"type": "library",
"extra": {
"component": {
"id": "gax",
"path": "Gax",
"entry": "README.md",
"target": "googleapis/gax-php.git"
}
},
"autoload": {
"psr-4": {
"Google\\ApiCore\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "Google API Core for PHP",
"homepage": "https://github.com/googleapis/gax-php",
"keywords": [
"google"
],
"support": {
"issues": "https://github.com/googleapis/gax-php/issues",
"source": "https://github.com/googleapis/gax-php/tree/v1.48.0"
},
"time": "2026-08-14T23:32:22+00:00"
},
{
"name": "google/grpc-gcp",
"version": "0.4.2",
"source": {
"type": "git",
"url": "https://github.com/GoogleCloudPlatform/grpc-gcp-php.git",
"reference": "1049c0c15b6a1789fdeb52af688a94d540932469"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/GoogleCloudPlatform/grpc-gcp-php/zipball/1049c0c15b6a1789fdeb52af688a94d540932469",
"reference": "1049c0c15b6a1789fdeb52af688a94d540932469",
"shasum": ""
},
"require": {
"google/auth": "^1.3",
"google/protobuf": "^v3.25.3||^4.26.1||^5.0",
"grpc/grpc": "^v1.13.0",
"php": "^8.0",
"psr/cache": "^1.0.1||^2.0.0||^3.0.0"
},
"require-dev": {
"google/cloud-spanner": "^1.7",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Grpc\\Gcp\\": "src/"
},
"classmap": [
"src/generated/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "gRPC GCP library for channel management",
"support": {
"issues": "https://github.com/GoogleCloudPlatform/grpc-gcp-php/issues",
"source": "https://github.com/GoogleCloudPlatform/grpc-gcp-php/tree/v0.4.2"
},
"time": "2026-03-12T22:56:09+00:00"
},
{
"name": "google/longrunning",
"version": "0.8.1",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-longrunning.git",
"reference": "309705016290679e6fe14b727f4b1a3e04ae52f4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-longrunning/zipball/309705016290679e6fe14b727f4b1a3e04ae52f4",
"reference": "309705016290679e6fe14b727f4b1a3e04ae52f4",
"shasum": ""
},
"require-dev": {
"google/gax": "^1.38.0",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"component": {
"id": "longrunning",
"path": "LongRunning",
"entry": null,
"target": "googleapis/php-longrunning"
}
},
"autoload": {
"psr-4": {
"Google\\LongRunning\\": "src/LongRunning",
"Google\\ApiCore\\LongRunning\\": "src/ApiCore/LongRunning",
"GPBMetadata\\Google\\Longrunning\\": "metadata/Longrunning"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "Google LongRunning Client for PHP",
"support": {
"source": "https://github.com/googleapis/php-longrunning/tree/v0.8.1"
},
"time": "2026-08-14T23:32:22+00:00"
},
{
"name": "google/protobuf",
"version": "v5.36.0",
"source": {
"type": "git",
"url": "https://github.com/protocolbuffers/protobuf-php.git",
"reference": "9c105104b54709ecd902494ab340ed2122789b2d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/9c105104b54709ecd902494ab340ed2122789b2d",
"reference": "9c105104b54709ecd902494ab340ed2122789b2d",
"shasum": ""
},
"require": {
"php": ">=8.2.0"
},
"require-dev": {
"phpunit/phpunit": ">=11.5.50 <12.0.0"
},
"suggest": {
"ext-bcmath": "Need to support JSON deserialization"
},
"type": "library",
"autoload": {
"psr-4": {
"Google\\Protobuf\\": "src/Google/Protobuf",
"GPBMetadata\\Google\\Protobuf\\": "src/GPBMetadata/Google/Protobuf"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "proto library for PHP",
"homepage": "https://developers.google.com/protocol-buffers/",
"keywords": [
"proto"
],
"support": {
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.0"
},
"time": "2026-08-20T13:06:50+00:00"
},
{
"name": "graham-campbell/result-type",
"version": "v1.1.4",
@@ -1230,6 +1674,50 @@
],
"time": "2025-12-27T19:43:20+00:00"
},
{
"name": "grpc/grpc",
"version": "1.82.0",
"source": {
"type": "git",
"url": "https://github.com/grpc/grpc-php.git",
"reference": "be984cb608f21e96453b3cfe54c748cc7b192250"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/grpc/grpc-php/zipball/be984cb608f21e96453b3cfe54c748cc7b192250",
"reference": "be984cb608f21e96453b3cfe54c748cc7b192250",
"shasum": ""
},
"require": {
"php": ">=7.1.0"
},
"require-dev": {
"google/auth": "^v1.3.0"
},
"suggest": {
"ext-protobuf": "For better performance, install the protobuf C extension.",
"google/protobuf": "To get started using grpc quickly, install the native protobuf library."
},
"type": "library",
"autoload": {
"psr-4": {
"Grpc\\": "src/lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"description": "gRPC library for PHP",
"homepage": "https://grpc.io",
"keywords": [
"rpc"
],
"support": {
"source": "https://github.com/grpc/grpc-php/tree/v1.82.0"
},
"time": "2026-07-03T09:39:53+00:00"
},
{
"name": "guzzlehttp/guzzle",
"version": "7.15.2",
@@ -2648,6 +3136,54 @@
},
"time": "2026-07-01T23:25:49+00:00"
},
{
"name": "league/flysystem-google-cloud-storage",
"version": "3.34.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-google-cloud-storage.git",
"reference": "7ae8cd9ec58dd4b387ee1f7349e728ed8c455b09"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-google-cloud-storage/zipball/7ae8cd9ec58dd4b387ee1f7349e728ed8c455b09",
"reference": "7ae8cd9ec58dd4b387ee1f7349e728ed8c455b09",
"shasum": ""
},
"require": {
"google/cloud-storage": "^1.23 || ^2.0",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\GoogleCloudStorage\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "Google Cloud Storage adapter for Flysystem.",
"keywords": [
"Flysystem",
"filesystem",
"gcs",
"google cloud storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-google-cloud-storage/tree/3.34.0"
},
"time": "2026-05-12T08:30:57+00:00"
},
{
"name": "league/flysystem-local",
"version": "3.31.0",
@@ -4098,6 +4634,55 @@
},
"time": "2026-08-16T18:45:51+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
@@ -4787,6 +5372,70 @@
},
"time": "2026-06-18T03:57:49+00:00"
},
{
"name": "rize/uri-template",
"version": "0.4.2",
"source": {
"type": "git",
"url": "https://github.com/rize/UriTemplate.git",
"reference": "7ad22944daede547b4542e1c977ec4a81aa20832"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rize/UriTemplate/zipball/7ad22944daede547b4542e1c977ec4a81aa20832",
"reference": "7ad22944daede547b4542e1c977ec4a81aa20832",
"shasum": ""
},
"require": {
"php": ">=8.1"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.63",
"phpstan/phpstan": "^1.12",
"phpunit/phpunit": "~10.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Rize\\": "src/Rize"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Marut K",
"homepage": "http://twitter.com/rezigned"
}
],
"description": "PHP URI Template (RFC 6570) supports both expansion & extraction",
"keywords": [
"RFC 6570",
"template",
"uri"
],
"support": {
"issues": "https://github.com/rize/UriTemplate/issues",
"source": "https://github.com/rize/UriTemplate/tree/0.4.2"
},
"funding": [
{
"url": "https://www.paypal.me/rezigned",
"type": "custom"
},
{
"url": "https://github.com/rezigned",
"type": "github"
},
{
"url": "https://opencollective.com/rize-uri-template",
"type": "open_collective"
}
],
"time": "2026-05-07T15:30:40+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.93.1",
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('external_storage_settings', function (Blueprint $table) {
// Every row that exists predates the choice, and every one of
// them is S3 — so the default is what keeps this migration
// invisible to anyone already using external storage.
$table->string('provider')->default('s3')->after('active');
// A service account key is a ~2 KB JSON document, not a
// password, so it gets its own encrypted column rather than
// sharing `secret` with S3. The two are validated
// differently, labelled differently and shown differently,
// and one column meaning two things is how that gets
// confusing later.
$table->text('key_file')->nullable()->after('secret');
});
}
public function down(): void
{
Schema::table('external_storage_settings', function (Blueprint $table) {
$table->dropColumn(['provider', 'key_file']);
});
}
};
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Confirma la contrasenya",
"Conflict": "Conflicte",
"Connect": "Connecta't",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Connecta un bucket extern — compatible amb S3 (AWS S3, MinIO, Backblaze) o Google Cloud Storage — com a emmagatzematge de les càrregues noves.",
"Connection Closed Without Response": "Connexió tancada sense resposta",
"Connection Timed Out": "Temps de connexió esgotat",
"Continue": "Continua",
@@ -410,6 +411,7 @@
"Go to page :page": "Aneu a la pàgina :page",
"Go to the login form": "Ves al formulari d'inici de sessió",
"Gone": "Desaparegut",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Gris",
"Green": "Verd",
"Group": "Grup",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Encara no s'ha afegit cap destinatari.",
"No requests match your search.": "Cap sol·licitud coincideix amb la teva cerca.",
"No roles match this filter.": "Cap rol coincideix amb aquest filtre.",
"No service account key has been saved yet.": "Encara no s'ha desat cap clau de compte de servei.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Sense límit de mida. Les pujades es poden posar en pausa i es reprenen automàticament després d'una interrupció.",
"No users match these filters.": "Cap usuari coincideix amb aquests filtres.",
"No users yet.": "Encara no hi ha usuaris.",
@@ -617,6 +620,7 @@
"Partial Content": "Contingut parcial",
"Password": "Contrasenya",
"Password reset": "Restabliment de la contrasenya",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Enganxa el fitxer de clau JSON d'un compte de servei amb accés de lectura i escriptura als objectes del bucket. Es desa xifrat i no es torna a mostrar.",
"Path": "Camí",
"Payload Too Large": "Càrrega útil massa gran",
"Payment Required": "Pagament obligatori",
@@ -724,6 +728,7 @@
"Role name": "Nom del rol",
"Role updated.": "Rol actualitzat.",
"Roles": "Rols",
"S3-compatible": "Compatible amb S3",
"Sample email": "Correu d'exemple",
"Save": "Desar",
"Save :name": "Estalvia :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Envia ara mateix un correu de debò, sense passar per la cua, perquè puguis comprovar que la configuració de sobre funciona realment.",
"Separate paragraphs with a blank line.": "Separa els paràgrafs amb una línia en blanc.",
"Server Error": "Error del servidor",
"Service account key": "Clau de compte de servei",
"Service Unavailable": "Servei no disponible",
"Session Has Expired": "La sessió ha caducat",
"Set file expiration dates": "Establir dates de caducitat dels fitxers",
@@ -827,6 +833,8 @@
"Test": "Prova",
"Text": "Text",
"Text or author": "Text o autor",
"That does not look like a service account key file: it is not valid JSON.": "Això no sembla un fitxer de clau de compte de servei: no és JSON vàlid.",
"That service account key file is missing its :field.": "A aquest fitxer de clau de compte de servei li falta :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "El compte de :name s'eliminarà definitivament. Aquesta acció no es pot desfer.",
"The account password was changed": "S'ha canviat la contrasenya del compte",
"The account request of :name will be denied and the account deleted.": "La sol·licitud de compte de :name es denegarà i el compte s'eliminarà.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Potvrzení hesla",
"Conflict": "Konflikt",
"Connect": "Připojit",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Připojte externí bucket — kompatibilní s S3 (AWS S3, MinIO, Backblaze) nebo Google Cloud Storage — jako úložiště pro nová nahrání.",
"Connection Closed Without Response": "Připojení uzavřeno bez odezvy",
"Connection Timed Out": "Připojení vypršelo",
"Continue": "Pokračovat",
@@ -410,6 +411,7 @@
"Go to page :page": "Přejít na stranu :page",
"Go to the login form": "Přejít na přihlašovací formulář",
"Gone": "Pryč",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Šedá",
"Green": "Zelená",
"Group": "Skupina",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Zatím nebyl přidán žádný příjemce.",
"No requests match your search.": "Vašemu hledání neodpovídá žádná žádost.",
"No roles match this filter.": "Tomuto filtru neodpovídá žádná role.",
"No service account key has been saved yet.": "Zatím nebyl uložen žádný klíč servisního účtu.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Bez omezení velikosti. Nahrávání lze pozastavit a po přerušení pokračuje automaticky.",
"No users match these filters.": "Těmto filtrům neodpovídá žádný uživatel.",
"No users yet.": "Zatím žádní uživatelé.",
@@ -617,6 +620,7 @@
"Partial Content": "Částečný obsah",
"Password": "Heslo",
"Password reset": "Obnova hesla",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Vložte soubor s klíčem JSON servisního účtu s právem číst a zapisovat objekty v bucketu. Ukládá se zašifrovaný a už se nikdy nezobrazí.",
"Path": "Cesta",
"Payload Too Large": "Příliš velké užitečné zatížení",
"Payment Required": "Platba vyžadována",
@@ -724,6 +728,7 @@
"Role name": "Název role",
"Role updated.": "Role aktualizována.",
"Roles": "Role",
"S3-compatible": "Kompatibilní s S3",
"Sample email": "Ukázkový e-mail",
"Save": "Uložit",
"Save :name": "Uložit :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Odešle hned teď skutečný e-mail mimo frontu, abyste si ověřili, že nastavení výše opravdu funguje.",
"Separate paragraphs with a blank line.": "Odstavce oddělujte prázdným řádkem.",
"Server Error": "Chyba serveru",
"Service account key": "Klíč servisního účtu",
"Service Unavailable": "Služba je nedostupná",
"Session Has Expired": "Platnost relace vypršela",
"Set file expiration dates": "Nastavování data vypršení souborů",
@@ -827,6 +833,8 @@
"Test": "Test",
"Text": "Text",
"Text or author": "Text nebo autor",
"That does not look like a service account key file: it is not valid JSON.": "Tohle nevypadá jako soubor s klíčem servisního účtu: není to platný JSON.",
"That service account key file is missing its :field.": "V tomto souboru s klíčem servisního účtu chybí :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "Účet :name bude trvale smazán. Tuto akci nelze vrátit zpět.",
"The account password was changed": "Heslo k účtu bylo změněno",
"The account request of :name will be denied and the account deleted.": "Žádost o účet od :name bude zamítnuta a účet smazán.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Passwort bestätigen",
"Conflict": "Konflikt",
"Connect": "Verbinden",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Verbinden Sie einen externen Bucket — S3-kompatibel (AWS S3, MinIO, Backblaze) oder Google Cloud Storage — als Speicher für neue Uploads.",
"Connection Closed Without Response": "Verbindung ohne Antwort getrennt",
"Connection Timed Out": "Verbindungszeit überschritten",
"Continue": "Weiter",
@@ -410,6 +411,7 @@
"Go to page :page": "Gehe zur Seite :page",
"Go to the login form": "Zum Anmeldeformular",
"Gone": "Nicht mehr verfügbar",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Grau",
"Green": "Grün",
"Group": "Gruppe",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Noch keine Empfänger hinzugefügt.",
"No requests match your search.": "Keine Anfrage entspricht Ihrer Suche.",
"No roles match this filter.": "Keine Rolle entspricht diesem Filter.",
"No service account key has been saved yet.": "Es wurde noch kein Dienstkontoschlüssel gespeichert.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Keine Größenbeschränkung. Uploads können pausiert werden und laufen nach Unterbrechungen automatisch weiter.",
"No users match these filters.": "Kein Benutzer entspricht diesen Filtern.",
"No users yet.": "Noch keine Benutzer.",
@@ -617,6 +620,7 @@
"Partial Content": "Teilinhalt",
"Password": "Passwort",
"Password reset": "Passwort zurücksetzen",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Fügen Sie die JSON-Schlüsseldatei eines Dienstkontos mit Lese- und Schreibzugriff auf die Objekte des Buckets ein. Sie wird verschlüsselt gespeichert und nie wieder angezeigt.",
"Path": "Pfad",
"Payload Too Large": "Nutzlast zu groß",
"Payment Required": "Zahlung erforderlich",
@@ -724,6 +728,7 @@
"Role name": "Rollenname",
"Role updated.": "Rolle aktualisiert.",
"Roles": "Rollen",
"S3-compatible": "S3-kompatibel",
"Sample email": "Beispiel-E-Mail",
"Save": "Speichern",
"Save :name": "Sparen Sie :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Sendet sofort eine echte E-Mail an der Warteschlange vorbei, damit Sie prüfen können, ob die Einstellungen oben tatsächlich funktionieren.",
"Separate paragraphs with a blank line.": "Trennen Sie Absätze durch eine Leerzeile.",
"Server Error": "Interner Fehler",
"Service account key": "Dienstkontoschlüssel",
"Service Unavailable": "Service nicht verfügbar",
"Session Has Expired": "Sitzung ist abgelaufen",
"Set file expiration dates": "Ablaufdaten für Dateien festlegen",
@@ -827,6 +833,8 @@
"Test": "Test",
"Text": "Text",
"Text or author": "Text oder Autor",
"That does not look like a service account key file: it is not valid JSON.": "Das sieht nicht nach einer Dienstkonto-Schlüsseldatei aus: Es ist kein gültiges JSON.",
"That service account key file is missing its :field.": "In dieser Dienstkonto-Schlüsseldatei fehlt :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "Das Konto von :name wird endgültig gelöscht. Das lässt sich nicht rückgängig machen.",
"The account password was changed": "Das Kontopasswort wurde geändert",
"The account request of :name will be denied and the account deleted.": "Die Kontoanfrage von :name wird abgelehnt und das Konto gelöscht.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Confirmar contraseña",
"Conflict": "Conflicto",
"Connect": "Conectar",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Conecta un bucket externo — compatible con S3 (AWS S3, MinIO, Backblaze) o Google Cloud Storage — como almacenamiento para las subidas nuevas.",
"Connection Closed Without Response": "Conexión cerrada sin respuesta",
"Connection Timed Out": "Tiempo de conexión agotado",
"Continue": "Continuar",
@@ -410,6 +411,7 @@
"Go to page :page": "Ir a la página :page",
"Go to the login form": "Ir al formulario de inicio de sesión",
"Gone": "Recurso no disponible",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Gris",
"Green": "Verde",
"Group": "Grupo",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Aún no se agregaron destinatarios.",
"No requests match your search.": "Ninguna solicitud coincide con tu búsqueda.",
"No roles match this filter.": "Ningún rol coincide con este filtro.",
"No service account key has been saved yet.": "Todavía no se ha guardado ninguna clave de cuenta de servicio.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Sin límite de tamaño. Las subidas pueden pausarse y se reanudan automáticamente tras interrupciones.",
"No users match these filters.": "Ningún usuario coincide con estos filtros.",
"No users yet.": "Aún no hay usuarios.",
@@ -617,6 +620,7 @@
"Partial Content": "Contenido parcial",
"Password": "Contraseña",
"Password reset": "Restablecimiento de contraseña",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Pega el archivo de clave JSON de una cuenta de servicio con permiso de lectura y escritura de objetos en el bucket. Se guarda cifrado y no se vuelve a mostrar.",
"Path": "Ruta",
"Payload Too Large": "Solicitud demasiado grande",
"Payment Required": "Pago requerido",
@@ -724,6 +728,7 @@
"Role name": "Nombre del rol",
"Role updated.": "Rol actualizado.",
"Roles": "Roles",
"S3-compatible": "Compatible con S3",
"Sample email": "Correo de muestra",
"Save": "Guardar",
"Save :name": "Guardar :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Envía un correo real de inmediato, sin pasar por la cola, para verificar que la configuración anterior funciona.",
"Separate paragraphs with a blank line.": "Separa los párrafos con una línea en blanco.",
"Server Error": "Error del servidor",
"Service account key": "Clave de cuenta de servicio",
"Service Unavailable": "Servicio no disponible",
"Session Has Expired": "La sesión ha expirado",
"Set file expiration dates": "Establecer fechas de vencimiento de archivos",
@@ -827,6 +833,8 @@
"Test": "Prueba",
"Text": "Texto",
"Text or author": "Texto o autor",
"That does not look like a service account key file: it is not valid JSON.": "Esto no parece un archivo de clave de cuenta de servicio: no es JSON válido.",
"That service account key file is missing its :field.": "A ese archivo de clave de cuenta de servicio le falta :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "La cuenta de :name se eliminará permanentemente. Esto no se puede deshacer.",
"The account password was changed": "Se cambió la contraseña de la cuenta",
"The account request of :name will be denied and the account deleted.": "La solicitud de cuenta de :name será denegada y la cuenta eliminada.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Confirmer le mot de passe",
"Conflict": "Conflit",
"Connect": "Connecter",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Connectez un bucket externe — compatible S3 (AWS S3, MinIO, Backblaze) ou Google Cloud Storage — comme espace de stockage des nouveaux envois.",
"Connection Closed Without Response": "Connexion fermée sans réponse",
"Connection Timed Out": "La connexion a expiré",
"Continue": "Continuer",
@@ -410,6 +411,7 @@
"Go to page :page": "Aller à la page :page",
"Go to the login form": "Aller au formulaire de connexion",
"Gone": "Disparu",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Gris",
"Green": "Vert",
"Group": "Groupe",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Aucun destinataire ajouté pour l'instant.",
"No requests match your search.": "Aucune demande ne correspond à votre recherche.",
"No roles match this filter.": "Aucun rôle ne correspond à ce filtre.",
"No service account key has been saved yet.": "Aucune clé de compte de service n'a encore été enregistrée.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Aucune limite de taille. Les téléversements peuvent être mis en pause et reprennent automatiquement après une interruption.",
"No users match these filters.": "Aucun utilisateur ne correspond à ces filtres.",
"No users yet.": "Aucun utilisateur pour l'instant.",
@@ -617,6 +620,7 @@
"Partial Content": "Contenu partiel",
"Password": "Mot de passe",
"Password reset": "Réinitialisation du mot de passe",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Collez le fichier de clé JSON d'un compte de service disposant d'un accès en lecture et écriture aux objets du bucket. Il est stocké chiffré et n'est plus jamais affiché.",
"Path": "Chemin",
"Payload Too Large": "Charge utile trop grande",
"Payment Required": "Paiement requis",
@@ -724,6 +728,7 @@
"Role name": "Nom du rôle",
"Role updated.": "Rôle mis à jour.",
"Roles": "Rôles",
"S3-compatible": "Compatible S3",
"Sample email": "Exemple d'e-mail",
"Save": "Sauvegarder",
"Save :name": "Sauvegarder :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Envoie immédiatement un vrai e-mail, sans passer par la file d'attente, pour vérifier que les paramètres ci-dessus fonctionnent réellement.",
"Separate paragraphs with a blank line.": "Séparez les paragraphes par une ligne vide.",
"Server Error": "Erreur serveur",
"Service account key": "Clé de compte de service",
"Service Unavailable": "Service indisponible",
"Session Has Expired": "La session a expiré",
"Set file expiration dates": "Définir des dates d'expiration des fichiers",
@@ -827,6 +833,8 @@
"Test": "Test",
"Text": "Texte",
"Text or author": "Texte ou auteur",
"That does not look like a service account key file: it is not valid JSON.": "Cela ne ressemble pas à un fichier de clé de compte de service : ce n'est pas du JSON valide.",
"That service account key file is missing its :field.": "Il manque :field à ce fichier de clé de compte de service.",
"The account of :name will be permanently deleted. This cannot be undone.": "Le compte de :name sera définitivement supprimé. Cette action est irréversible.",
"The account password was changed": "Le mot de passe du compte a été modifié",
"The account request of :name will be denied and the account deleted.": "La demande de compte de :name sera refusée et le compte supprimé.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Konfirmasi kata sandi",
"Conflict": "Konflik",
"Connect": "Menghubung",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Hubungkan bucket eksternal — kompatibel dengan S3 (AWS S3, MinIO, Backblaze) atau Google Cloud Storage — sebagai penyimpanan untuk unggahan baru.",
"Connection Closed Without Response": "Koneksi Ditutup Tanpa Respon",
"Connection Timed Out": "Waktu koneksi berakhir",
"Continue": "Lanjut",
@@ -410,6 +411,7 @@
"Go to page :page": "Ke halaman :page",
"Go to the login form": "Ke formulir masuk",
"Gone": "Hilang",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Abu-abu",
"Green": "Hijau",
"Group": "Grup",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Belum ada penerima yang ditambahkan.",
"No requests match your search.": "Tidak ada permintaan yang cocok dengan pencarian Anda.",
"No roles match this filter.": "Tidak ada peran yang cocok dengan filter ini.",
"No service account key has been saved yet.": "Belum ada kunci akun layanan yang disimpan.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Tanpa batas ukuran. Unggahan bisa dijeda dan otomatis berlanjut setelah terputus.",
"No users match these filters.": "Tidak ada pengguna yang cocok dengan filter ini.",
"No users yet.": "Belum ada pengguna.",
@@ -617,6 +620,7 @@
"Partial Content": "Konten parsial",
"Password": "Kata sandi",
"Password reset": "Penyetelan ulang kata sandi",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Tempel berkas kunci JSON milik akun layanan yang punya akses baca dan tulis ke objek di bucket. Berkas disimpan terenkripsi dan tidak pernah ditampilkan lagi.",
"Path": "Jalur",
"Payload Too Large": "Payload terlalu besar",
"Payment Required": "Pembayaran Diperlukan",
@@ -724,6 +728,7 @@
"Role name": "Nama peran",
"Role updated.": "Peran diperbarui.",
"Roles": "Peran",
"S3-compatible": "Kompatibel dengan S3",
"Sample email": "Contoh email",
"Save": "Simpan",
"Save :name": "Hemat :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Mengirim email sungguhan sekarang juga tanpa lewat antrean, agar Anda bisa memastikan pengaturan di atas benar-benar bekerja.",
"Separate paragraphs with a blank line.": "Pisahkan paragraf dengan satu baris kosong.",
"Server Error": "Terjadi Kesalahan Server",
"Service account key": "Kunci akun layanan",
"Service Unavailable": "Layanan Tidak Tersedia",
"Session Has Expired": "Sesi telah berakhir",
"Set file expiration dates": "Menetapkan tanggal kedaluwarsa berkas",
@@ -827,6 +833,8 @@
"Test": "Uji",
"Text": "Teks",
"Text or author": "Isi atau penulis",
"That does not look like a service account key file: it is not valid JSON.": "Ini sepertinya bukan berkas kunci akun layanan: bukan JSON yang valid.",
"That service account key file is missing its :field.": "Berkas kunci akun layanan ini tidak memiliki :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "Akun :name akan dihapus permanen. Tindakan ini tidak bisa dibatalkan.",
"The account password was changed": "Kata sandi akun diubah",
"The account request of :name will be denied and the account deleted.": "Permintaan akun dari :name akan ditolak dan akunnya dihapus.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Conferma password",
"Conflict": "Conflitto",
"Connect": "Collega",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Collega un bucket esterno — compatibile con S3 (AWS S3, MinIO, Backblaze) o Google Cloud Storage — come spazio di archiviazione per i nuovi caricamenti.",
"Connection Closed Without Response": "Connessione chiusa senza risposta",
"Connection Timed Out": "Tempo scaduto per la connessione",
"Continue": "Continua",
@@ -410,6 +411,7 @@
"Go to page :page": "Vai alla pagina :page",
"Go to the login form": "Vai al modulo di accesso",
"Gone": "Andata",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Grigio",
"Green": "Verde",
"Group": "Gruppo",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Ancora nessun destinatario aggiunto.",
"No requests match your search.": "Nessuna richiesta corrisponde alla tua ricerca.",
"No roles match this filter.": "Nessun ruolo corrisponde a questo filtro.",
"No service account key has been saved yet.": "Non è ancora stata salvata nessuna chiave dell'account di servizio.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Nessun limite di dimensione. I caricamenti possono essere messi in pausa e riprendono automaticamente dopo un'interruzione.",
"No users match these filters.": "Nessun utente corrisponde a questi filtri.",
"No users yet.": "Ancora nessun utente.",
@@ -617,6 +620,7 @@
"Partial Content": "Contenuto parziale",
"Password": "Password",
"Password reset": "Reimpostazione della password",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Incolla il file di chiave JSON di un account di servizio con accesso in lettura e scrittura agli oggetti del bucket. Viene salvato cifrato e non viene più mostrato.",
"Path": "Percorso",
"Payload Too Large": "Payload troppo grande",
"Payment Required": "Pagamento richiesto",
@@ -724,6 +728,7 @@
"Role name": "Nome del ruolo",
"Role updated.": "Ruolo aggiornato.",
"Roles": "Ruoli",
"S3-compatible": "Compatibile con S3",
"Sample email": "E-mail di esempio",
"Save": "Salva",
"Save :name": "Salva :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Invia subito una vera e-mail, saltando la coda, così puoi verificare che le impostazioni qui sopra funzionino davvero.",
"Separate paragraphs with a blank line.": "Separa i paragrafi con una riga vuota.",
"Server Error": "Errore server",
"Service account key": "Chiave dell'account di servizio",
"Service Unavailable": "Servizio non disponibile",
"Session Has Expired": "Sessione scaduta",
"Set file expiration dates": "Impostare le date di scadenza dei file",
@@ -827,6 +833,8 @@
"Test": "Prova",
"Text": "Testo",
"Text or author": "Testo o autore",
"That does not look like a service account key file: it is not valid JSON.": "Questo non sembra un file di chiave dell'account di servizio: non è JSON valido.",
"That service account key file is missing its :field.": "A questo file di chiave dell'account di servizio manca :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "L'account di :name verrà eliminato definitivamente. L'operazione non è reversibile.",
"The account password was changed": "La password dell'account è stata modificata",
"The account request of :name will be denied and the account deleted.": "La richiesta di account di :name verrà rifiutata e l'account eliminato.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "パスワードの確認",
"Conflict": "競合",
"Connect": "接続",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "新しいアップロードの保存先として、外部のバケット (S3 互換 — AWS S3、MinIO、Backblaze — または Google Cloud Storage) を接続します。",
"Connection Closed Without Response": "応答なしで接続が閉じられました",
"Connection Timed Out": "接続がタイムアウト",
"Continue": "継続",
@@ -410,6 +411,7 @@
"Go to page :page": ":pageページへ",
"Go to the login form": "ログインフォームへ",
"Gone": "消滅",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "グレー",
"Green": "緑",
"Group": "グループ",
@@ -574,6 +576,7 @@
"No recipients added yet.": "宛先はまだ追加されていません。",
"No requests match your search.": "検索条件に一致する申請はありません。",
"No roles match this filter.": "この条件に一致するロールはありません。",
"No service account key has been saved yet.": "サービスアカウントキーはまだ保存されていません。",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "サイズ制限はありません。アップロードは一時停止でき、中断後は自動的に再開します。",
"No users match these filters.": "この条件に一致するユーザーはいません。",
"No users yet.": "ユーザーはまだいません。",
@@ -617,6 +620,7 @@
"Partial Content": "部分的なコンテンツ",
"Password": "パスワード",
"Password reset": "パスワードの再設定",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "バケット内のオブジェクトへの読み取りと書き込み権限を持つサービスアカウントの JSON キーファイルを貼り付けてください。暗号化して保存され、二度と表示されません。",
"Path": "パス",
"Payload Too Large": "ペイロードが大きすぎます",
"Payment Required": "お支払いが必要",
@@ -724,6 +728,7 @@
"Role name": "ロール名",
"Role updated.": "ロールを更新しました。",
"Roles": "ロール",
"S3-compatible": "S3 互換",
"Sample email": "サンプルメール",
"Save": "保存",
"Save :name": ":nameを保存",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "キューを通さず今すぐ実際のメールを送信し、上の設定が本当に機能するか確認できます。",
"Separate paragraphs with a blank line.": "段落は空行で区切ってください。",
"Server Error": "サーバーエラー",
"Service account key": "サービスアカウントキー",
"Service Unavailable": "サービスは利用できません",
"Session Has Expired": "セッションの有効期限切れ",
"Set file expiration dates": "ファイルの有効期限の設定",
@@ -827,6 +833,8 @@
"Test": "テスト",
"Text": "テキスト",
"Text or author": "本文または投稿者",
"That does not look like a service account key file: it is not valid JSON.": "サービスアカウントのキーファイルではないようです。有効な JSON ではありません。",
"That service account key file is missing its :field.": "このサービスアカウントキーファイルには :field がありません。",
"The account of :name will be permanently deleted. This cannot be undone.": ":name のアカウントを完全に削除します。この操作は取り消せません。",
"The account password was changed": "アカウントのパスワードが変更されました",
"The account request of :name will be denied and the account deleted.": ":name のアカウント申請を却下し、アカウントを削除します。",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Wachtwoord bevestigen",
"Conflict": "Conflict",
"Connect": "Verbinden",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Koppel een externe bucket — S3-compatibel (AWS S3, MinIO, Backblaze) of Google Cloud Storage — als opslag voor nieuwe uploads.",
"Connection Closed Without Response": "Verbinding gesloten zonder reactie",
"Connection Timed Out": "Connectie duurt te lang",
"Continue": "Doorgaan",
@@ -410,6 +411,7 @@
"Go to page :page": "Ga naar pagina :page",
"Go to the login form": "Naar het inlogformulier",
"Gone": "Verdwenen",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Grijs",
"Green": "Groen",
"Group": "Groep",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Nog geen ontvangers toegevoegd.",
"No requests match your search.": "Geen aanvraag komt overeen met je zoekopdracht.",
"No roles match this filter.": "Geen rol voldoet aan dit filter.",
"No service account key has been saved yet.": "Er is nog geen serviceaccountsleutel opgeslagen.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Geen groottelimiet. Uploads kunnen worden gepauzeerd en hervatten automatisch na een onderbreking.",
"No users match these filters.": "Geen gebruiker voldoet aan deze filters.",
"No users yet.": "Nog geen gebruikers.",
@@ -617,6 +620,7 @@
"Partial Content": "Gedeeltelijke inhoud",
"Password": "Wachtwoord",
"Password reset": "Wachtwoordherstel",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Plak het JSON-sleutelbestand van een serviceaccount met lees- en schrijftoegang tot de objecten in de bucket. Het wordt versleuteld opgeslagen en nooit meer getoond.",
"Path": "Pad",
"Payload Too Large": "Aanvraag te groot",
"Payment Required": "Betaling vereist",
@@ -724,6 +728,7 @@
"Role name": "Rolnaam",
"Role updated.": "Rol bijgewerkt.",
"Roles": "Rollen",
"S3-compatible": "S3-compatibel",
"Sample email": "Voorbeeld-e-mail",
"Save": "Opslaan",
"Save :name": ":Name opslaan",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Verstuurt nu meteen een echte e-mail, buiten de wachtrij om, zodat je kunt controleren of de instellingen hierboven echt werken.",
"Separate paragraphs with a blank line.": "Scheid alinea's met een lege regel.",
"Server Error": "Serverfout",
"Service account key": "Serviceaccountsleutel",
"Service Unavailable": "Website onbeschikbaar",
"Session Has Expired": "Pagina verlopen",
"Set file expiration dates": "Vervaldatums voor bestanden instellen",
@@ -827,6 +833,8 @@
"Test": "Test",
"Text": "Tekst",
"Text or author": "Tekst of auteur",
"That does not look like a service account key file: it is not valid JSON.": "Dit lijkt geen serviceaccountsleutelbestand: het is geen geldige JSON.",
"That service account key file is missing its :field.": "In dit serviceaccountsleutelbestand ontbreekt :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "Het account van :name wordt definitief verwijderd. Dit kan niet ongedaan worden gemaakt.",
"The account password was changed": "Het wachtwoord van het account is gewijzigd",
"The account request of :name will be denied and the account deleted.": "De accountaanvraag van :name wordt geweigerd en het account verwijderd.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Potwierdź hasło",
"Conflict": "Konflikt",
"Connect": "Połącz",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Podłącz zewnętrzny bucket — zgodny z S3 (AWS S3, MinIO, Backblaze) lub Google Cloud Storage — jako magazyn dla nowych przesyłanych plików.",
"Connection Closed Without Response": "Połączenie zamknięte bez odpowiedzi",
"Connection Timed Out": "Przekroczono limit czasu połączenia",
"Continue": "Kontynuuj",
@@ -410,6 +411,7 @@
"Go to page :page": "Przejdź do strony :page",
"Go to the login form": "Przejdź do formularza logowania",
"Gone": "Zasób został usunięty lub przeniesiony",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Szary",
"Green": "Zielony",
"Group": "Grupa",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Nie dodano jeszcze żadnych odbiorców.",
"No requests match your search.": "Żaden wniosek nie pasuje do wyszukiwania.",
"No roles match this filter.": "Żadna rola nie pasuje do tego filtru.",
"No service account key has been saved yet.": "Nie zapisano jeszcze żadnego klucza konta usługi.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Bez limitu rozmiaru. Przesyłanie można wstrzymać, a po przerwie wznawia się automatycznie.",
"No users match these filters.": "Żaden użytkownik nie pasuje do tych filtrów.",
"No users yet.": "Nie ma jeszcze użytkowników.",
@@ -617,6 +620,7 @@
"Partial Content": "Częściowa zawartość",
"Password": "Hasło",
"Password reset": "Resetowanie hasła",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Wklej plik klucza JSON konta usługi z prawem odczytu i zapisu obiektów w buckecie. Jest przechowywany w postaci zaszyfrowanej i nigdy więcej nie zostanie pokazany.",
"Path": "Ścieżka",
"Payload Too Large": "Ładunek zbyt duży",
"Payment Required": "Płatność Wymagana",
@@ -724,6 +728,7 @@
"Role name": "Nazwa roli",
"Role updated.": "Zaktualizowano rolę.",
"Roles": "Role",
"S3-compatible": "Zgodny z S3",
"Sample email": "Przykładowa wiadomość",
"Save": "Zapisz",
"Save :name": "Zapisz :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Wysyła od razu prawdziwą wiadomość, z pominięciem kolejki, żebyś mógł sprawdzić, czy powyższe ustawienia naprawdę działają.",
"Separate paragraphs with a blank line.": "Oddzielaj akapity pustym wierszem.",
"Server Error": "Błąd Serwera",
"Service account key": "Klucz konta usługi",
"Service Unavailable": "Serwis Niedostępny",
"Session Has Expired": "Sesja wygasła – wymagane ponowne logowanie",
"Set file expiration dates": "Ustawianie dat wygaśnięcia plików",
@@ -827,6 +833,8 @@
"Test": "Test",
"Text": "Tekst",
"Text or author": "Treść lub autor",
"That does not look like a service account key file: it is not valid JSON.": "To nie wygląda na plik klucza konta usługi: to nie jest prawidłowy JSON.",
"That service account key file is missing its :field.": "W tym pliku klucza konta usługi brakuje :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "Konto :name zostanie trwale usunięte. Tej operacji nie można cofnąć.",
"The account password was changed": "Zmieniono hasło do konta",
"The account request of :name will be denied and the account deleted.": "Wniosek o konto od :name zostanie odrzucony, a konto usunięte.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Confirmar senha",
"Conflict": "Conflito",
"Connect": "Conectar",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Conecte um bucket externo — compatível com S3 (AWS S3, MinIO, Backblaze) ou Google Cloud Storage — como armazenamento dos novos envios.",
"Connection Closed Without Response": "Conexão Fechada Sem Resposta",
"Connection Timed Out": "Tempo Limite Da Conexão",
"Continue": "Continuar",
@@ -410,6 +411,7 @@
"Go to page :page": "Ir para a página :page",
"Go to the login form": "Ir para o formulário de acesso",
"Gone": "Perdido",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Cinza",
"Green": "Verde",
"Group": "Grupo",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Ainda não há destinatários adicionados.",
"No requests match your search.": "Nenhuma solicitação corresponde à sua busca.",
"No roles match this filter.": "Nenhuma função corresponde a este filtro.",
"No service account key has been saved yet.": "Nenhuma chave de conta de serviço foi salva ainda.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Sem limite de tamanho. Os envios podem ser pausados e retomam automaticamente após interrupções.",
"No users match these filters.": "Nenhum usuário corresponde a estes filtros.",
"No users yet.": "Ainda não há usuários.",
@@ -617,6 +620,7 @@
"Partial Content": "Conteúdo Parcial",
"Password": "Senha",
"Password reset": "Redefinição de senha",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Cole o arquivo de chave JSON de uma conta de serviço com acesso de leitura e escrita aos objetos do bucket. Ele é armazenado criptografado e nunca mais é exibido.",
"Path": "Caminho",
"Payload Too Large": "Carga Muito Grande",
"Payment Required": "Pagamento Requerido",
@@ -724,6 +728,7 @@
"Role name": "Nome da função",
"Role updated.": "Função atualizada.",
"Roles": "Funções",
"S3-compatible": "Compatível com S3",
"Sample email": "E-mail de exemplo",
"Save": "Salvar",
"Save :name": "Economize :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Envia um e-mail de verdade agora mesmo, sem passar pela fila, para você conferir se as configurações acima realmente funcionam.",
"Separate paragraphs with a blank line.": "Separe os parágrafos com uma linha em branco.",
"Server Error": "Erro do servidor",
"Service account key": "Chave de conta de serviço",
"Service Unavailable": "Serviço indisponível",
"Session Has Expired": "Sessão Expirou",
"Set file expiration dates": "Definir datas de validade dos arquivos",
@@ -827,6 +833,8 @@
"Test": "Teste",
"Text": "Texto",
"Text or author": "Texto ou autor",
"That does not look like a service account key file: it is not valid JSON.": "Isso não parece um arquivo de chave de conta de serviço: não é um JSON válido.",
"That service account key file is missing its :field.": "Falta :field nesse arquivo de chave de conta de serviço.",
"The account of :name will be permanently deleted. This cannot be undone.": "A conta de :name será excluída definitivamente. Não é possível desfazer.",
"The account password was changed": "A senha da conta foi alterada",
"The account request of :name will be denied and the account deleted.": "A solicitação de conta de :name será recusada e a conta excluída.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Подтверждение пароля",
"Conflict": "Конфликт",
"Connect": "Подключить",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Подключите внешний бакет — S3-совместимый (AWS S3, MinIO, Backblaze) или Google Cloud Storage — как хранилище для новых загрузок.",
"Connection Closed Without Response": "Соединение закрыто без ответа",
"Connection Timed Out": "Соединение не отвечает",
"Continue": "Продолжить",
@@ -410,6 +411,7 @@
"Go to page :page": "Перейти к :page-й странице",
"Go to the login form": "Перейти к форме входа",
"Gone": "Удалено",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Серый",
"Green": "Зелёный",
"Group": "Группа",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Получатели пока не добавлены.",
"No requests match your search.": "Нет заявок, подходящих под ваш запрос.",
"No roles match this filter.": "Нет ролей, подходящих под этот фильтр.",
"No service account key has been saved yet.": "Ключ сервисного аккаунта ещё не сохранён.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Без ограничения размера. Загрузку можно приостановить, после обрыва она продолжится сама.",
"No users match these filters.": "Нет пользователей, подходящих под эти фильтры.",
"No users yet.": "Пользователей пока нет.",
@@ -617,6 +620,7 @@
"Partial Content": "Не полное содержимое",
"Password": "Пароль",
"Password reset": "Сброс пароля",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Вставьте JSON-файл ключа сервисного аккаунта с правами на чтение и запись объектов в бакете. Он хранится в зашифрованном виде и больше не показывается.",
"Path": "Путь",
"Payload Too Large": "Большой объём данных",
"Payment Required": "Необходима оплата",
@@ -724,6 +728,7 @@
"Role name": "Название роли",
"Role updated.": "Роль обновлена.",
"Roles": "Роли",
"S3-compatible": "S3-совместимое",
"Sample email": "Пример письма",
"Save": "Сохранить",
"Save :name": "Сохранить :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Отправляет настоящее письмо прямо сейчас, минуя очередь, чтобы вы убедились, что настройки выше действительно работают.",
"Separate paragraphs with a blank line.": "Разделяйте абзацы пустой строкой.",
"Server Error": "Ошибка сервера",
"Service account key": "Ключ сервисного аккаунта",
"Service Unavailable": "Сервис недоступен",
"Session Has Expired": "Сессия устарела",
"Set file expiration dates": "Установка сроков действия файлов",
@@ -827,6 +833,8 @@
"Test": "Проверка",
"Text": "Текст",
"Text or author": "Текст или автор",
"That does not look like a service account key file: it is not valid JSON.": "Это не похоже на файл ключа сервисного аккаунта: это не корректный JSON.",
"That service account key file is missing its :field.": "В этом файле ключа сервисного аккаунта отсутствует :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "Учётная запись :name будет удалена безвозвратно. Отменить это нельзя.",
"The account password was changed": "Пароль учётной записи изменён",
"The account request of :name will be denied and the account deleted.": "Заявка :name на учётную запись будет отклонена, а сама запись удалена.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Thibitisha nenosiri",
"Conflict": "Migogoro",
"Connect": "Unganisha",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Unganisha bucket ya nje — inayooana na S3 (AWS S3, MinIO, Backblaze) au Google Cloud Storage — kama hifadhi ya mafaili mapya yanayopakiwa.",
"Connection Closed Without Response": "Muunganisho Umefungwa Bila Majibu",
"Connection Timed Out": "Muda wa Muunganisho Umekwisha",
"Continue": "Endelea",
@@ -410,6 +411,7 @@
"Go to page :page": "Kwenda kwa ukurasa :page",
"Go to the login form": "Nenda kwenye fomu ya kuingia",
"Gone": "Imeondoka",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Kijivu",
"Green": "Kijani",
"Group": "Kikundi",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Bado hakuna mpokeaji aliyeongezwa.",
"No requests match your search.": "Hakuna ombi linalolingana na utafutaji wako.",
"No roles match this filter.": "Hakuna jukumu linalolingana na kichujio hiki.",
"No service account key has been saved yet.": "Bado hakuna ufunguo wa akaunti ya huduma uliohifadhiwa.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Hakuna kikomo cha ukubwa. Upakiaji unaweza kusimamishwa na huendelea wenyewe baada ya kukatika.",
"No users match these filters.": "Hakuna mtumiaji anayelingana na vichujio hivi.",
"No users yet.": "Bado hakuna watumiaji.",
@@ -617,6 +620,7 @@
"Partial Content": "Maudhui Sehemu",
"Password": "Nenosiri",
"Password reset": "Kuweka upya nenosiri",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Bandika faili la ufunguo la JSON la akaunti ya huduma yenye ruhusa ya kusoma na kuandika vitu ndani ya bucket. Huhifadhiwa kwa usimbaji fiche na hauonyeshwi tena.",
"Path": "Njia",
"Payload Too Large": "Mzigo Mkubwa Sana",
"Payment Required": "Malipo yanahitajika",
@@ -724,6 +728,7 @@
"Role name": "Jina la jukumu",
"Role updated.": "Jukumu limesasishwa.",
"Roles": "Majukumu",
"S3-compatible": "Inayooana na S3",
"Sample email": "Barua pepe ya mfano",
"Save": "Hifadhi",
"Save :name": "Hifadhi :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Hutuma barua pepe halisi sasa hivi bila kupitia foleni, ili uthibitishe kuwa mipangilio ya juu inafanya kazi kweli.",
"Separate paragraphs with a blank line.": "Tenganisha aya kwa mstari mtupu.",
"Server Error": "Hitilafu ya Seva",
"Service account key": "Ufunguo wa akaunti ya huduma",
"Service Unavailable": "Huduma Hazipatikani",
"Session Has Expired": "Kipindi Kimeisha",
"Set file expiration dates": "Kuweka tarehe za mwisho za mafaili",
@@ -827,6 +833,8 @@
"Test": "Jaribio",
"Text": "Maandishi",
"Text or author": "Maandishi au mwandishi",
"That does not look like a service account key file: it is not valid JSON.": "Hii haionekani kuwa faili la ufunguo wa akaunti ya huduma: si JSON halali.",
"That service account key file is missing its :field.": "Faili hili la ufunguo wa akaunti ya huduma linakosa :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "Akaunti ya :name itafutwa kabisa. Hatua hii haiwezi kutenduliwa.",
"The account password was changed": "Nenosiri la akaunti limebadilishwa",
"The account request of :name will be denied and the account deleted.": "Ombi la akaunti la :name litakataliwa na akaunti ifutwe.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Parolayı doğrula",
"Conflict": "Çakışma",
"Connect": "Bağlamak",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Yeni yüklemelerin depolanacağı yer olarak harici bir kova — S3 uyumlu (AWS S3, MinIO, Backblaze) ya da Google Cloud Storage — bağlayın.",
"Connection Closed Without Response": "Bağlantı Yanıtsız Kapatıldı",
"Connection Timed Out": "Bağlantı Zaman Aşımına Uğradı",
"Continue": "Devam Et",
@@ -410,6 +411,7 @@
"Go to page :page": ":Page sayfasına git",
"Go to the login form": "Giriş formuna git",
"Gone": "Gitmiş",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Gri",
"Green": "Yeşil",
"Group": "Grup",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Henüz alıcı eklenmedi.",
"No requests match your search.": "Aramanıza uyan başvuru yok.",
"No roles match this filter.": "Bu filtreye uyan rol yok.",
"No service account key has been saved yet.": "Henüz bir hizmet hesabı anahtarı kaydedilmedi.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Boyut sınırı yok. Yüklemeler duraklatılabilir ve kesintiden sonra kendiliğinden devam eder.",
"No users match these filters.": "Bu filtrelere uyan kullanıcı yok.",
"No users yet.": "Henüz kullanıcı yok.",
@@ -617,6 +620,7 @@
"Partial Content": "Kısmi İçerik",
"Password": "Parola",
"Password reset": "Parola sıfırlama",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Kovadaki nesneleri okuma ve yazma yetkisi olan bir hizmet hesabının JSON anahtar dosyasını yapıştırın. Şifrelenmiş olarak saklanır ve bir daha gösterilmez.",
"Path": "Yol",
"Payload Too Large": "Veri Çok Büyük",
"Payment Required": "ödeme gerekli",
@@ -724,6 +728,7 @@
"Role name": "Rol adı",
"Role updated.": "Rol güncellendi.",
"Roles": "Roller",
"S3-compatible": "S3 uyumlu",
"Sample email": "Örnek e-posta",
"Save": "Kaydet",
"Save :name": ":name'u kaydet",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Kuyruğu atlayarak hemen gerçek bir e-posta gönderir; böylece yukarıdaki ayarların gerçekten çalıştığını doğrulayabilirsiniz.",
"Separate paragraphs with a blank line.": "Paragrafları boş bir satırla ayırın.",
"Server Error": "Sunucu Hatası",
"Service account key": "Hizmet hesabı anahtarı",
"Service Unavailable": "Hizmet Kullanılamıyor",
"Session Has Expired": "Oturum süresi doldu",
"Set file expiration dates": "Dosya son kullanma tarihi belirleme",
@@ -827,6 +833,8 @@
"Test": "Deneme",
"Text": "Metin",
"Text or author": "Metin veya yazar",
"That does not look like a service account key file: it is not valid JSON.": "Bu bir hizmet hesabı anahtar dosyasına benzemiyor: geçerli bir JSON değil.",
"That service account key file is missing its :field.": "Bu hizmet hesabı anahtar dosyasında :field eksik.",
"The account of :name will be permanently deleted. This cannot be undone.": ":name kişisinin hesabı kalıcı olarak silinecek. Bu işlem geri alınamaz.",
"The account password was changed": "Hesap parolası değiştirildi",
"The account request of :name will be denied and the account deleted.": ":name kişisinin hesap başvurusu reddedilecek ve hesap silinecek.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "Xác nhận mật khẩu",
"Conflict": "Xung Đột",
"Connect": "Kết nối",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Kết nối một bucket bên ngoài — tương thích S3 (AWS S3, MinIO, Backblaze) hoặc Google Cloud Storage — làm nơi lưu trữ cho các tệp tải lên mới.",
"Connection Closed Without Response": "Đóng Kết Nối Với Không Phản Hồi",
"Connection Timed Out": "Quá Thời Gian Kết Nối",
"Continue": "Tiếp Tục",
@@ -410,6 +411,7 @@
"Go to page :page": "Tới trang :page",
"Go to the login form": "Đến trang đăng nhập",
"Gone": "Không Còn",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "Xám",
"Green": "Xanh lá",
"Group": "Nhóm",
@@ -574,6 +576,7 @@
"No recipients added yet.": "Chưa thêm người nhận nào.",
"No requests match your search.": "Không có yêu cầu nào khớp với tìm kiếm của bạn.",
"No roles match this filter.": "Không có vai trò nào khớp với bộ lọc này.",
"No service account key has been saved yet.": "Chưa có khóa tài khoản dịch vụ nào được lưu.",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "Không giới hạn kích thước. Có thể tạm dừng tải lên và quá trình sẽ tự tiếp tục sau khi bị gián đoạn.",
"No users match these filters.": "Không có người dùng nào khớp với các bộ lọc này.",
"No users yet.": "Chưa có người dùng nào.",
@@ -617,6 +620,7 @@
"Partial Content": "Nội Dung Một Phần",
"Password": "Mật khẩu",
"Password reset": "Đặt lại mật khẩu",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Dán tệp khóa JSON của một tài khoản dịch vụ có quyền đọc và ghi đối tượng trong bucket. Tệp được lưu ở dạng mã hóa và không bao giờ hiển thị lại.",
"Path": "Đường dẫn",
"Payload Too Large": "Tải Trọng Quá Lớn",
"Payment Required": "yêu cầu thanh toán",
@@ -724,6 +728,7 @@
"Role name": "Tên vai trò",
"Role updated.": "Đã cập nhật vai trò.",
"Roles": "Vai trò",
"S3-compatible": "Tương thích S3",
"Sample email": "Email mẫu",
"Save": "Lưu",
"Save :name": "Tiết kiệm :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "Gửi ngay một email thật, bỏ qua hàng đợi, để bạn kiểm chứng các cài đặt phía trên có thực sự hoạt động không.",
"Separate paragraphs with a blank line.": "Ngăn cách các đoạn bằng một dòng trống.",
"Server Error": "Máy Chủ Gặp Sự Cố",
"Service account key": "Khóa tài khoản dịch vụ",
"Service Unavailable": "Dịch Vụ Không Khả Dụng",
"Session Has Expired": "Phiên Đã Hết Hạn",
"Set file expiration dates": "Đặt ngày hết hạn cho tệp",
@@ -827,6 +833,8 @@
"Test": "Thử",
"Text": "Văn bản",
"Text or author": "Nội dung hoặc tác giả",
"That does not look like a service account key file: it is not valid JSON.": "Đây có vẻ không phải là tệp khóa tài khoản dịch vụ: nó không phải JSON hợp lệ.",
"That service account key file is missing its :field.": "Tệp khóa tài khoản dịch vụ này thiếu :field.",
"The account of :name will be permanently deleted. This cannot be undone.": "Tài khoản của :name sẽ bị xóa vĩnh viễn. Không thể hoàn tác.",
"The account password was changed": "Mật khẩu tài khoản đã được đổi",
"The account request of :name will be denied and the account deleted.": "Yêu cầu tài khoản của :name sẽ bị từ chối và tài khoản bị xóa.",
+8
View File
@@ -204,6 +204,7 @@
"Confirm password": "确认密码",
"Conflict": "冲突",
"Connect": "连接",
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "连接外部存储桶(S3 兼容 — AWS S3、MinIO、Backblaze — 或 Google Cloud Storage),作为新上传文件的存储后端。",
"Connection Closed Without Response": "连接关闭无响应",
"Connection Timed Out": "连接超时",
"Continue": "继续请求",
@@ -410,6 +411,7 @@
"Go to page :page": "前往第 :page 页",
"Go to the login form": "前往登录页",
"Gone": "不可用",
"Google Cloud Storage": "Google Cloud Storage",
"Gray": "灰色",
"Green": "绿色",
"Group": "群组",
@@ -574,6 +576,7 @@
"No recipients added yet.": "还没有添加收件人。",
"No requests match your search.": "没有符合搜索条件的申请。",
"No roles match this filter.": "没有符合此筛选条件的角色。",
"No service account key has been saved yet.": "尚未保存任何服务账号密钥。",
"No size limit. Uploads can be paused and resume automatically after interruptions.": "不限文件大小。上传可以暂停,中断后会自动续传。",
"No users match these filters.": "没有符合这些筛选条件的用户。",
"No users yet.": "还没有用户。",
@@ -617,6 +620,7 @@
"Partial Content": "部分内容",
"Password": "密码",
"Password reset": "重置密码",
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "粘贴对存储桶内对象拥有读写权限的服务账号 JSON 密钥文件。它会加密保存,并且不会再次显示。",
"Path": "路径",
"Payload Too Large": "请求实体过大",
"Payment Required": "需要付款",
@@ -724,6 +728,7 @@
"Role name": "角色名称",
"Role updated.": "角色已更新。",
"Roles": "角色",
"S3-compatible": "S3 兼容",
"Sample email": "示例邮件",
"Save": "保存",
"Save :name": "保存 :name",
@@ -763,6 +768,7 @@
"Sends a real email right now, bypassing the queue, so you can verify the settings above actually work.": "立即绕过队列发送一封真实邮件,便于你确认上面的设置确实可用。",
"Separate paragraphs with a blank line.": "段落之间用空行分隔。",
"Server Error": "服务器错误",
"Service account key": "服务账号密钥",
"Service Unavailable": "服务不可用",
"Session Has Expired": "会话已过期",
"Set file expiration dates": "设置文件到期日期",
@@ -827,6 +833,8 @@
"Test": "测试",
"Text": "文本",
"Text or author": "内容或作者",
"That does not look like a service account key file: it is not valid JSON.": "这看起来不是服务账号密钥文件:它不是有效的 JSON。",
"That service account key file is missing its :field.": "该服务账号密钥文件缺少 :field。",
"The account of :name will be permanently deleted. This cannot be undone.": ":name 的账户将被永久删除,此操作无法撤销。",
"The account password was changed": "账户密码已修改",
"The account request of :name will be denied and the account deleted.": ":name 的账户申请将被拒绝,账户也会被删除。",
+25 -6
View File
@@ -15,11 +15,26 @@ interface Toast {
const DURATION = 4500;
/**
* The flash object most recently turned into toasts, across remounts.
*
* Both of the component's sources can hand over the *same* visit's flash:
* the layout (and the Toaster inside it) remounts whenever a flashed
* redirect lands on a different page component — create → edit, delete →
* index — and then the mount-time read and the router `success` event
* each fire once for one flash, stacking every "Client created." twice.
* Identity comparison is the dedup that cannot over-trigger: a repeat of
* the same action produces an identical *message* but never the identical
* *object*, so deliberate back-to-back toasts still both show.
*/
let shownFlash: SharedData['flash'] | null = null;
/**
* App-wide flash toasts. Reads the `flash` shared prop and shows a toast
* after any Inertia visit that carried one (created/updated/deleted…).
* Uses the router `success` event so two identical messages in a row still
* each toast. Mounted once in the app layout.
* each toast. Mounted in the app layout, which remounts it — see the note on
* `shownFlash` above.
*/
export function Toaster() {
const { t } = useTranslation();
@@ -36,16 +51,20 @@ export function Toaster() {
const dismiss = (id: number) => setToasts((current) => current.filter((toast) => toast.id !== id));
const pushFlash = (flash: SharedData['flash'] | null | undefined) => {
if (!flash || flash === shownFlash) return;
shownFlash = flash;
push('success', flash.success);
push('error', flash.error);
};
useEffect(() => {
// A flash present on the very first render (e.g. a server redirect on load).
push('success', page.props.flash?.success);
push('error', page.props.flash?.error);
pushFlash(page.props.flash);
// And every subsequent successful visit.
const stop = router.on('success', (event) => {
const flash = (event.detail.page.props as unknown as SharedData).flash;
push('success', flash?.success);
push('error', flash?.error);
pushFlash((event.detail.page.props as unknown as SharedData).flash);
});
return () => stop();
+106 -52
View File
@@ -9,14 +9,17 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useTranslation } from '@/hooks/use-translation';
import AppLayout from '@/layouts/app-layout';
interface StorageSettingsProps {
active: boolean;
provider: string;
access_key: string;
has_secret: boolean;
has_key_file: boolean;
bucket: string;
region: string;
endpoint: string;
@@ -29,8 +32,10 @@ const FORM_ID = 'storage-settings-form';
export default function StorageSettings({
active,
provider,
access_key,
has_secret,
has_key_file,
bucket,
region,
endpoint,
@@ -48,8 +53,10 @@ export default function StorageSettings({
const { data, setData, patch, processing, recentlySuccessful, errors } = useForm({
active: active,
provider: provider,
access_key: access_key,
secret: '',
key_file: '',
bucket: bucket,
region: region,
endpoint: endpoint,
@@ -57,11 +64,16 @@ export default function StorageSettings({
root: root,
});
const isGcs = data.provider === 'gcs';
const submit: FormEventHandler = (e) => {
e.preventDefault();
patch(route('system-settings.storage.update'), {
preserveScroll: true,
onSuccess: () => setData('secret', ''),
onSuccess: () => {
setData('secret', '');
setData('key_file', '');
},
});
};
@@ -70,8 +82,10 @@ export default function StorageSettings({
router.post(
route('system-settings.storage.test'),
{
provider: data.provider,
access_key: data.access_key,
secret: data.secret,
key_file: data.key_file,
bucket: data.bucket,
region: data.region,
endpoint: data.endpoint,
@@ -89,7 +103,7 @@ export default function StorageSettings({
<Heading
title={t('Storage settings')}
description={t(
'Connect an external S3-compatible bucket (AWS S3, MinIO, or another S3-compatible service) as the storage backend for new uploads.',
'Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.',
)}
/>
@@ -111,67 +125,107 @@ export default function StorageSettings({
</div>
</div>
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_access_key">{t('Access key')}</Label>
<Input id="storage_access_key" value={data.access_key} onChange={(e) => setData('access_key', e.target.value)} />
<InputError message={errors.access_key} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_secret">{t('Secret key')}</Label>
<Input
id="storage_secret"
type="password"
placeholder={has_secret ? t('Unchanged') : ''}
value={data.secret}
onChange={(e) => setData('secret', e.target.value)}
/>
<InputError message={errors.secret} />
</div>
<div className="grid gap-2">
<Label htmlFor="storage_provider">{t('Provider')}</Label>
<Select value={data.provider} onValueChange={(value) => setData('provider', value)}>
<SelectTrigger id="storage_provider" className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s3">{t('S3-compatible')}</SelectItem>
<SelectItem value="gcs">{t('Google Cloud Storage')}</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.provider} />
</div>
{isGcs ? (
<div className="grid gap-2">
<Label htmlFor="storage_key_file">{t('Service account key')}</Label>
<p className="text-muted-foreground text-sm">
{t(
'Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.',
)}
</p>
<Textarea
id="storage_key_file"
rows={6}
className="font-mono text-xs"
placeholder={has_key_file ? t('Unchanged') : '{ "type": "service_account", ... }'}
value={data.key_file}
onChange={(e) => setData('key_file', e.target.value)}
/>
<InputError message={errors.key_file} />
</div>
) : (
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_access_key">{t('Access key')}</Label>
<Input id="storage_access_key" value={data.access_key} onChange={(e) => setData('access_key', e.target.value)} />
<InputError message={errors.access_key} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_secret">{t('Secret key')}</Label>
<Input
id="storage_secret"
type="password"
placeholder={has_secret ? t('Unchanged') : ''}
value={data.secret}
onChange={(e) => setData('secret', e.target.value)}
/>
<InputError message={errors.secret} />
</div>
</div>
)}
<div className="flex gap-4">
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_bucket">{t('Bucket')}</Label>
<Input id="storage_bucket" value={data.bucket} onChange={(e) => setData('bucket', e.target.value)} />
<InputError message={errors.bucket} />
</div>
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_region">{t('Region')}</Label>
<Input id="storage_region" value={data.region} onChange={(e) => setData('region', e.target.value)} />
<InputError message={errors.region} />
</div>
{!isGcs && (
<div className="grid flex-1 gap-2">
<Label htmlFor="storage_region">{t('Region')}</Label>
<Input id="storage_region" value={data.region} onChange={(e) => setData('region', e.target.value)} />
<InputError message={errors.region} />
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="storage_endpoint">{t('Custom endpoint')}</Label>
<p className="text-muted-foreground text-sm">
{t('Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.')}
</p>
<Input
id="storage_endpoint"
value={data.endpoint}
onChange={(e) => setData('endpoint', e.target.value)}
placeholder="https://s3.example.com"
/>
<InputError message={errors.endpoint} />
</div>
{!isGcs && (
<>
<div className="grid gap-2">
<Label htmlFor="storage_endpoint">{t('Custom endpoint')}</Label>
<p className="text-muted-foreground text-sm">
{t('Leave blank for AWS S3. Set this to use an S3-compatible service such as MinIO or Backblaze.')}
</p>
<Input
id="storage_endpoint"
value={data.endpoint}
onChange={(e) => setData('endpoint', e.target.value)}
placeholder="https://s3.example.com"
/>
<InputError message={errors.endpoint} />
</div>
<div className="flex items-start gap-2">
<Checkbox
id="use_path_style"
checked={data.use_path_style}
onCheckedChange={(checked) => setData('use_path_style', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="use_path_style" className="font-normal">
{t('Use path-style addressing')}
</Label>
<p className="text-muted-foreground text-sm">
{t('Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.')}
</p>
</div>
</div>
<div className="flex items-start gap-2">
<Checkbox
id="use_path_style"
checked={data.use_path_style}
onCheckedChange={(checked) => setData('use_path_style', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="use_path_style" className="font-normal">
{t('Use path-style addressing')}
</Label>
<p className="text-muted-foreground text-sm">
{t('Required by most S3-compatible services (MinIO, etc). Leave off for AWS S3.')}
</p>
</div>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="storage_root">{t('Path prefix')}</Label>
@@ -251,3 +251,33 @@ test('a part within the size limit is still accepted', function () {
expect($this->actingAs($user)->getJson("/uploads/{$session}/parts")->json())->toHaveCount(1);
});
test('a storage backend that refuses the write fails the upload instead of recording a phantom file', function () {
// Found against a real GCS bucket, not in a test: the disks are
// configured with 'throw' => false, so a refused write returns false
// rather than raising. The assembled bytes were dropped, the upload
// reported success, and a File row was created pointing at an object
// that had never been stored — an upload that silently disappears is
// worse than one that fails.
$this->actingAs($this->admin);
$sessionId = createSession(11, 'assembled.txt');
putPart($sessionId, 1, 'hello-');
putPart($sessionId, 2, 'world');
$refusing = Mockery::mock(Illuminate\Contracts\Filesystem\Filesystem::class);
$refusing->shouldReceive('writeStream')->once()->andReturnFalse();
Storage::set('files', $refusing);
$before = File::query()->count();
// The controller turns a RuntimeException from the assemble step into
// a validation error on `parts`, so the person uploading is told what
// went wrong instead of meeting a 500.
$this->postJson("/uploads/{$sessionId}/complete")
->assertStatus(422)
->assertJsonPath('errors.parts.0', fn (string $message): bool => str_contains($message, 'Could not write the assembled upload'));
// The point of the whole test: no row for bytes that were never stored.
expect(File::query()->count())->toBe($before);
});
+35
View File
@@ -10,6 +10,7 @@ use App\Modules\Files\Models\File;
use App\Modules\Files\Models\ShareLink;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
@@ -164,6 +165,40 @@ test('the public show and download routes work with no authenticated user at all
->and($entry->actor_name)->toBeNull();
});
test('a share link to an externally stored file hands out a presigned url, not an nginx path', function () {
// The bug this covers: this route used to answer every download with
// X-Accel-Redirect regardless of the file's disk, so a share link to a
// file on external storage pointed nginx at a path that does not exist
// on its filesystem. Every other download path already got this right.
Storage::fake('files_external');
Storage::disk('files_external')->buildTemporaryUrlsUsing(
fn (string $path, $expiration, array $options) => 'https://storage.example.test/'.$path.'?disposition='.urlencode($options['ResponseContentDisposition'] ?? '')
);
$file = shareTestFile($this->admin);
$file->update(['disk' => 'files_external']);
$link = ShareLink::query()->create([
'shareable_type' => $file->getMorphClass(),
'shareable_id' => $file->id,
'token' => Str::random(32),
]);
$response = $this->get("/s/{$link->token}/download");
$response->assertRedirect();
$response->assertHeaderMissing('X-Accel-Redirect');
$target = $response->headers->get('Location');
expect($target)->toStartWith('https://storage.example.test/'.$file->path)
// The filename has to survive into the signed URL, or the download
// arrives named after the storage key.
->and(urldecode((string) $target))->toContain('attachment; filename="report.pdf"');
// The link's counter still moves for an external file.
expect($link->refresh()->downloads_count)->toBe(1);
});
test('an unknown token shows a not-found state instead of a 404', function () {
$this->get('/s/does-not-exist')->assertOk()->assertInertia(fn (AssertableInertia $page) => $page->where('status', 'not_found'));
$this->get('/s/does-not-exist/download')->assertRedirect(route('share.show', 'does-not-exist'));
+27
View File
@@ -310,6 +310,33 @@ test('the public thumbnail route generates and serves a thumbnail for a public i
$this->get(route('public.thumbnail', ['public', $privateImage->slug]))->assertNotFound();
});
test('a public thumbnail renders from external storage rather than a local path that does not exist', function () {
// The bug this covers: this route read its *source* through
// Storage::disk('files')->path(), which for an externally stored file
// is a path nothing ever wrote. The rendition is still cached locally
// — only the source moves. FileThumbnailController already handled
// this; the public twin did not.
Storage::fake('files_external');
$staff = User::factory()->create();
$image = publicListingImageFile($staff);
// Restage the bytes where an install with external storage configured
// would have put them, and remove the local copy so a local path
// cannot accidentally satisfy the request.
Storage::disk('files_external')->put($image->path, Storage::disk('files')->get($image->path));
Storage::disk('files')->delete($image->path);
$image->update(['disk' => 'files_external']);
auth()->logout();
$this->get(route('public.thumbnail', ['public', $image->slug]))
->assertOk()
->assertHeader('Content-Type', 'image/jpeg');
expect(Storage::disk('files')->exists("thumbnails/external/{$image->id}.jpg"))->toBeTrue();
});
test('existing literal routes are unaffected by the new catch-all public routes', function () {
$this->actingAs(User::factory()->create())->get('/dashboard')->assertOk();
$this->actingAs(User::factory()->create())->get('/files')->assertOk();
@@ -86,6 +86,35 @@ test('reconnecting a different account at the same provider replaces the link',
->and($links->first()->provider_user_id)->toBe('sub-2');
});
/*
|--------------------------------------------------------------------------
| Starting the exchange
|--------------------------------------------------------------------------
|
| Connecting starts as an Inertia XHR, and an XHR cannot follow a 302 to
| the provider: the browser refuses the cross-origin hop and nobody goes
| anywhere. Inertia's 409 + X-Inertia-Location pair is what turns the
| same answer into a real top-level navigation.
|
*/
test('connecting from the settings screen navigates the browser, not the XHR', function () {
test()->swap(SocialGateway::class, new FakeSocialGateway);
$this->actingAs($this->staff)
->post(route('connected-accounts.connect', ['provider' => 'google']), [], ['X-Inertia' => 'true'])
->assertStatus(409)
->assertHeader('X-Inertia-Location', 'https://provider.test/authorize');
});
test('a plain request is still given the provider redirect itself', function () {
test()->swap(SocialGateway::class, new FakeSocialGateway);
$this->actingAs($this->staff)
->post(route('connected-accounts.connect', ['provider' => 'google']))
->assertRedirect('https://provider.test/authorize');
});
/*
|--------------------------------------------------------------------------
| Disconnecting
@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Storage\ResolvingUploadDisk;
use App\Modules\Platform\Settings\ExternalStorageConfigApplier;
use App\Modules\Platform\Settings\ExternalStorageSettings;
use App\Modules\Platform\Settings\StorageProvider;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->admin = User::factory()->create();
});
/**
* A syntactically real service account key, generated per test.
*
* V4 signing is done locally with the private key — no network, no
* project, no bucket needs to exist — which is what makes the signed-URL
* assertions below real rather than mocked.
*
* @return array<string, string>
*/
function fakeServiceAccountKey(): array
{
$resource = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($resource, $privateKey);
return [
'type' => 'service_account',
'project_id' => 'projectsend-test',
'private_key_id' => 'test-key-id',
'private_key' => $privateKey,
'client_email' => 'projectsend@projectsend-test.iam.gserviceaccount.com',
'client_id' => '1234567890',
];
}
function configureGcs(array $overrides = []): void
{
ExternalStorageSettings::current()->fill([
'active' => true,
'provider' => StorageProvider::Gcs,
'bucket' => 'projectsend-files',
'key_file' => json_encode(fakeServiceAccountKey()),
...$overrides,
])->save();
app(ExternalStorageConfigApplier::class)->flush();
app(ExternalStorageConfigApplier::class)->apply();
}
test('choosing Google Cloud Storage points the external disk at the gcs driver', function () {
configureGcs();
expect(config('filesystems.disks.files_external.driver'))->toBe('gcs')
->and(config('filesystems.disks.files_external.bucket'))->toBe('projectsend-files')
// The key file is decoded for the client, and the S3 leftovers
// from the config stub are cleared rather than left looking like
// configuration.
->and(config('filesystems.disks.files_external.key_file'))->toBeArray()
->and(config('filesystems.disks.files_external.key_file')['client_email'])
->toBe('projectsend@projectsend-test.iam.gserviceaccount.com')
->and(config('filesystems.disks.files_external.key'))->toBeNull()
->and(config('filesystems.disks.files_external.secret'))->toBeNull();
});
test('a temporary url can be generated at all', function () {
// Not a tautology. Laravel's FilesystemAdapter::temporaryUrl() looks
// for a method named getTemporaryUrl() on the adapter; League's GCS
// adapter names its method temporaryUrl(). Without the callback
// registered by GoogleCloudStorageDriver the two never meet and this
// throws "This driver does not support creating temporary URLs" —
// which is every download and every preview on a GCS install.
configureGcs();
$url = Storage::disk('files_external')->temporaryUrl('2026/07/report.pdf', now()->addHour());
expect($url)->toStartWith('https://storage.googleapis.com/projectsend-files/2026/07/report.pdf?')
->and($url)->toContain('X-Goog-Algorithm=GOOG4-RSA-SHA256')
->and($url)->toContain('X-Goog-Signature=');
});
test('the download filename survives into the signed url', function () {
// The failure this covers is silent, which is why it is asserted on
// the URL's contents rather than on "a redirect happened": callers
// speak S3's ResponseContentDisposition, GCS wants responseDisposition,
// and an unrecognised option is dropped without complaint. The symptom
// is a download named after the storage key, and nothing in the logs.
configureGcs();
$url = Storage::disk('files_external')->temporaryUrl(
'2026/07/8f3a-uuid.pdf',
now()->addHour(),
['ResponseContentDisposition' => 'attachment; filename="Quarterly report.pdf"'],
);
expect($url)->toContain('response-content-disposition=')
->and(urldecode($url))->toContain('attachment; filename="Quarterly report.pdf"');
});
test('an option that is already a google name is passed through untranslated', function () {
configureGcs();
$url = Storage::disk('files_external')->temporaryUrl(
'2026/07/report.pdf',
now()->addHour(),
['responseType' => 'application/pdf'],
);
expect($url)->toContain('response-content-type=application%2Fpdf');
});
test('the folder setting prefixes the object path for gcs, the way root does for s3', function () {
configureGcs(['root' => 'projectsend']);
$url = Storage::disk('files_external')->temporaryUrl('2026/07/report.pdf', now()->addHour());
expect($url)->toStartWith('https://storage.googleapis.com/projectsend-files/projectsend/2026/07/report.pdf?');
});
test('new uploads are routed to the external disk once gcs is configured', function () {
configureGcs();
$event = new ResolvingUploadDisk($this->admin);
Event::dispatch($event);
expect($event->disk)->toBe('files_external');
});
test('gcs is judged configured by its key file, not by an access key and secret', function () {
$settings = ExternalStorageSettings::current();
// Everything S3 would need, and nothing GCS needs.
$settings->fill([
'active' => true,
'provider' => StorageProvider::Gcs,
'bucket' => 'projectsend-files',
'key' => 'AKIAEXAMPLE',
'secret' => 'shh',
])->save();
expect($settings->isConfigured())->toBeFalse();
$settings->fill(['key_file' => json_encode(fakeServiceAccountKey())])->save();
expect($settings->fresh()->isConfigured())->toBeTrue();
});
test('the service account key is encrypted at rest', function () {
$key = fakeServiceAccountKey();
ExternalStorageSettings::current()->fill(['key_file' => json_encode($key)])->save();
$raw = DB::table('external_storage_settings')->value('key_file');
expect($raw)->not->toContain('BEGIN PRIVATE KEY')
->and($raw)->not->toContain('iam.gserviceaccount.com')
->and(json_decode((string) ExternalStorageSettings::current()->key_file, true)['private_key'])
->toBe($key['private_key']);
});
test('a file stored on gcs downloads as a redirect to a signed url, not an nginx path', function () {
configureGcs();
$file = File::factory()->create([
'uploaded_by' => $this->admin->id,
'original_name' => 'contract.pdf',
'mime_type' => 'application/pdf',
'path' => '2026/07/contract.pdf',
'disk' => 'files_external',
]);
$response = $this->actingAs($this->admin)->get("/files/{$file->id}/download");
$response->assertRedirect();
$response->assertHeaderMissing('X-Accel-Redirect');
$target = urldecode((string) $response->headers->get('Location'));
expect($target)->toStartWith('https://storage.googleapis.com/projectsend-files/2026/07/contract.pdf?')
->and($target)->toContain('attachment; filename="contract.pdf"');
});
test('staff can save a Google Cloud Storage backend through the settings form', function () {
$key = json_encode(fakeServiceAccountKey());
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'projectsend-files',
'key_file' => $key,
'use_path_style' => false,
])->assertRedirect();
$settings = ExternalStorageSettings::current();
expect($settings->provider)->toBe(StorageProvider::Gcs)
->and($settings->key_file)->toBe($key)
->and($settings->isConfigured())->toBeTrue();
});
test('switching to gcs does not demand an access key or a region', function () {
// The S3 fields are required_if, not required — otherwise selecting
// Google would insist on an AWS region that means nothing to it.
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'projectsend-files',
'key_file' => json_encode(fakeServiceAccountKey()),
'use_path_style' => false,
])->assertSessionHasNoErrors();
});
test('a blank key file keeps the one already stored', function () {
$key = json_encode(fakeServiceAccountKey());
ExternalStorageSettings::current()->fill(['provider' => StorageProvider::Gcs, 'key_file' => $key])->save();
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'a-different-bucket',
'key_file' => '',
'use_path_style' => false,
])->assertRedirect();
expect(ExternalStorageSettings::current()->key_file)->toBe($key)
->and(ExternalStorageSettings::current()->bucket)->toBe('a-different-bucket');
});
test('a key file that is not a service account key is rejected before it can be saved', function () {
// A paste that lost its last line is the likeliest way this goes
// wrong, and the alternative to catching it here is a 500 at the
// first upload with nothing pointing at the cause.
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'projectsend-files',
'key_file' => '{"type": "service_account", "project_id": "demo"',
'use_path_style' => false,
])->assertSessionHasErrors('key_file');
$this->actingAs($this->admin)->patch('/system/settings/storage', [
'active' => true,
'provider' => 'gcs',
'bucket' => 'projectsend-files',
'key_file' => '{"type": "service_account", "project_id": "demo"}',
'use_path_style' => false,
])->assertSessionHasErrors('key_file');
});
test('the settings screen offers the provider choice and says whether a key is stored', function () {
ExternalStorageSettings::current()->fill([
'provider' => StorageProvider::Gcs,
'key_file' => json_encode(fakeServiceAccountKey()),
])->save();
$this->actingAs($this->admin)->get('/system/settings/storage')
->assertInertia(fn (AssertableInertia $page) => $page
->component('system/settings/storage')
->where('provider', 'gcs')
->where('has_key_file', true)
// The key itself is never sent back to the browser.
->missing('key_file'));
});
+6 -2
View File
@@ -21,13 +21,17 @@ test('community edition has the self-management capabilities and no cloud exclus
->and($registry->has(Capability::SystemUpdates))->toBeTrue()
->and($registry->has(Capability::SchedulerMonitoring))->toBeTrue()
->and($registry->has(Capability::CustomAssets))->toBeTrue()
->and($registry->has(Capability::Branding))->toBeFalse();
->and($registry->has(Capability::Branding))->toBeFalse()
// The counterpart of StorageConfigure above: a self-hosted install
// configures its own bucket and is never handed one.
->and($registry->has(Capability::StorageManaged))->toBeFalse();
});
test('cloud edition has cloud exclusives and none of the community-only capabilities', function () {
$registry = new CapabilityRegistry(Edition::Cloud);
expect($registry->has(Capability::Branding))->toBeTrue()
->and($registry->has(Capability::StorageManaged))->toBeTrue()
->and($registry->has(Capability::UsersManage))->toBeFalse()
->and($registry->has(Capability::StorageConfigure))->toBeFalse()
->and($registry->has(Capability::EmailTransportConfigure))->toBeFalse()
@@ -39,5 +43,5 @@ test('cloud edition has cloud exclusives and none of the community-only capabili
test('enabledKeys returns the string keys of enabled capabilities', function () {
$registry = new CapabilityRegistry(Edition::Cloud);
expect($registry->enabledKeys())->toBe(['branding.customize', 'captcha.managed_keys']);
expect($registry->enabledKeys())->toBe(['branding.customize', 'storage.managed', 'captcha.managed_keys']);
});