mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 05:25:51 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e17498a2 | |||
| a459d45c87 | |||
| b22d3cf33c | |||
| f7db586c7e | |||
| 23b7dc0d11 | |||
| daec0a877e | |||
| 57540164fa | |||
| 457fed0c86 | |||
| 4f38c9adee | |||
| f7d6fe929e | |||
| 1030f719fc | |||
| fbd6c3603d | |||
| 5bfc5a0883 | |||
| 94e4aa36e4 |
+45
-25
@@ -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.
|
||||
|
||||
@@ -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:'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('external_storage_settings', function (Blueprint $table) {
|
||||
// Every row that exists predates the choice, and every one of
|
||||
// them 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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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à.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 のアカウント申請を却下し、アカウントを削除します。",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 на учётную запись будет отклонена, а сама запись удалена.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 的账户申请将被拒绝,账户也会被删除。",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user