mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-03 21:03:17 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55e17498a2 | |||
| a459d45c87 | |||
| b22d3cf33c | |||
| f7db586c7e | |||
| 23b7dc0d11 | |||
| daec0a877e | |||
| 57540164fa | |||
| 457fed0c86 | |||
| 4f38c9adee | |||
| f7d6fe929e | |||
| 1030f719fc | |||
| fbd6c3603d | |||
| 5bfc5a0883 | |||
| 94e4aa36e4 | |||
| 027e8532d2 | |||
| 2a82335e07 | |||
| 1aaab1bf66 | |||
| 503676647f | |||
| 5a7c9938dd | |||
| d1d1999216 | |||
| 51eea30dda | |||
| c18f2f0f73 | |||
| 3d6089a501 | |||
| 8f12c83d21 | |||
| 11e6876826 | |||
| 88c182cf3b | |||
| 30f66cff2b | |||
| cca3d9c314 | |||
| 7d1903f9db | |||
| 6b76c11192 | |||
| 283c79bcd6 | |||
| 1b6513f0fb | |||
| cb67a15e81 | |||
| 1c62036ed2 | |||
| 5e23474e8b | |||
| 202a1d7ad5 | |||
| 8b5d480670 | |||
| e279e83fd0 | |||
| 318550f866 | |||
| 9c991f495d |
@@ -61,6 +61,15 @@ SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Set this only if your web server and PHP-FPM run as different system
|
||||
# users — common on cPanel/Plesk shared hosting. Uploaded files are
|
||||
# written 0600 in 0700 directories, which nginx cannot read, and since
|
||||
# nginx is what actually streams a download (PHP authorizes, then hands
|
||||
# it the path) every download fails while the rest of the site works.
|
||||
# Relaxes those to 0644/0755, which every account on the machine can
|
||||
# read — leave it off if your web server and PHP are the same user.
|
||||
# FILES_WEB_SERVER_READABLE=true
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
CACHE_STORE=redis
|
||||
|
||||
+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:'
|
||||
|
||||
+109
@@ -13,6 +13,115 @@ 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
|
||||
the file, because uploads are written readable only by the account that wrote them. The rest of
|
||||
the site gave no sign of it: uploading worked, the library listed everything, and only downloads
|
||||
failed, in the browser as `ERR_INVALID_RESPONSE`. Setting `FILES_WEB_SERVER_READABLE=true` now
|
||||
writes uploads so the web server can read them. It is opt-in, and deliberately so — the modes it
|
||||
uses are readable by every account on the machine, which is the wrong trade on a server where the
|
||||
web server and PHP are the same user, as they are in the Docker image and on most servers people
|
||||
set up themselves. The install guide has the full procedure, including the one thing no
|
||||
application setting can fix: a PHP-FPM pool with a restrictive umask, which caps new directories
|
||||
no matter what ProjectSend asks for.
|
||||
([#1668](https://github.com/projectsend/projectsend/issues/1668), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
|
||||
- **An installation that builds its own containers is no longer told to pull.** ProjectSend prints
|
||||
the update instructions for the way you installed it, and it had two answers where it needed
|
||||
three: anything running in a container was handed `docker compose pull && docker compose up -d`,
|
||||
including the Compose stack that builds from a checkout of the repository. There is no image
|
||||
behind those containers to pull, so both commands ran, reported success and changed nothing — and
|
||||
the dashboard went on offering the same release. Those installations are now recognised and given
|
||||
`git pull && docker compose up -d --build` instead, with the two extra steps a checkout needs when
|
||||
a release moves its dependencies or its frontend.
|
||||
([#1661](https://github.com/projectsend/projectsend/issues/1661), reported by
|
||||
[@mueller7382](https://github.com/mueller7382))
|
||||
|
||||
- **The dashboard no longer fails on shared hosting.** To decide which update instructions to print,
|
||||
ProjectSend asks whether it is running inside a container by looking for a file in the root of the
|
||||
filesystem. On shared hosting PHP is usually confined to your own directory, and looking outside it
|
||||
is treated as an error rather than as a "no" — so the one page that asks the question, the
|
||||
dashboard, returned a 500 while every other page worked. It now takes the restriction as the answer
|
||||
it always was: a server that keeps PHP inside a single directory is not our container image, and
|
||||
gets the manual update instructions, which is correct for shared hosting anyway. Nothing to change
|
||||
on your side, and no setting you would have been able to change if there were.
|
||||
([#1663](https://github.com/projectsend/projectsend/issues/1663), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
|
||||
- **502 Bad Gateway behind a reverse proxy.** Every page carried a `Link:` header listing its
|
||||
frontend assets, duplicating tags the page already had in its `<head>` — twenty of them on the
|
||||
login screen, more on a heavier page. nginx buffers a response's headers into a single block that
|
||||
defaults to 4 KB, so the file list, at over 6 KB of headers, was refused with `upstream sent too
|
||||
big header` and the proxy answered 502. Which pages went over depended on how many assets they
|
||||
loaded, so it looked like an intermittent fault: the login screen appeared, and then the
|
||||
application did not. The duplicate header is gone — the same pages now send under 1.3 KB — and no
|
||||
browser loses anything, because the tags it actually reads were always in the document. The
|
||||
install guide gained the proxy buffer settings for anyone on an older version or behind a proxy
|
||||
holding a tighter default.
|
||||
([#1664](https://github.com/projectsend/projectsend/issues/1664), reported by
|
||||
[@denkfabrik-li](https://github.com/denkfabrik-li))
|
||||
|
||||
- **`docker logs` now shows the web server's log.** The container runs nginx, PHP-FPM, the queue
|
||||
worker and the scheduler, and all of them reported to Docker except the one you need when a
|
||||
request fails: nginx opened the log files named in its own configuration and wrote to them inside
|
||||
the container, where nothing looks. The effect was that a proxy problem produced no logs on either
|
||||
side — the reason for every 502 and every 403 existed, in a file nobody knew to open. Both its
|
||||
access and error logs now go to the container's output, and the Docker guide has a section on
|
||||
running behind a reverse proxy that says which side a given message points at.
|
||||
|
||||
## 2.1.0 — 18 August 2026
|
||||
|
||||
Updating, mostly. ProjectSend now tells you when there is a new version, ends an update somewhere
|
||||
|
||||
@@ -8,20 +8,37 @@ data with it.
|
||||
|
||||
Read this before you put real files in ProjectSend, not after.
|
||||
|
||||
> Getting started with Docker in the first place is covered in [README](README.md#getting-started).
|
||||
> **This page is about the official image**, `projectsend/projectsend`, started from the
|
||||
> `compose.example.yaml` in [Getting started](README.md#getting-started). That is the supported way
|
||||
> to run it.
|
||||
>
|
||||
> A **clone of this repository is a development copy, not an installation** — it builds from source,
|
||||
> bind-mounts the working tree, and ships nothing pre-built. If that is what you are running, its
|
||||
> setup and its data layout are [CONTRIBUTING.md](CONTRIBUTING.md), not this page.
|
||||
>
|
||||
> Installing without Docker, on a plain PHP server, is [INSTALL.md](INSTALL.md).
|
||||
|
||||
---
|
||||
|
||||
## The three things that matter
|
||||
## The two things that matter
|
||||
|
||||
Everything ProjectSend cannot regenerate lives in exactly three places:
|
||||
Everything ProjectSend cannot regenerate lives in exactly two Docker volumes:
|
||||
|
||||
| What | Where it is by default | Losing it means |
|
||||
|---|---|---|
|
||||
| **The database** | A Docker *named volume*, `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log |
|
||||
| **Uploaded files** | `storage/app/files/` in the project directory | The files your clients downloaded — gone |
|
||||
| **`.env`** | The project directory | `APP_KEY`, without which saved SMTP and LDAP passwords cannot be decrypted |
|
||||
| **The database** | The volume mounted at `/var/lib/mysql` — `projectsend_db-data` | Everything except the files themselves: accounts, groups, permissions, share links, comments, the activity log |
|
||||
| **Uploaded files, and `APP_KEY`** | The volume mounted at `/var/www/html/storage` — `projectsend_storage` | The files your clients downloaded, and the key that decrypts saved SMTP and LDAP passwords |
|
||||
|
||||
The second one is the one people get wrong, because it is two things in one place. The container
|
||||
generates `.env` on first boot and keeps it *on the storage volume*, at `storage/.env`, symlinked
|
||||
into place — precisely so `APP_KEY` survives the container being replaced. A key that changes
|
||||
between restarts signs everybody out and makes every encrypted column permanently unreadable, and
|
||||
nothing errors when it happens. Back up the volume and you have both halves; back up only
|
||||
`storage/app/files/` and you have the files without the key.
|
||||
|
||||
(If you set `APP_KEY` in the environment instead, Laravel reads it from there and it wins. That is
|
||||
the right move when you already manage secrets somewhere else — but then it is *that* system's
|
||||
backup you are relying on.)
|
||||
|
||||
You do not have to work out which of these you have from memory. **The dashboard's System panel
|
||||
reports where your uploaded files actually live** — a host directory, a Docker volume (named), or
|
||||
@@ -36,14 +53,14 @@ Two things you may be surprised to find you do **not** need to protect:
|
||||
everyone out and drops any not-yet-sent emails or half-built zips. Annoying; not data loss.
|
||||
- **Parts of `storage/app/files/`** are derived, not precious: `zips/` (built downloads, deleted
|
||||
automatically after a day), `thumbnails/` and `previews/` (rebuilt on demand the next time
|
||||
somebody looks at a file). They sit in the same directory as the real uploads, so the simplest
|
||||
thing is to back up all of it and not think about which is which.
|
||||
somebody looks at a file). They sit inside the volume you are backing up anyway, so the simplest
|
||||
thing is to take all of it and not think about which is which.
|
||||
|
||||
## The good news, and the one command to fear
|
||||
|
||||
Named volumes are already outside the container lifecycle. `docker compose down`,
|
||||
`docker compose up --build`, deleting and recreating every container — none of those touch
|
||||
`projectsend_db-data`. Upgrading does not lose your database, and never did.
|
||||
Named volumes are already outside the container lifecycle. `docker compose pull`,
|
||||
`docker compose down`, deleting and recreating every container — none of those touch
|
||||
`projectsend_db-data` or `projectsend_storage`. Upgrading does not lose your data, and never did.
|
||||
|
||||
The command that *does* destroy it is:
|
||||
|
||||
@@ -52,73 +69,185 @@ docker compose down -v # ← the -v deletes the named volumes
|
||||
```
|
||||
|
||||
That flag exists to clean up a development machine. On a real installation it deletes your entire
|
||||
database in about a second, with no confirmation. The same goes for `docker volume prune` and
|
||||
`docker system prune --volumes` when the stack happens to be down.
|
||||
database and every uploaded file in about a second, with no confirmation. The same goes for
|
||||
`docker volume prune` and `docker system prune --volumes` when the stack happens to be down.
|
||||
|
||||
So the actual problem with the default setup is not fragility, it is **invisibility**: your
|
||||
database is somewhere under `/var/lib/docker/volumes/`, which means most people never back it up
|
||||
and would not know where to look. The rest of this page fixes that.
|
||||
So the actual problem with the default setup is not fragility, it is **invisibility**: your data is
|
||||
somewhere under `/var/lib/docker/volumes/`, which means most people never back it up and would not
|
||||
know where to look. The rest of this page fixes that.
|
||||
|
||||
---
|
||||
|
||||
## Surviving a reboot
|
||||
|
||||
Every service needs a restart policy, or the Docker daemon will not start it again when the host
|
||||
comes back:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
restart: unless-stopped
|
||||
db:
|
||||
restart: unless-stopped
|
||||
redis:
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
`compose.example.yaml` already has this on all three. It is worth checking if you wrote your own
|
||||
compose file, because the failure is silent and delayed: the stack works perfectly until the first
|
||||
reboot or power cut, and then the site is simply down with no error anywhere. `depends_on` does not
|
||||
cover this — it applies to `docker compose up`, not to containers the daemon brings back at boot.
|
||||
|
||||
```sh
|
||||
docker compose ps -a # after a reboot, everything should be Up, not Exited (0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Behind a reverse proxy
|
||||
|
||||
Almost nobody exposes the container directly: there is a proxy in front terminating TLS — Nginx
|
||||
Proxy Manager, Traefik, Caddy, or an nginx vhost you wrote. Two things are worth setting before you
|
||||
go looking for a bug that isn't there.
|
||||
|
||||
### Tell ProjectSend the proxy is there
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
TRUSTED_PROXIES: "*"
|
||||
```
|
||||
|
||||
Without it every visitor appears to come from the proxy. The login rate limiter then treats all of
|
||||
your users as one attacker, and the download log records the proxy's address instead of the
|
||||
person's. `compose.example.yaml` already sets this.
|
||||
|
||||
Leaving it unset does not cause a `502` — that means your proxy could not get a usable response out
|
||||
of the container at all, which is a different problem with a different fix. It does cause a **419
|
||||
"page expired"**. Without it the application never learns the proxy terminated TLS, so it builds
|
||||
its links and redirects with `http://` while the browser is on `https://`, and marks the session
|
||||
cookie as non-secure. The browser declines to send that cookie back to what it now reads as a
|
||||
different, less secure origin, the session arrives empty, and the first thing you submit — usually
|
||||
the create-your-admin-account form — is rejected as a stale token. After that you get returned to
|
||||
the login screen at random, because each redirect leaves and re-enters over the wrong scheme.
|
||||
|
||||
Your proxy also has to pass the original `Host` header through, or the links come out naming the
|
||||
container instead of your domain. Most do by default: `passHostHeader=true` in Traefik,
|
||||
`proxy_set_header Host $host;` in nginx.
|
||||
|
||||
### Give the proxy header headroom
|
||||
|
||||
If you are running a version before this one, some pages — the dashboard and the file list first —
|
||||
can send a response header block larger than the 4 KB single page nginx buffers headers into by
|
||||
default, and the proxy answers `502 Bad Gateway`. Because it depends on the page, it looks like an
|
||||
intermittent fault rather than a setting: the login screen loads, and then the application does not.
|
||||
The proxy's own error log names it exactly:
|
||||
|
||||
```
|
||||
upstream sent too big header while reading response header from upstream
|
||||
```
|
||||
|
||||
ProjectSend no longer sends headers that large. On an older version, or behind any proxy holding a
|
||||
default that tight, raise them:
|
||||
|
||||
```nginx
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 8 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
```
|
||||
|
||||
In Nginx Proxy Manager that goes in the **Advanced** tab of the proxy host. Traefik and Caddy have
|
||||
their own spellings; the idea is the same.
|
||||
|
||||
### When something does go wrong, read the container's log
|
||||
|
||||
The app container logs everything — nginx, PHP-FPM, the queue worker and the scheduler — to Docker:
|
||||
|
||||
```sh
|
||||
docker compose logs -f app
|
||||
docker compose logs --since 30m app | grep -iE "error|upstream|502"
|
||||
```
|
||||
|
||||
nginx's line is the one that matters for a proxy problem, because it says which side failed.
|
||||
`connect() failed` or `upstream timed out` means the request reached the container and PHP was the
|
||||
problem. **Nothing at all**, while your proxy reports a 502, means the request never arrived — look
|
||||
at the proxy, the network between them, and the published port, not at ProjectSend.
|
||||
|
||||
The container also answers a cheap health endpoint that touches neither the database nor Redis, which
|
||||
is the quickest way to separate "the app is down" from "the proxy cannot reach the app". Run both
|
||||
during an outage, from the same machine:
|
||||
|
||||
```sh
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://<host-ip>:8080/up # straight at the container
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://files.example.com/up
|
||||
```
|
||||
|
||||
Docker records the same check every 30 seconds, so there is a history to read after the fact:
|
||||
|
||||
```sh
|
||||
docker inspect --format 'restarts={{.RestartCount}} oom={{.State.OOMKilled}} health={{.State.Health.Status}}' $(docker compose ps -q app)
|
||||
```
|
||||
|
||||
A non-zero `restarts`, or `oom=true`, means the container is dying and coming back rather than
|
||||
misbehaving — check memory. `compose.example.yaml` sets no limits, and MySQL, Redis and up to ten
|
||||
PHP-FPM workers add up on a small VPS.
|
||||
|
||||
---
|
||||
|
||||
## Putting the data where you chose
|
||||
|
||||
Bind-mount both to real paths on the host, so your data sits somewhere you picked, somewhere you
|
||||
can see in `ls`, and somewhere your existing backup tool already knows about.
|
||||
Bind-mount both volumes to real paths on the host, so your data sits somewhere you picked, somewhere
|
||||
you can see in `ls`, and somewhere your existing backup tool already knows about.
|
||||
|
||||
### 1. Make the directories
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /srv/projectsend/files /srv/projectsend/mysql
|
||||
|
||||
# The app containers run as uid 1000 by default (the WWWUSER build argument).
|
||||
# If you set WWWUSER to something else in .env, use that instead.
|
||||
sudo chown -R 1000:1000 /srv/projectsend/files
|
||||
sudo mkdir -p /srv/projectsend/storage /srv/projectsend/mysql
|
||||
```
|
||||
|
||||
Leave `/srv/projectsend/mysql` owned by root — the MySQL image sets its own ownership the first
|
||||
time it starts.
|
||||
No `chown` needed for either. The ProjectSend container recreates the directory tree it needs on
|
||||
every boot and sets its own ownership (uid 1000), precisely because a bind-mounted host directory
|
||||
arrives empty where a named volume arrives seeded from the image. The MySQL image does the same for
|
||||
its own directory the first time it starts.
|
||||
|
||||
### 2. Create `compose.override.yaml`
|
||||
### 2. Point the compose file at them
|
||||
|
||||
Next to `compose.yaml`. Docker Compose reads this file automatically and merges it on top, so you
|
||||
never edit the tracked `compose.yaml` and nothing you write here is lost on the next update.
|
||||
`compose.example.yaml` is yours — you downloaded and edited it — so change the volumes in place
|
||||
rather than layering an override on top:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
# All four app containers must see the same files directory. Missing one of
|
||||
# them is the classic mistake: uploads land in one place and downloads are
|
||||
# served from another, so every download 404s. `web` is the one people
|
||||
# forget — nginx serves the bytes itself, from
|
||||
# /var/www/html/storage/app/files/, so it needs the mount just as much as
|
||||
# the container that wrote them.
|
||||
app:
|
||||
volumes:
|
||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
||||
web:
|
||||
volumes:
|
||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
||||
worker:
|
||||
volumes:
|
||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
||||
scheduler:
|
||||
volumes:
|
||||
- /srv/projectsend/files:/var/www/html/storage/app/files
|
||||
# Was: storage:/var/www/html/storage
|
||||
- /srv/projectsend/storage:/var/www/html/storage
|
||||
|
||||
db:
|
||||
volumes:
|
||||
# Was: db-data:/var/lib/mysql
|
||||
- /srv/projectsend/mysql:/var/lib/mysql
|
||||
```
|
||||
|
||||
Check the result before applying it — this prints the fully merged configuration:
|
||||
Mount the whole `storage` directory, not `storage/app/files` inside it. Uploads are only half of
|
||||
what lives there — `storage/.env` holds `APP_KEY`, and mounting one level too deep leaves the key
|
||||
back inside the container where the next `docker compose down` takes it.
|
||||
|
||||
Then drop `storage:` and `db-data:` from the `volumes:` block at the bottom, if nothing else uses
|
||||
them, and check the result before applying it — this prints the fully merged configuration:
|
||||
|
||||
```sh
|
||||
docker compose config
|
||||
```
|
||||
|
||||
### 3. Move the data you already have
|
||||
### 3. Move an existing install's data onto the new paths
|
||||
|
||||
**Skip this on a brand-new installation.** There is nothing to move; go straight to step 4.
|
||||
This step is only for an install that has **already been running** on the named volumes and is now
|
||||
moving to the host paths you just chose. It moves ProjectSend's own storage and database, nothing
|
||||
else.
|
||||
|
||||
**Skip it on a brand-new installation** — there is nothing to move; go straight to step 4. That
|
||||
includes an install you are about to migrate ProjectSend Legacy (v1) into: those files and that
|
||||
database come across later, through the migration tool, and the new install has to be empty when
|
||||
they do. See [MIGRATING-FROM-V1.md](MIGRATING-FROM-V1.md).
|
||||
|
||||
Stop everything first. Copying a database out from under a running MySQL is how you get a backup
|
||||
that restores into a corrupt table.
|
||||
@@ -127,25 +256,22 @@ that restores into a corrupt table.
|
||||
docker compose down # no -v
|
||||
```
|
||||
|
||||
Files, which are already on the host inside the project directory:
|
||||
A throwaway container is the tidy way to reach inside a named volume:
|
||||
|
||||
```sh
|
||||
sudo rsync -a storage/app/files/ /srv/projectsend/files/
|
||||
sudo chown -R 1000:1000 /srv/projectsend/files
|
||||
```
|
||||
docker run --rm \
|
||||
-v projectsend_storage:/from \
|
||||
-v /srv/projectsend/storage:/to \
|
||||
alpine sh -c 'cd /from && cp -a . /to'
|
||||
|
||||
The database, which is in the named volume. A throwaway container is the tidy way to reach inside
|
||||
one:
|
||||
|
||||
```sh
|
||||
docker run --rm \
|
||||
-v projectsend_db-data:/from \
|
||||
-v /srv/projectsend/mysql:/to \
|
||||
alpine sh -c 'cd /from && cp -a . /to'
|
||||
```
|
||||
|
||||
(`projectsend_db-data` is the volume's real name — the `db-data` from `compose.yaml` prefixed with
|
||||
the project name. `docker volume ls` will confirm it.)
|
||||
(Those are the volumes' real names — the `storage` and `db-data` from your compose file, prefixed
|
||||
with the project name. `docker volume ls` will confirm them.)
|
||||
|
||||
### 4. Start, and check
|
||||
|
||||
@@ -155,14 +281,22 @@ docker compose up -d
|
||||
|
||||
Then prove it worked rather than assuming: log in and check the dashboard's System panel — **Files
|
||||
stored on** should now read *Host directory*, and the Docker-volume warning should be gone. Then
|
||||
open a file, **download it**, and upload a new one; confirm the new upload appears in
|
||||
`/srv/projectsend/files/` on the host. A download that returns nothing means one of the four
|
||||
containers is missing the mount from step 2.
|
||||
open a file, **download it**, and upload a new one; confirm the new upload appears under
|
||||
`/srv/projectsend/storage/app/files/` on the host.
|
||||
|
||||
Once you are satisfied, and not before, you can reclaim the old volume:
|
||||
Confirm the key came across too, since that is the half nothing on screen will tell you about:
|
||||
|
||||
```sh
|
||||
docker volume rm projectsend_db-data
|
||||
grep '^APP_KEY=' /srv/projectsend/storage/.env
|
||||
```
|
||||
|
||||
If that is empty or missing while your database has saved SMTP or LDAP credentials, stop and go
|
||||
back — the container will generate a *new* key and those passwords become unreadable.
|
||||
|
||||
Once you are satisfied, and not before, you can reclaim the old volumes:
|
||||
|
||||
```sh
|
||||
docker volume rm projectsend_storage projectsend_db-data
|
||||
```
|
||||
|
||||
---
|
||||
@@ -178,37 +312,41 @@ copy of a live data directory is not a snapshot — it is a set of files capture
|
||||
different moments, and it may restore into something subtly broken. Use a dump:
|
||||
|
||||
```sh
|
||||
docker compose exec -T db \
|
||||
mysqldump -u root -p"${DB_ROOT_PASSWORD:-root}" \
|
||||
docker compose exec -T db sh -c \
|
||||
'mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" \
|
||||
--single-transaction --routines --triggers \
|
||||
projectsend > projectsend-$(date +%F).sql
|
||||
projectsend' > projectsend-$(date +%F).sql
|
||||
```
|
||||
|
||||
`--single-transaction` is what makes this safe on a running database: the dump sees one consistent
|
||||
moment in time without locking anybody out.
|
||||
moment in time without locking anybody out. Reading the password from the container's own
|
||||
environment keeps it off your shell history and off the process list on the host.
|
||||
|
||||
### The files
|
||||
### The files, and the key
|
||||
|
||||
```sh
|
||||
rsync -a /srv/projectsend/files/ /your/backup/location/files/
|
||||
rsync -a /srv/projectsend/storage/ /your/backup/location/storage/
|
||||
```
|
||||
|
||||
Ordinary files, no special handling. Restoring means copying them back and fixing ownership
|
||||
(`chown -R 1000:1000`).
|
||||
Ordinary files, no special handling — and taking the whole directory is what picks up `.env` with
|
||||
`APP_KEY` in it. That file is a few hundred bytes and it is the difference between a perfect backup
|
||||
and one where the SMTP and LDAP passwords in your database are undecryptable.
|
||||
|
||||
### `.env`
|
||||
If you kept the named volume instead of bind-mounting, the same content comes out through a
|
||||
throwaway container:
|
||||
|
||||
Copy it somewhere safe, once, and again whenever you change it. It is a few hundred bytes and it
|
||||
holds `APP_KEY` — lose that and the SMTP and LDAP passwords stored in your database become
|
||||
undecryptable, even though the rest of the backup is perfect.
|
||||
```sh
|
||||
docker run --rm -v projectsend_storage:/from -v "$PWD":/to \
|
||||
alpine tar czf /to/projectsend-storage-$(date +%F).tar.gz -C /from .
|
||||
```
|
||||
|
||||
### Restoring
|
||||
|
||||
```sh
|
||||
docker compose up -d db
|
||||
docker compose exec -T db mysql -u root -p"${DB_ROOT_PASSWORD:-root}" projectsend < projectsend-2026-08-08.sql
|
||||
sudo rsync -a /your/backup/location/files/ /srv/projectsend/files/
|
||||
sudo chown -R 1000:1000 /srv/projectsend/files
|
||||
docker compose exec -T db sh -c \
|
||||
'mysql -u root -p"$MYSQL_ROOT_PASSWORD" projectsend' < projectsend-2026-08-08.sql
|
||||
sudo rsync -a /your/backup/location/storage/ /srv/projectsend/storage/
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -222,19 +360,17 @@ restored is a hypothesis, not a backup.
|
||||
With the data outside the containers, an upgrade touches only the containers:
|
||||
|
||||
```sh
|
||||
docker compose down # again: no -v
|
||||
git pull # or unpack the new release over the directory
|
||||
docker compose up -d --build
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app container runs `php artisan projectsend:update` itself on boot — the same command a
|
||||
manual install runs — so it migrates the database and verifies its reference data with no separate
|
||||
step. Take a database dump first anyway — migrations move forwards, not
|
||||
backwards, and the one time you skip it will be the time you want it.
|
||||
That is the whole procedure. The container runs `php artisan projectsend:update` itself on boot —
|
||||
the same command a manual install runs — so it migrates the database and verifies its reference data
|
||||
with no separate step. Take a database dump first anyway: migrations move forwards, not backwards,
|
||||
and the one time you skip it will be the time you want it.
|
||||
|
||||
If you run the published image rather than building your own, it is `docker compose pull` followed
|
||||
by `docker compose up -d`. Either way, **[UPDATE.md](UPDATE.md)** has the whole procedure: what the
|
||||
container does on its way up, how to tell it worked, and what to do when it does not.
|
||||
**[UPDATE.md](UPDATE.md)** has the rest: what the container does on its way up, how to tell it
|
||||
worked, and what to do when it does not.
|
||||
|
||||
---
|
||||
|
||||
@@ -243,10 +379,14 @@ container does on its way up, how to tell it worked, and what to do when it does
|
||||
This is the payoff for everything above, and it is worth doing once deliberately so you know it
|
||||
works:
|
||||
|
||||
1. Dump the database and copy `/srv/projectsend/`, `.env` and the dump to the new machine.
|
||||
2. Install Docker, put the project directory in place, restore both as described under
|
||||
1. Dump the database, and copy `/srv/projectsend/` (or the storage tarball) and the dump to the new
|
||||
machine.
|
||||
2. Install Docker, put your `compose.yaml` in place, restore both as described under
|
||||
[Restoring](#restoring).
|
||||
3. Point DNS at the new machine, and update `APP_URL` in `.env` if the address changed.
|
||||
3. Point DNS at the new machine, and update `APP_URL` in your compose file if the address changed.
|
||||
|
||||
No export tool, no vendor involvement, nothing that only works while the old machine is alive.
|
||||
That is the property worth protecting, and the reason this page exists.
|
||||
Bring `APP_KEY` across with the storage directory — a fresh key on the new machine leaves the site
|
||||
working and the saved mail and LDAP passwords silently broken.
|
||||
|
||||
No export tool, no vendor involvement, nothing that only works while the old machine is alive. That
|
||||
is the property worth protecting, and the reason this page exists.
|
||||
|
||||
+111
-27
@@ -68,8 +68,23 @@ instruction PHP just gave. There is no setting to change; the header names simpl
|
||||
Two ways out, if nginx really is impossible on your hosting:
|
||||
|
||||
- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works
|
||||
but is more moving parts than just using nginx.
|
||||
- Store your files in S3-compatible object storage instead (see
|
||||
but is more moving parts than just using nginx. Give the proxy some header headroom while you are
|
||||
there — the same headroom the reference configuration in Step 6 gives PHP-FPM, in the directives a
|
||||
proxy uses instead:
|
||||
|
||||
```nginx
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 8 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
```
|
||||
|
||||
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
|
||||
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
|
||||
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
|
||||
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 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
|
||||
@@ -182,6 +197,66 @@ sudo chown -R www-data:www-data /var/www/projectsend
|
||||
sudo chmod -R 775 /var/www/projectsend/storage /var/www/projectsend/bootstrap/cache
|
||||
```
|
||||
|
||||
### If your web server and PHP-FPM are different users
|
||||
|
||||
Check before you go further, because the symptom is misleading:
|
||||
|
||||
```sh
|
||||
ps -o user= -C nginx | sort -u # the web server's user
|
||||
ps -o user= -C php-fpm | sort -u # PHP's user
|
||||
```
|
||||
|
||||
Most servers you set up yourself run both as `www-data` and there is nothing to do here. Managed
|
||||
panels often do not — cPanel and Plesk commonly give each site its own PHP user while nginx runs as
|
||||
its own. If the two differ, add this to your `.env`:
|
||||
|
||||
```dotenv
|
||||
FILES_WEB_SERVER_READABLE=true
|
||||
```
|
||||
|
||||
Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote
|
||||
them. That is deliberate, and on a same-user server it is the safer setting. But a download is not
|
||||
served by PHP: PHP checks permissions and then hands the web server the path with `X-Accel-Redirect`
|
||||
(see [Why nginx](#why-nginx)), so the web server has to open a file PHP owns. When it cannot, **the
|
||||
whole site works and only downloads fail** — the browser reports `ERR_INVALID_RESPONSE` and the
|
||||
nginx error log says:
|
||||
|
||||
```
|
||||
open() ".../storage/app/files/..." failed (13: Permission denied)
|
||||
```
|
||||
|
||||
The setting relaxes new uploads to `0644`/`0755`. Be aware of what that means on a shared machine:
|
||||
those modes are readable by *every* account on the server, not only by the web server. The files stay
|
||||
off the web — the `internal` directive in Step 6 sees to that — but they are no longer private from
|
||||
your neighbours, so leave this off unless you need it.
|
||||
|
||||
Files already on disk keep the permissions they were written with, so fix those once:
|
||||
|
||||
```sh
|
||||
sudo find /var/www/projectsend/storage/app/files -type d -exec chmod 755 {} +
|
||||
sudo find /var/www/projectsend/storage/app/files -type f -exec chmod 644 {} +
|
||||
```
|
||||
|
||||
**Then check that new uploads keep it.** Upload a file and look at the directory it landed in:
|
||||
|
||||
```sh
|
||||
ls -ld /var/www/projectsend/storage/app/files/*/*
|
||||
```
|
||||
|
||||
If it is `drwxr-xr-x` you are done. If it is still `drwx------`, your PHP-FPM pool runs with a
|
||||
restrictive umask, and no application setting can beat it: ProjectSend asks for `0755`, but the
|
||||
directory is created by `mkdir()`, and `mkdir()` masks whatever mode it is given with the umask of
|
||||
the process. (Files are unaffected — they are set explicitly after being written, so they are `0644`
|
||||
either way.) Fix it in the pool configuration, not here:
|
||||
|
||||
```ini
|
||||
; /etc/php/8.4/fpm/pool.d/your-pool.conf — the path varies by panel
|
||||
php_admin_value[umask] = 0022
|
||||
```
|
||||
|
||||
Some panels expose this as a "umask" field instead. Restart PHP-FPM afterwards, then re-run the
|
||||
`chmod` above for anything uploaded in the meantime.
|
||||
|
||||
## Step 5 — Prepare the application
|
||||
|
||||
Three commands. Run them from the install directory, as the web server's user, so that everything
|
||||
@@ -372,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
|
||||
|
||||
@@ -388,28 +475,16 @@ sudo -u www-data php artisan event:cache
|
||||
You only run these once: `projectsend:update` notices they are in place and rebuilds them for you
|
||||
after every update. If you change your mind, `php artisan optimize:clear` undoes all three.
|
||||
|
||||
#### One command to skip: `config:cache`
|
||||
#### `config:cache` and your `.env`
|
||||
|
||||
Every Laravel deployment guide on the internet lists `php artisan config:cache` alongside those
|
||||
three, and `php artisan optimize` runs it for you. **Don't** — not on this application.
|
||||
Every Laravel deployment guide also lists `php artisan config:cache`, and `php artisan optimize`
|
||||
runs it for you. It is safe here, with one thing to remember.
|
||||
|
||||
Here is why. Caching the configuration writes every resolved setting into one PHP file, and from
|
||||
then on the framework stops reading your `.env` at all, on the entirely reasonable grounds that
|
||||
everything in it has already been baked in. That holds for settings read the normal way, through
|
||||
`config()`. ProjectSend reads one value earlier than that — `TRUSTED_PROXIES`, which has to be
|
||||
known before the middleware stack is assembled, so it is read straight from the environment. Cache
|
||||
the config and that read returns nothing.
|
||||
|
||||
Nothing breaks loudly. The site comes up, you log in, everything looks fine. But if there is a
|
||||
proxy or CDN in front of the server, ProjectSend goes back to believing every visitor is the proxy:
|
||||
the login rate limiter now counts all of your users as one attacker and locks the whole site out
|
||||
after five wrong passwords, and every row in the download log records the proxy's address instead
|
||||
of the person who actually downloaded the file. Both are the kind of thing you discover weeks
|
||||
later, from a complaint.
|
||||
|
||||
If you have already run it — or ran `php artisan optimize`, which includes it — `php artisan
|
||||
config:clear` puts things back immediately, and every update clears it too, saying why. The three commands above are safe and give you nearly
|
||||
all of the speed anyway; `config:cache` was always the smallest win of the four.
|
||||
Caching the configuration writes every resolved setting into one PHP file, and from then on the
|
||||
framework stops reading your `.env` at all — everything in it has already been baked in. So
|
||||
**re-run `php artisan config:cache` every time you edit `.env`**, or the edit does nothing and you
|
||||
are left staring at a setting that is plainly there and plainly ignored. `php artisan config:clear`
|
||||
goes back to reading `.env` directly, and every update clears it too, saying why.
|
||||
|
||||
---
|
||||
|
||||
@@ -502,13 +577,22 @@ applies to a non-standard port; behind a TLS proxy on 443 you do not need it.
|
||||
**A change I made in `.env` has no effect.**
|
||||
Run `php artisan optimize:clear`, then restart PHP-FPM and the worker. Both hold the old values
|
||||
until they are restarted. If it *still* has no effect, someone has run `php artisan config:cache`
|
||||
(or `optimize`) on this install — see [One command to skip](#one-command-to-skip-configcache).
|
||||
(or `optimize`) on this install — re-run it to pick the new value up, or `php artisan config:clear`
|
||||
to go back to reading `.env` directly.
|
||||
|
||||
**Everyone is locked out of the login form at once, or the download log shows the same IP for
|
||||
every download.**
|
||||
ProjectSend is seeing your proxy or CDN instead of your visitors. Set `TRUSTED_PROXIES` in `.env`
|
||||
(step 3) — and make sure `config:cache` has not been run, which stops that value from being read
|
||||
at all. Same section as above.
|
||||
(step 3) and restart PHP-FPM.
|
||||
|
||||
**Behind a reverse proxy: 419 "page expired" when you log in or save a form, or you land back on
|
||||
the login screen at random.**
|
||||
`TRUSTED_PROXIES` again (step 3). Without it ProjectSend never learns the proxy terminated TLS, so
|
||||
it builds its links and redirects with `http://` while the browser is on `https://`, and marks the
|
||||
session cookie as non-secure. The browser then declines to send that cookie back, the session
|
||||
arrives empty, and the write fails with a 419 that reads as an expired session. Make sure your
|
||||
proxy passes the original `Host` header through as well — `proxy_set_header Host $host;` in nginx,
|
||||
`passHostHeader=true` in Traefik (its default).
|
||||
|
||||
Still stuck? Ask in the [community forum](https://www.projectsend.org/) or open an issue on
|
||||
[GitHub](https://github.com/projectsend/projectsend/issues), and include the last few lines of
|
||||
|
||||
@@ -178,9 +178,11 @@ are listed at each step.
|
||||
| Legacy and ProjectSend are on the **same machine** | [**Direct**](#step-3a--direct-same-machine) |
|
||||
| Legacy is on **another server**, or on hosting you cannot reach from the new box | [**Bundle**](#step-3b--bundle-different-machines) |
|
||||
|
||||
Direct is faster and simpler, and on a single filesystem it does not copy your files at all — it
|
||||
hardlinks them, so 400 GB migrates in seconds and both installs point at the same bytes until you
|
||||
decide otherwise. Use it if you can.
|
||||
Direct is faster and simpler. It copies your files by default, and it can also *hardlink* them
|
||||
instead when you ask it to — on a single filesystem that writes no bytes at all, so 400 GB migrates
|
||||
in seconds and both installs point at the same bytes until you decide otherwise. Either way your
|
||||
Legacy install is left intact. Use Direct if you can; [Step 3a](#step-3a--direct-same-machine) has
|
||||
the strategies.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,7 +78,8 @@ docker compose -f compose.example.yaml up -d
|
||||
```
|
||||
|
||||
Open `APP_URL` and the first thing you see is a setup screen that creates your administrator
|
||||
account — or set `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first and it is created for you.
|
||||
account — or uncomment `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the file first, with a password of
|
||||
your own, and it is created for you.
|
||||
|
||||
Before you put real files in it, read **[DOCKER.md](DOCKER.md)** — where your database and uploads
|
||||
actually live, how to move them onto paths you chose, and how to back them up so an upgrade can't
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Updating ProjectSend
|
||||
|
||||
How to move an existing installation to a newer version, for both ways of running it. If you are
|
||||
installing for the first time, you want [INSTALL.md](INSTALL.md) (or [DOCKER.md](DOCKER.md))
|
||||
instead.
|
||||
installing for the first time, you want [Getting started](README.md#getting-started) for Docker or
|
||||
[INSTALL.md](INSTALL.md) for your own server instead.
|
||||
|
||||
Two rules hold everywhere in this document:
|
||||
|
||||
@@ -17,7 +17,7 @@ Which path you are on decides the rest:
|
||||
| How you installed | What updating means | Manual steps |
|
||||
|---|---|---|
|
||||
| The official Docker image (`projectsend/projectsend`) | Pull a new image, recreate the container | None — the container migrates itself |
|
||||
| Docker Compose built from source (DOCKER.md) | New code, rebuild the image | None — same entrypoint |
|
||||
| Docker Compose built from a clone (CONTRIBUTING.md) | New code, rebuild the image | None — same entrypoint |
|
||||
| A release zip on your own server (INSTALL.md) | Download the zip, run one script | `sudo ./update.sh`, and answer three questions |
|
||||
|
||||
ProjectSend also tells you which of these you are on: the **System** card on the dashboard prints
|
||||
|
||||
@@ -54,6 +54,7 @@ enum Action: string
|
||||
case ShareLinkRevoked = 'share_link.revoked';
|
||||
case ShareLinkDownloaded = 'share_link.downloaded';
|
||||
case PublicFileDownloaded = 'public_file.downloaded';
|
||||
case PublicFilePreviewed = 'public_file.previewed';
|
||||
case FolderCreated = 'folder.created';
|
||||
case FolderRenamed = 'folder.renamed';
|
||||
case FolderMoved = 'folder.moved';
|
||||
@@ -180,6 +181,7 @@ enum Action: string
|
||||
self::ShareLinkRevoked => 'Revoked a public link for the file ":subject"',
|
||||
self::ShareLinkDownloaded => 'Downloaded the file ":subject" via a public link',
|
||||
self::PublicFileDownloaded => 'Downloaded the file ":subject" via the public group listing',
|
||||
self::PublicFilePreviewed => 'Previewed the file ":subject" via the public group listing',
|
||||
self::FolderCreated => 'Created the folder ":subject"',
|
||||
self::FolderRenamed => 'Renamed the folder ":subject"',
|
||||
self::FolderMoved => 'Moved the folder ":subject"',
|
||||
@@ -278,6 +280,7 @@ enum Action: string
|
||||
self::ShareLinkRevoked => 'A public link was revoked',
|
||||
self::ShareLinkDownloaded => 'A file was downloaded via a public link',
|
||||
self::PublicFileDownloaded => 'A file was downloaded via the public group listing',
|
||||
self::PublicFilePreviewed => 'A file was previewed via the public group listing',
|
||||
self::FolderCreated => 'A folder was created',
|
||||
self::FolderRenamed => 'A folder was renamed',
|
||||
self::FolderMoved => 'A folder was moved',
|
||||
|
||||
@@ -93,7 +93,7 @@ class ActivityLogger
|
||||
|
||||
private function shouldRecordIp(Action $action, ?User $actor): bool
|
||||
{
|
||||
if (! in_array($action, [Action::FileDownloaded, Action::FilePreviewed, Action::ShareLinkDownloaded, Action::PublicFileDownloaded], true)) {
|
||||
if (! in_array($action, [Action::FileDownloaded, Action::FilePreviewed, Action::ShareLinkDownloaded, Action::PublicFileDownloaded, Action::PublicFilePreviewed], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,17 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Audit\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\DownloadPresenter;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Support\Pagination;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -27,6 +32,7 @@ class DownloadsController extends Controller
|
||||
public function __construct(
|
||||
private readonly DownloadPresenter $presenter,
|
||||
private readonly ActivityLogScope $scope,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -34,15 +40,9 @@ class DownloadsController extends Controller
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
// A download row names the file and says who fetched it from which
|
||||
// IP, so it needs the viewer's library scope applied — not just
|
||||
// `view_actions_log`. See ActivityLogScope for the full reasoning.
|
||||
$entries = $this->scope
|
||||
->apply(ActivityLog::query(), $viewer)
|
||||
->where('subject_type', (new File)->getMorphClass())
|
||||
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id')
|
||||
$filters = $this->validatedFilters($request);
|
||||
|
||||
$entries = $this->filteredQuery($filters, $viewer)
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
@@ -63,6 +63,65 @@ class DownloadsController extends Controller
|
||||
];
|
||||
})->all(),
|
||||
'pagination' => Pagination::meta($entries),
|
||||
'filters' => $filters,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{file: ?string, user: ?string, from: ?string, to: ?string}
|
||||
*/
|
||||
private function validatedFilters(Request $request): array
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'file' => ['nullable', 'string', 'max:255'],
|
||||
'user' => ['nullable', 'string', 'max:255'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'file' => $validated['file'] ?? null,
|
||||
'user' => $validated['user'] ?? null,
|
||||
'from' => $validated['from'] ?? null,
|
||||
'to' => $validated['to'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{file: ?string, user: ?string, from: ?string, to: ?string} $filters
|
||||
* @return Builder<ActivityLog>
|
||||
*/
|
||||
private function filteredQuery(array $filters, User $viewer): Builder
|
||||
{
|
||||
$timezone = $this->timezones->resolve($viewer);
|
||||
|
||||
// A download row names the file and says who fetched it from which
|
||||
// IP, so it needs the viewer's library scope applied — not just
|
||||
// `view_actions_log`. See ActivityLogScope for the full reasoning.
|
||||
return $this->scope
|
||||
->apply(ActivityLog::query(), $viewer)
|
||||
->where('subject_type', (new File)->getMorphClass())
|
||||
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded])
|
||||
// Both names are matched on what the entry snapshotted, not on
|
||||
// a join: a file or an account deleted since is still findable
|
||||
// by the name it went out under, which is often exactly what
|
||||
// this page is being asked.
|
||||
->when($filters['file'], fn (Builder $query, string $file) => $query->where('subject_name', 'like', "%{$file}%"))
|
||||
// Only rows with a real account can match a name. The two
|
||||
// anonymous flavours ("Public link", "Public listing") are
|
||||
// labels this page prints, not stored values, so a search for
|
||||
// them finds nothing rather than something arbitrary.
|
||||
->when($filters['user'], fn (Builder $query, string $user) => $query->where('actor_name', 'like', "%{$user}%"))
|
||||
// The viewer's own calendar day, not the UTC one — see LocalDay.
|
||||
->when(
|
||||
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
|
||||
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
|
||||
)
|
||||
->when(
|
||||
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
|
||||
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
|
||||
)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ class ClientSettingsController extends Controller
|
||||
'clients_can_select_group' => $this->settings->get(Setting::ClientsCanSelectGroup),
|
||||
'clients_membership_deny_cooldown_days' => $this->settings->get(Setting::ClientsMembershipDenyCooldownDays),
|
||||
'default_client_storage_quota_mb' => (int) $this->settings->get(Setting::DefaultClientStorageQuotaMb),
|
||||
'clients_can_preview_files' => $this->settings->get(Setting::ClientsCanPreviewFiles),
|
||||
'groups' => Group::query()->orderBy('name')->get()
|
||||
->map(fn (Group $group): array => ['id' => $group->id, 'name' => $group->name])
|
||||
->all(),
|
||||
@@ -47,6 +48,7 @@ class ClientSettingsController extends Controller
|
||||
'clients_can_select_group' => ['required', Rule::in(['none', 'public'])],
|
||||
'clients_membership_deny_cooldown_days' => ['required', 'integer', 'min:0', 'max:365'],
|
||||
'default_client_storage_quota_mb' => ['required', 'integer', 'min:0'],
|
||||
'clients_can_preview_files' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$this->settings->set(Setting::ClientsCanRegister, $validated['clients_can_register']);
|
||||
@@ -55,6 +57,7 @@ class ClientSettingsController extends Controller
|
||||
$this->settings->set(Setting::ClientsCanSelectGroup, $validated['clients_can_select_group']);
|
||||
$this->settings->set(Setting::ClientsMembershipDenyCooldownDays, (int) $validated['clients_membership_deny_cooldown_days']);
|
||||
$this->settings->set(Setting::DefaultClientStorageQuotaMb, (int) $validated['default_client_storage_quota_mb']);
|
||||
$this->settings->set(Setting::ClientsCanPreviewFiles, $validated['clients_can_preview_files']);
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'clients']);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityPresenter;
|
||||
@@ -18,10 +19,15 @@ use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use App\Modules\Files\Versions\FileVersionLinks;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -34,6 +40,41 @@ class FileDetailsController extends Controller
|
||||
/** Raw rows considered when grouping downloads() by actor — see that method's docblock. */
|
||||
private const DOWNLOADS_SUMMARY_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* How a file leaves: three actions, because *how* it left matters —
|
||||
* a signed-in recipient, somebody following a public link, and a
|
||||
* visitor to a public group listing are all recorded separately.
|
||||
*
|
||||
* @var non-empty-list<Action>
|
||||
*/
|
||||
private const DOWNLOAD_ACTIONS = [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded];
|
||||
|
||||
/**
|
||||
* Looking at a file without taking it. One action today; if a second
|
||||
* way to preview is ever recorded separately, add it here and give
|
||||
* previews an ACTION_GROUPS entry the way downloads has one.
|
||||
*
|
||||
* @var non-empty-list<Action>
|
||||
*/
|
||||
private const PREVIEW_ACTIONS = [Action::FilePreviewed];
|
||||
|
||||
/**
|
||||
* Filters that stand for a question rather than for one logged action.
|
||||
*
|
||||
* Nobody reading a file's history wants to ask "who downloaded this?"
|
||||
* three times, so this offers it once — and only when the file's own
|
||||
* log holds more than one of the members, since otherwise it would
|
||||
* filter to exactly what its single member already offers.
|
||||
*
|
||||
* @var array<string, array{label: string, actions: non-empty-list<Action>}>
|
||||
*/
|
||||
private const ACTION_GROUPS = [
|
||||
'downloads' => [
|
||||
'label' => 'All downloads',
|
||||
'actions' => self::DOWNLOAD_ACTIONS,
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly ActivityPresenter $presenter,
|
||||
private readonly DownloadPresenter $downloadPresenter,
|
||||
@@ -41,6 +82,7 @@ class FileDetailsController extends Controller
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly FileVersionLinks $versionLinks,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, File $file): JsonResponse
|
||||
@@ -136,6 +178,65 @@ class FileDetailsController extends Controller
|
||||
return response()->json(['entries' => $entries, 'total' => $total]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who has actually had this file: its downloads and previews, newest
|
||||
* first, with a count of each.
|
||||
*
|
||||
* A narrower question than activity() and a much more frequent one —
|
||||
* "did they ever actually get it?" — which the full log answers only
|
||||
* by being read past everything else that has happened to the file.
|
||||
*/
|
||||
public function access(Request $request, File $file): JsonResponse
|
||||
{
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
Gate::forUser($viewer)->authorize('view', $file);
|
||||
abort_unless($viewer->can('view_actions_log'), 403);
|
||||
|
||||
$base = fn (): Builder => ActivityLog::query()
|
||||
->where('subject_type', $file->getMorphClass())
|
||||
->where('subject_id', $file->id);
|
||||
|
||||
$entries = $base()
|
||||
->whereIn('action', [...self::DOWNLOAD_ACTIONS, ...self::PREVIEW_ACTIONS])
|
||||
->orderByDesc('created_at')->orderByDesc('id')
|
||||
->limit(20)->get()
|
||||
->map(fn (ActivityLog $entry): array => [
|
||||
// The sentence the presenter builds already says which of
|
||||
// the two this was ("Downloaded the file …"), so nothing
|
||||
// here has to label the row a second time.
|
||||
...$this->presenter->present($entry),
|
||||
// Subject to the privacy setting that decides whether an
|
||||
// address is recorded at all, so it is often null.
|
||||
'ip_address' => $entry->ip_address,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'entries' => $entries,
|
||||
'downloads_total' => $base()->whereIn('action', self::DOWNLOAD_ACTIONS)->count(),
|
||||
'previews_total' => $base()->whereIn('action', self::PREVIEW_ACTIONS)->count(),
|
||||
// Built here rather than in the page: which filter value stands
|
||||
// for "every download" is a fact about the log's vocabulary,
|
||||
// and a group key only exists while the group does.
|
||||
'downloads_url' => $this->historyUrl($file, 'downloads', self::DOWNLOAD_ACTIONS),
|
||||
'previews_url' => $this->historyUrl($file, 'previews', self::PREVIEW_ACTIONS),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The file's history, pre-filtered to one question: by the group when
|
||||
* one covers these actions, and by the action itself when the group
|
||||
* would have a single member and therefore does not exist.
|
||||
*
|
||||
* @param non-empty-list<Action> $actions
|
||||
*/
|
||||
private function historyUrl(File $file, string $groupKey, array $actions): string
|
||||
{
|
||||
$filter = isset(self::ACTION_GROUPS[$groupKey]) ? $groupKey : $actions[0]->value;
|
||||
|
||||
return route('files.activity.history', $file, false).'?action='.$filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full, paginated activity history for a file — the "View full
|
||||
* history" destination linked from the details panel's Activity tab,
|
||||
@@ -148,7 +249,15 @@ class FileDetailsController extends Controller
|
||||
Gate::forUser($viewer)->authorize('view', $file);
|
||||
abort_unless($viewer->can('view_actions_log'), 403);
|
||||
|
||||
return $this->renderHistory($file->getMorphClass(), $file->id, $file->name, route('files.edit', $file, false));
|
||||
return $this->renderHistory(
|
||||
$request,
|
||||
$file->getMorphClass(),
|
||||
$file->id,
|
||||
$file->name,
|
||||
route('files.edit', $file, false).'?tab=activity',
|
||||
'files.activity.history',
|
||||
['file' => $file->id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,7 +282,7 @@ class FileDetailsController extends Controller
|
||||
$query = ActivityLog::query()
|
||||
->where('subject_type', $file->getMorphClass())
|
||||
->where('subject_id', $file->id)
|
||||
->whereIn('action', [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded]);
|
||||
->whereIn('action', self::DOWNLOAD_ACTIONS);
|
||||
|
||||
$total = (clone $query)->count();
|
||||
|
||||
@@ -304,15 +413,35 @@ class FileDetailsController extends Controller
|
||||
Gate::forUser($viewer)->authorize('view', $folder);
|
||||
abort_unless($viewer->can('view_actions_log'), 403);
|
||||
|
||||
return $this->renderHistory($folder->getMorphClass(), $folder->id, $folder->name, route('files.index', ['folder' => $folder->id], false));
|
||||
return $this->renderHistory(
|
||||
$request,
|
||||
$folder->getMorphClass(),
|
||||
$folder->id,
|
||||
$folder->name,
|
||||
route('files.index', ['folder' => $folder->id], false),
|
||||
'folders.activity.history',
|
||||
['folder' => $folder->id],
|
||||
);
|
||||
}
|
||||
|
||||
private function renderHistory(string $morphClass, int $subjectId, string $subjectName, string $backUrl): Response
|
||||
{
|
||||
$entries = ActivityLog::query()
|
||||
->where('subject_type', $morphClass)
|
||||
->where('subject_id', $subjectId)
|
||||
->orderByDesc('created_at')->orderByDesc('id')
|
||||
/**
|
||||
* @param array<string, mixed> $routeParams
|
||||
*/
|
||||
private function renderHistory(
|
||||
Request $request,
|
||||
string $morphClass,
|
||||
int $subjectId,
|
||||
string $subjectName,
|
||||
string $backUrl,
|
||||
string $routeName,
|
||||
array $routeParams,
|
||||
): Response {
|
||||
$viewer = $request->user();
|
||||
assert($viewer !== null);
|
||||
|
||||
$filters = $this->validatedHistoryFilters($request);
|
||||
|
||||
$entries = $this->historyQuery($morphClass, $subjectId, $filters, $viewer)
|
||||
->paginate(25)
|
||||
->withQueryString();
|
||||
|
||||
@@ -327,8 +456,135 @@ class FileDetailsController extends Controller
|
||||
'next' => $entries->nextPageUrl(),
|
||||
'total' => $entries->total(),
|
||||
],
|
||||
'filters' => $filters,
|
||||
'action_options' => $this->actionOptions($morphClass, $subjectId, $filters['action']),
|
||||
'subject_name' => $subjectName,
|
||||
'back_url' => $backUrl,
|
||||
'route_name' => $routeName,
|
||||
'route_params' => $routeParams,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions this subject's history actually contains, with how many
|
||||
* times each happened.
|
||||
*
|
||||
* Built from the log rather than from `Action::cases()`: the enum has
|
||||
* over eighty members and all but a handful can never appear against a
|
||||
* file, so offering them all would be a dropdown you scroll past the
|
||||
* answer in. What is here is what happened.
|
||||
*
|
||||
* @return list<array{key: string, label: string, count: int}>
|
||||
*/
|
||||
private function actionOptions(string $morphClass, int $subjectId, ?string $active): array
|
||||
{
|
||||
/** @var array<string, int> $counts */
|
||||
$counts = ActivityLog::query()
|
||||
->where('subject_type', $morphClass)
|
||||
->where('subject_id', $subjectId)
|
||||
->selectRaw('action, count(*) as total')
|
||||
->groupBy('action')
|
||||
->pluck('total', 'action')
|
||||
->map(fn ($total): int => (int) $total)
|
||||
->all();
|
||||
|
||||
$options = [];
|
||||
|
||||
foreach (self::ACTION_GROUPS as $key => $group) {
|
||||
$present = array_filter($group['actions'], fn (Action $action): bool => isset($counts[$action->value]));
|
||||
|
||||
// One member present means the group would filter to exactly
|
||||
// what its member already offers, under a vaguer name — unless
|
||||
// this *is* what is currently being filtered on (the file
|
||||
// page's "View all downloads" button links straight to it), in
|
||||
// which case the dropdown has to be able to show its own value.
|
||||
if (count($present) < 2 && $active !== $key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'key' => $key,
|
||||
'label' => $group['label'],
|
||||
'count' => array_sum(array_map(fn (Action $action): int => $counts[$action->value], $present)),
|
||||
];
|
||||
}
|
||||
|
||||
// Enum order, not count order, so the list does not rearrange
|
||||
// itself under the reader every time the file is downloaded.
|
||||
foreach (Action::cases() as $action) {
|
||||
// Same reason as the group above: a filter arrived at from a
|
||||
// link stays visible in the dropdown even at a count of zero,
|
||||
// rather than leaving it blank over an empty table.
|
||||
if (! isset($counts[$action->value]) && $active !== $action->value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[] = [
|
||||
'key' => $action->value,
|
||||
'label' => $action->description(),
|
||||
'count' => $counts[$action->value] ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{action: ?string, actor: ?string, from: ?string, to: ?string}
|
||||
*/
|
||||
private function validatedHistoryFilters(Request $request): array
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'action' => ['nullable', Rule::in([
|
||||
...array_keys(self::ACTION_GROUPS),
|
||||
...array_column(Action::cases(), 'value'),
|
||||
])],
|
||||
'actor' => ['nullable', 'string', 'max:255'],
|
||||
'from' => ['nullable', 'date'],
|
||||
'to' => ['nullable', 'date', 'after_or_equal:from'],
|
||||
]);
|
||||
|
||||
return [
|
||||
'action' => $validated['action'] ?? null,
|
||||
'actor' => $validated['actor'] ?? null,
|
||||
'from' => $validated['from'] ?? null,
|
||||
'to' => $validated['to'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{action: ?string, actor: ?string, from: ?string, to: ?string} $filters
|
||||
* @return Builder<ActivityLog>
|
||||
*/
|
||||
private function historyQuery(string $morphClass, int $subjectId, array $filters, User $viewer): Builder
|
||||
{
|
||||
$timezone = $this->timezones->resolve($viewer);
|
||||
|
||||
return ActivityLog::query()
|
||||
->where('subject_type', $morphClass)
|
||||
->where('subject_id', $subjectId)
|
||||
->when($filters['action'], function (Builder $query, string $action): void {
|
||||
$group = self::ACTION_GROUPS[$action] ?? null;
|
||||
|
||||
$group === null
|
||||
? $query->where('action', $action)
|
||||
: $query->whereIn('action', array_map(fn (Action $member): string => $member->value, $group['actions']));
|
||||
})
|
||||
// Matched on the name snapshotted onto the entry, the same as
|
||||
// the main log: an account deleted since is still findable by
|
||||
// the name it acted under, which is the whole point of the
|
||||
// snapshot.
|
||||
->when($filters['actor'], fn (Builder $query, string $actor) => $query->where('actor_name', 'like', "%{$actor}%"))
|
||||
// The reader's own calendar day, not the UTC one — see LocalDay.
|
||||
->when(
|
||||
$filters['from'] !== null ? LocalDay::start($filters['from'], $timezone) : null,
|
||||
fn (Builder $query, Carbon $from) => $query->where('created_at', '>=', $from),
|
||||
)
|
||||
->when(
|
||||
$filters['to'] !== null ? LocalDay::end($filters['to'], $timezone) : null,
|
||||
fn (Builder $query, Carbon $to) => $query->where('created_at', '<=', $to),
|
||||
)
|
||||
->orderByDesc('created_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,15 +8,21 @@ 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\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;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -32,17 +38,25 @@ use Illuminate\Support\Facades\Storage;
|
||||
* file's contents — a real, audit-worthy action, just not a "download."
|
||||
*
|
||||
* SECURITY: both methods serve bytes inline, from this app's own origin,
|
||||
* with the File's stored mime type — so both are restricted to
|
||||
* with the File's stored mime type, so both are restricted to an
|
||||
* allowlist — but not the same one, because they are asking different
|
||||
* questions. `thumbnail()` is bounded by
|
||||
* ThumbnailGenerator::SUPPORTED_MIME_TYPES, the raster formats this app
|
||||
* renders itself. That list is the allowlist; nothing else is ever served
|
||||
* inline. Do NOT widen it to text/html, image/svg+xml, or anything else a
|
||||
* browser executes script from, and do not reach for the upload
|
||||
* allowed-extensions setting as a substitute: that setting matches on the
|
||||
* *extension*, while mime_type is detected from the *bytes*
|
||||
* (ChunkedUploadsController::complete), so a .txt holding HTML is stored
|
||||
* as text/html and would render as a page here. Serving a file inline as
|
||||
* a type the browser executes is same-origin script execution with the
|
||||
* viewer's session.
|
||||
* decodes and re-encodes itself, since a thumbnail *is* a rendition.
|
||||
* `preview()` is bounded by PreviewKind, which additionally admits the
|
||||
* video, audio and PDF types a browser plays natively and this app never
|
||||
* touches. PreviewKind's docblock carries the rule in full; the short
|
||||
* version is that neither list may ever grow a type a browser executes
|
||||
* script from, and neither may be derived from the upload
|
||||
* allowed-extensions setting, which matches on the *extension* while
|
||||
* mime_type is detected from the *bytes*
|
||||
* (ChunkedUploadsController::complete).
|
||||
*
|
||||
* Serving media inline is also why `preview()` logs at most one
|
||||
* Action::FilePreviewed per viewer per file per five minutes: a `<video>`
|
||||
* seeking through a recording issues a long tail of Range requests
|
||||
* against this same URL, and one row each would bury the log under a
|
||||
* single deliberate act.
|
||||
*
|
||||
* Renditions always cache on the local "files" disk regardless of where
|
||||
* the source file lives — they're a derived artifact, not the original,
|
||||
@@ -60,6 +74,9 @@ class FileThumbnailController extends Controller
|
||||
private readonly ThumbnailGenerator $thumbnails,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly LocalSourceFile $source,
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
public function thumbnail(Request $request, File $file): Response
|
||||
@@ -82,66 +99,92 @@ class FileThumbnailController extends Controller
|
||||
/**
|
||||
* A file opened to be looked at.
|
||||
*
|
||||
* A preview is not the file — it is a rendered view of it, which is
|
||||
* why it may be decorated at all. But rendering one is expensive
|
||||
* (decoding and re-encoding a full-size photograph) where serving the
|
||||
* stored bytes is nearly free, so core only pays that cost when a
|
||||
* listener says this particular viewer must be served a rendering:
|
||||
* ResolvingImageRendering asks, and defaults to no. On an
|
||||
* For an image, a preview is not the file — it is a rendered view of
|
||||
* it, which is why it may be decorated at all. But rendering one is
|
||||
* expensive (decoding and re-encoding a full-size photograph) where
|
||||
* serving the stored bytes is nearly free, so core only pays that
|
||||
* cost when a listener says this particular viewer must be served a
|
||||
* rendering: ResolvingImageRendering asks, and defaults to no. On an
|
||||
* installation that watermarks, a client gets a bounded, watermarked
|
||||
* render and staff get the original; on one that does not, everyone
|
||||
* gets exactly what this endpoint has always returned.
|
||||
*
|
||||
* For video, audio and PDF there is no rendering to resolve — this
|
||||
* app cannot decode any of them, so it has no rendition to cache, no
|
||||
* watermark to stamp, and nothing to ask about. Those go straight to
|
||||
* the bytes.
|
||||
*/
|
||||
public function preview(Request $request, File $file): Response|RedirectResponse
|
||||
{
|
||||
Gate::authorize('view', $file);
|
||||
|
||||
// Only types this app renders itself may be served inline; anything
|
||||
// else is a download, not a preview. See the class docblock — the
|
||||
// stored mime type is sniffed from the bytes, so an allowed
|
||||
// The inline allowlist. See the class docblock and PreviewKind —
|
||||
// the stored mime type is sniffed from the bytes, so an allowed
|
||||
// extension is not evidence of a safe-to-render payload.
|
||||
abort_unless(ThumbnailGenerator::supports($file->mime_type), 404);
|
||||
$kind = PreviewKind::forMime($file->mime_type);
|
||||
|
||||
abort_if($kind === null, 404);
|
||||
|
||||
// Staff are never gated: this switch exists so an installation can
|
||||
// decide what its *clients* may do with a file short of taking it.
|
||||
// 404 rather than 403 because with the setting off the endpoint is
|
||||
// not a thing that exists for this viewer.
|
||||
abort_if(
|
||||
$request->user()?->isStaff() !== true && ! $this->settings->get(Setting::ClientsCanPreviewFiles),
|
||||
404,
|
||||
);
|
||||
|
||||
// A preview is not counted as a download, but it is refused once
|
||||
// the download limit is spent — because unless a listener asks
|
||||
// for a rendering (nothing does by default), the branches below
|
||||
// serve the *original bytes* at full size. Without this a cap
|
||||
// would be one URL away from meaningless for every image on the
|
||||
// install. thumbnail() needs no such guard: a 300px rendition is
|
||||
// not the file.
|
||||
// for a rendering (nothing does by default, and nothing ever does
|
||||
// for media), the branches below serve the *original bytes* at
|
||||
// full size. Without this a cap would be one URL away from
|
||||
// meaningless for every previewable file on the install.
|
||||
// thumbnail() needs no such guard: a 300px rendition is not the
|
||||
// file.
|
||||
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
||||
|
||||
$this->activity->log(Action::FilePreviewed, subject: $file);
|
||||
$this->logPreview($file, $request);
|
||||
|
||||
$audience = ImageAudience::forViewer($request->user());
|
||||
if ($kind === PreviewKind::Image) {
|
||||
$audience = ImageAudience::forViewer($request->user());
|
||||
|
||||
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
|
||||
Event::dispatch($decision);
|
||||
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
|
||||
Event::dispatch($decision);
|
||||
|
||||
if ($decision->required) {
|
||||
$path = $this->render($file, $audience, ImageRendition::Preview);
|
||||
if ($decision->required) {
|
||||
$path = $this->render($file, $audience, ImageRendition::Preview);
|
||||
|
||||
abort_if($path === null, 404);
|
||||
abort_if($path === null, 404);
|
||||
|
||||
return $this->serve($file, $path);
|
||||
return $this->serve($file, $path);
|
||||
}
|
||||
}
|
||||
|
||||
if ($file->disk !== 'files') {
|
||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||
$file->path,
|
||||
now()->addHour(),
|
||||
['ResponseContentDisposition' => ContentDisposition::inline($file->original_name)],
|
||||
);
|
||||
return $this->bytes->inline($file);
|
||||
}
|
||||
|
||||
return redirect()->away($url);
|
||||
/**
|
||||
* One log row per viewer per file per five minutes.
|
||||
*
|
||||
* Watching a video is a single deliberate act that the browser turns
|
||||
* into dozens of Range requests against this route, and each one
|
||||
* arrives here indistinguishable from someone clicking preview again.
|
||||
* Cache::add is the whole mechanism: it writes only if the key is
|
||||
* absent, so the first request through the window logs and the rest
|
||||
* are silent, without a read-then-write race between two of them.
|
||||
*
|
||||
* Keyed by viewer, so one client's playback never suppresses another
|
||||
* person's preview of the same file. Anonymous viewers do not reach
|
||||
* this route at all — see PublicGroupsController::preview.
|
||||
*/
|
||||
private function logPreview(File $file, Request $request): void
|
||||
{
|
||||
$key = 'file-preview-logged:'.$file->id.':'.($request->user()->id ?? 'guest');
|
||||
|
||||
if (Cache::add($key, true, now()->addMinutes(5))) {
|
||||
$this->activity->log(Action::FilePreviewed, subject: $file);
|
||||
}
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,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;
|
||||
}
|
||||
@@ -185,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,11 @@ class FilesController extends Controller
|
||||
'can_update' => Gate::forUser($viewer)->allows('update', $file),
|
||||
'can_delete' => Gate::forUser($viewer)->allows('delete', $file),
|
||||
'can_manage_public' => $viewer->can('upload_public'),
|
||||
// Whether this page offers its Activity tab. The file's own
|
||||
// page is where somebody lands from a link, a search or a
|
||||
// notification, so "what happened to this file" has to be
|
||||
// answerable here and not only from the library's list.
|
||||
'can_view_activity' => $viewer->can('view_actions_log'),
|
||||
// The per-file switch only does anything while the comment
|
||||
// scope is `selected`; under every other value the page hides
|
||||
// it rather than offer a control with no current effect.
|
||||
|
||||
@@ -260,6 +260,13 @@ class MyFilesController extends Controller
|
||||
'can_upload' => $client->can('upload'),
|
||||
'can_upload_here' => Folder::uploadableBy($client, $current),
|
||||
'can_create_folders' => $client->can('create_own_folders'),
|
||||
// Whether a row is clickable to look at rather than only to
|
||||
// take. Per page rather than per file: the mime type decides
|
||||
// which files can be previewed and every theme already knows
|
||||
// how to read one, so all this has to carry is whether the
|
||||
// installation offers it here at all. See
|
||||
// FileThumbnailController::preview, which re-checks it.
|
||||
'preview_enabled' => $this->settings->get(Setting::ClientsCanPreviewFiles),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Preview;
|
||||
|
||||
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
|
||||
|
||||
/**
|
||||
* What kind of inline view, if any, a stored file gets — the single
|
||||
* answer to "may these bytes be served inline, and what element renders
|
||||
* them?", shared by FileThumbnailController::preview (signed in) and
|
||||
* PublicGroupsController::preview (anonymous).
|
||||
*
|
||||
* SECURITY: this is an allowlist, and it is the boundary. Preview serves
|
||||
* a file's own bytes inline, from this app's origin, labelled with the
|
||||
* mime type stored on the row — so anything a browser executes script
|
||||
* from would be same-origin script execution with the viewer's session.
|
||||
* Never add text/html, image/svg+xml, or any other document type, and
|
||||
* never derive this list from Setting::AllowedUploadExtensions: that
|
||||
* setting matches on the *extension* while mime_type is sniffed from the
|
||||
* *bytes* (ChunkedUploadsController::complete), so a .txt holding HTML is
|
||||
* stored as text/html and would arrive here looking allowed.
|
||||
*
|
||||
* Deliberately narrower than "files a browser might cope with": every
|
||||
* type below is one every current browser decodes natively. Formats like
|
||||
* video/quicktime, video/x-msvideo and video/x-matroska are left out
|
||||
* because an embedded player for them shows a black rectangle. They
|
||||
* upload and download exactly as before — only the inline view is
|
||||
* withheld.
|
||||
*
|
||||
* Distinct from ThumbnailGenerator::SUPPORTED_MIME_TYPES, which answers a
|
||||
* narrower question: which types this app can *decode and re-encode*
|
||||
* itself, and therefore has renditions, a cache and a watermark hook for.
|
||||
* Image delegates to it rather than restating it, so the two cannot drift.
|
||||
*/
|
||||
enum PreviewKind: string
|
||||
{
|
||||
/** Rendered with <img>; the only kind with thumbnails and renditions. */
|
||||
case Image = 'image';
|
||||
|
||||
/** Rendered with <video controls>. */
|
||||
case Video = 'video';
|
||||
|
||||
/** Rendered with <audio controls>. */
|
||||
case Audio = 'audio';
|
||||
|
||||
/** Rendered in a sandboxed <iframe>, by the browser's own viewer. */
|
||||
case Pdf = 'pdf';
|
||||
|
||||
/** @var list<string> */
|
||||
private const VIDEO_MIME_TYPES = [
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/ogg',
|
||||
];
|
||||
|
||||
/**
|
||||
* More spellings than there are formats: the mime type is whatever
|
||||
* finfo made of the bytes, and it is not consistent across systems —
|
||||
* a .wav is audio/x-wav on one box and audio/vnd.wave on another, and
|
||||
* an .m4a can come back as audio/mp4 or audio/x-m4a.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const AUDIO_MIME_TYPES = [
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/vnd.wave',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/x-m4a',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
];
|
||||
|
||||
public static function forMime(string $mimeType): ?self
|
||||
{
|
||||
if (ThumbnailGenerator::supports($mimeType)) {
|
||||
return self::Image;
|
||||
}
|
||||
|
||||
if (in_array($mimeType, self::VIDEO_MIME_TYPES, true)) {
|
||||
return self::Video;
|
||||
}
|
||||
|
||||
if (in_array($mimeType, self::AUDIO_MIME_TYPES, true)) {
|
||||
return self::Audio;
|
||||
}
|
||||
|
||||
if ($mimeType === 'application/pdf') {
|
||||
return self::Pdf;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function supports(string $mimeType): bool
|
||||
{
|
||||
return self::forMime($mimeType) !== null;
|
||||
}
|
||||
}
|
||||
@@ -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,11 +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\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;
|
||||
@@ -79,6 +82,8 @@ class PublicGroupsController extends Controller
|
||||
private readonly PublicThemeRegistry $themes,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly LocalSourceFile $source,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
||||
@@ -208,6 +213,13 @@ class PublicGroupsController extends Controller
|
||||
'thumbnail_url' => ThumbnailGenerator::supports($file->mime_type)
|
||||
? route('public.thumbnail', [$publicSlug, $file->slug])
|
||||
: null,
|
||||
// Null whenever preview is unavailable, for any of the three
|
||||
// reasons — switched off, wrong type, or the download limit
|
||||
// spent — so a theme has one thing to check and the setting
|
||||
// itself never ships to a visitor's browser. preview() below
|
||||
// re-checks all three: this decides what to offer, not what
|
||||
// is allowed.
|
||||
'preview_url' => $this->previewUrlFor($file, $publicSlug),
|
||||
'download_url' => route('public.download', [$publicSlug, $file->slug]),
|
||||
// Same decided shape the listings send, so a theme's single
|
||||
// file page disables its button for the same reason a row
|
||||
@@ -241,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, [
|
||||
@@ -251,6 +274,58 @@ class PublicGroupsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The anonymous twin of FileThumbnailController::preview: a public
|
||||
* file shown rather than handed over.
|
||||
*
|
||||
* Nothing is rendered or cached here — an anonymous viewer only ever
|
||||
* previews the stored bytes. The watermark hook that decorates a
|
||||
* client's image preview has no equivalent on this route, for the
|
||||
* same reason thumbnail() hardcodes ImageAudience::External: there is
|
||||
* no viewer to tell apart.
|
||||
*/
|
||||
public function preview(string $publicSlug, File $file): Response|RedirectResponse
|
||||
{
|
||||
$this->guardSlug($publicSlug);
|
||||
|
||||
abort_unless($file->isEffectivelyPublic() && ! $file->isExpired(), 404);
|
||||
abort_unless($this->settings->get(Setting::PublicListingPreviewEnabled) === true, 404);
|
||||
abort_if(PreviewKind::forMime($file->mime_type) === null, 404);
|
||||
|
||||
// 403 rather than 404 for the same reason download() does it, and
|
||||
// it is the same allowance being read: preview serves the whole
|
||||
// file, so a spent cap has to close this door too or it closes
|
||||
// nothing.
|
||||
abort_unless($this->allowance->allows($file, null), 403);
|
||||
|
||||
$this->activity->log(Action::PublicFilePreviewed, subject: $file);
|
||||
|
||||
return $this->bytes->inline($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* What showFile() offers, which is not the same question as what
|
||||
* preview() permits — this one also declines to advertise a preview
|
||||
* whose download limit is already spent, so a visitor is not given a
|
||||
* button that can only answer 403.
|
||||
*/
|
||||
private function previewUrlFor(File $file, string $publicSlug): ?string
|
||||
{
|
||||
if ($this->settings->get(Setting::PublicListingPreviewEnabled) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PreviewKind::forMime($file->mime_type) === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->allowance->allows($file, null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('public.preview', [$publicSlug, $file->slug]);
|
||||
}
|
||||
|
||||
public function download(string $publicSlug, File $file): Response
|
||||
{
|
||||
$this->guardSlug($publicSlug);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class PublicListingSettingsController extends Controller
|
||||
return Inertia::render('system/settings/public-listing', [
|
||||
'public_listing_enabled' => $this->settings->get(Setting::PublicListingEnabled),
|
||||
'public_listing_slug' => $this->settings->get(Setting::PublicListingSlug),
|
||||
'public_listing_preview_enabled' => $this->settings->get(Setting::PublicListingPreviewEnabled),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -42,10 +43,12 @@ class PublicListingSettingsController extends Controller
|
||||
$validated = $request->validate([
|
||||
'public_listing_enabled' => ['required', 'boolean'],
|
||||
'public_listing_slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9]+(-[a-z0-9]+)*$/'],
|
||||
'public_listing_preview_enabled' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
$this->settings->set(Setting::PublicListingEnabled, $validated['public_listing_enabled']);
|
||||
$this->settings->set(Setting::PublicListingSlug, $validated['public_listing_slug']);
|
||||
$this->settings->set(Setting::PublicListingPreviewEnabled, $validated['public_listing_preview_enabled']);
|
||||
|
||||
$this->activity->log(Action::SettingsUpdated, context: ['section' => 'public_listing']);
|
||||
|
||||
|
||||
@@ -5,35 +5,56 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Platform\Installation;
|
||||
|
||||
/**
|
||||
* Whether this installation runs from a container image or from files on a
|
||||
* server somebody administers directly.
|
||||
* Whether this installation runs from a container image, from a container
|
||||
* the operator builds themselves, or from files on a server somebody
|
||||
* administers directly.
|
||||
*
|
||||
* It exists because the application tells administrators how to upgrade, and
|
||||
* the two answers have nothing in common. A container is replaced —
|
||||
* `docker compose pull && docker compose up -d`, with the entrypoint running
|
||||
* the migrations on the way up. A manual install is a sequence somebody
|
||||
* performs by hand: back up, take the site down, unpack the release over the
|
||||
* directory, migrate, refresh the caches, bring it back (INSTALL.md).
|
||||
* the three answers have nothing in common. A published container is
|
||||
* replaced — `docker compose pull && docker compose up -d`, with the
|
||||
* entrypoint running the migrations on the way up. A container built from a
|
||||
* checkout has to be given new code and rebuilt. A manual install is a
|
||||
* sequence somebody performs by hand: back up, take the site down, unpack
|
||||
* the release over the directory, migrate, refresh the caches, bring it back
|
||||
* (INSTALL.md).
|
||||
*
|
||||
* Printing the container command to someone who installed from a zip is
|
||||
* worse than printing nothing: it names a tool they do not have, for a stack
|
||||
* they are not running, at the exact moment they are trying to do the right
|
||||
* thing. That was the behaviour before this class existed — the command was
|
||||
* a hardcoded string in two React components, written when Docker was the
|
||||
* only supported path.
|
||||
* Printing the wrong one of those is worse than printing nothing. For a
|
||||
* manual install the container command names a tool they do not have, for a
|
||||
* stack they are not running, at the exact moment they are trying to do the
|
||||
* right thing — that was the behaviour before this class existed, when the
|
||||
* command was a hardcoded string in two React components. For a stack built
|
||||
* from a checkout it is worse still, because the command runs: `pull` skips
|
||||
* services that have no image to pull and `up -d` then finds every container
|
||||
* already current, so the update reports success and changes nothing, and
|
||||
* the dashboard goes on offering the same release forever (#1661).
|
||||
*
|
||||
* The detection is the presence of the file a container runtime leaves in
|
||||
* the root filesystem. It is a deliberately conservative signal: something
|
||||
* exotic enough to run neither Docker nor Podman is reported as a manual
|
||||
* install, which is the safer wrong answer of the two — the manual
|
||||
* instructions are steps a person follows and check for themselves, while
|
||||
* the container command is one they would paste.
|
||||
* Two signals, in order:
|
||||
*
|
||||
* 1. The published image sets PROJECTSEND_IMAGE. A positive marker set at
|
||||
* build time is the only one a bind mount can neither forge nor hide.
|
||||
* 2. Failing that — images published before that variable existed — a
|
||||
* working tree in the install directory. The image is built from an
|
||||
* unpacked release artifact and has none; the Compose stack in the
|
||||
* repository bind-mounts the repository itself.
|
||||
*
|
||||
* Being in a container at all is the presence of the file a container
|
||||
* runtime leaves in the root filesystem. It is a deliberately conservative
|
||||
* signal: something exotic enough to run neither Docker nor Podman is
|
||||
* reported as a manual install, which is the safer wrong answer of the
|
||||
* three — the manual instructions are steps a person follows and checks for
|
||||
* themselves, while the container commands are ones they would paste.
|
||||
*/
|
||||
class Installation
|
||||
{
|
||||
public function kind(): InstallationKind
|
||||
{
|
||||
return $this->inContainer() ? InstallationKind::Container : InstallationKind::Manual;
|
||||
if (! $this->inContainer()) {
|
||||
return InstallationKind::Manual;
|
||||
}
|
||||
|
||||
return $this->builtFromSource()
|
||||
? InstallationKind::ContainerSource
|
||||
: InstallationKind::Container;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +64,33 @@ class Installation
|
||||
protected function inContainer(): bool
|
||||
{
|
||||
// Docker writes the first; Podman writes the second.
|
||||
return file_exists('/.dockerenv') || file_exists('/run/.containerenv');
|
||||
//
|
||||
// Suppressed, and it has to stay that way. Shared hosting sets
|
||||
// open_basedir to the webspace, and probing a path outside it is a
|
||||
// warning rather than a false — which the framework's error handler
|
||||
// turns into an exception, so the one call that asks which install
|
||||
// this is took the whole dashboard down with it (#1663). Under `@`
|
||||
// the warning is filtered and the probe answers false, which is the
|
||||
// right answer anyway: a host that restricts PHP to a vhost
|
||||
// directory is not the container image.
|
||||
return @file_exists('/.dockerenv') || @file_exists('/run/.containerenv');
|
||||
}
|
||||
|
||||
/**
|
||||
* Protected for the same reason as inContainer(), and answered the same
|
||||
* way in tests.
|
||||
*/
|
||||
protected function builtFromSource(): bool
|
||||
{
|
||||
// getenv() rather than env(): once the configuration is cached,
|
||||
// env() outside a config file returns null, and the answer would
|
||||
// silently flip on the installs most likely to have cached it.
|
||||
if (getenv('PROJECTSEND_IMAGE') === '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A worktree checkout writes .git as a file rather than a
|
||||
// directory, so ask whether it exists, not what it is.
|
||||
return file_exists(base_path('.git'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,17 @@ namespace App\Modules\Platform\Installation;
|
||||
*/
|
||||
enum InstallationKind: string
|
||||
{
|
||||
/** Runs from an image: upgrading is pulling a new one. */
|
||||
/** Runs from the published image: upgrading is pulling a new one. */
|
||||
case Container = 'container';
|
||||
|
||||
/**
|
||||
* Runs from a container the operator builds themselves, out of a
|
||||
* checkout of the repository: upgrading is new code first, then a
|
||||
* rebuild. Pulling does nothing here — there is no published image
|
||||
* behind these containers to pull.
|
||||
*/
|
||||
case ContainerSource = 'container-source';
|
||||
|
||||
/** Runs from files on a server someone administers: upgrading is INSTALL.md's sequence. */
|
||||
case Manual = 'manual';
|
||||
}
|
||||
|
||||
@@ -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 !== '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ enum Setting: string
|
||||
// Days a denied membership request blocks re-requesting (0 = none).
|
||||
case ClientsMembershipDenyCooldownDays = 'clients_membership_deny_cooldown_days';
|
||||
|
||||
// Whether the client portal offers inline preview at all — the whole
|
||||
// affordance, images included, not just the media types. Staff are
|
||||
// never gated by it: it exists so an installation can decide that a
|
||||
// client either downloads a file or does not get it, without taking
|
||||
// the tool away from the people who administer the library. See
|
||||
// PreviewKind and FileThumbnailController::preview.
|
||||
case ClientsCanPreviewFiles = 'clients_can_preview_files';
|
||||
|
||||
// Maximum upload size in MB (0 = unlimited).
|
||||
case MaxFileSizeMb = 'max_file_size_mb';
|
||||
|
||||
@@ -169,6 +177,12 @@ enum Setting: string
|
||||
// group/file public flags instead of tokens. See PublicGroupsController.
|
||||
case PublicListingEnabled = 'public_listing_enabled';
|
||||
|
||||
// The same switch as ClientsCanPreviewFiles, for the anonymous side:
|
||||
// whether a public file page offers to show the file as well as hand
|
||||
// it over. v1 parity: public_listing_enable_preview. See
|
||||
// PublicGroupsController::preview.
|
||||
case PublicListingPreviewEnabled = 'public_listing_preview_enabled';
|
||||
|
||||
// The configurable base URL segment for the public listing (e.g.
|
||||
// "public" -> /public, /public/{group-slug}). Consumed by
|
||||
// PublicGroupsController's guard against every request's first path
|
||||
@@ -343,6 +357,8 @@ enum Setting: string
|
||||
self::EmailNotificationsEnabled,
|
||||
self::DiscourageSearchIndexing,
|
||||
self::PublicListingEnabled,
|
||||
self::ClientsCanPreviewFiles,
|
||||
self::PublicListingPreviewEnabled,
|
||||
self::CheckForUpdates,
|
||||
self::ExpiredFilesAutoDeleteEnabled,
|
||||
self::PublicCommentsEnabled,
|
||||
@@ -410,6 +426,11 @@ enum Setting: string
|
||||
|
||||
self::CheckForUpdates,
|
||||
self::CommentsGuestModeration,
|
||||
// On, so that an installation updating into these switches
|
||||
// keeps the preview it already had rather than losing it to a
|
||||
// setting nobody has seen yet.
|
||||
self::ClientsCanPreviewFiles,
|
||||
self::PublicListingPreviewEnabled,
|
||||
// On by default, but only ever consulted once a provider is
|
||||
// configured — so a fresh install is not protecting forms it
|
||||
// has no keys for.
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
+19
-13
@@ -17,7 +17,6 @@ use App\Modules\Platform\Http\Middleware\SetLocale;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Session\Middleware\AuthenticateSession;
|
||||
|
||||
@@ -33,17 +32,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// Unset = trust nothing, which is correct for the shipped topology
|
||||
// (nginx talks to PHP-FPM directly and passes the real REMOTE_ADDR).
|
||||
// Behind anything else — a load balancer, Cloudflare, the hosted
|
||||
// Cloud ingress — this MUST name the proxy, or every client appears
|
||||
// to come from it: per-IP throttles collapse into one shared bucket
|
||||
// and the download IP log records the proxy instead of the client.
|
||||
$proxies = env('TRUSTED_PROXIES');
|
||||
|
||||
if (is_string($proxies) && $proxies !== '') {
|
||||
$middleware->trustProxies(at: $proxies === '*' ? '*' : explode(',', $proxies));
|
||||
}
|
||||
// Trusted proxies are configured in config/trustedproxy.php, NOT
|
||||
// here. This closure runs when the HTTP kernel is resolved, which is
|
||||
// before the dotenv bootstrapper has read .env, so env() returns null
|
||||
// here for anything that is not already a real environment variable —
|
||||
// silently, and only on web requests (artisan bootstraps in the other
|
||||
// order, so a CLI check reports the setting as working). The framework's
|
||||
// TrustProxies middleware is in the global stack either way and falls
|
||||
// back to that config key on its own.
|
||||
|
||||
$middleware->web(append: [
|
||||
// Binds every session to the password hash it was created under,
|
||||
@@ -56,7 +52,17 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
EnforceTwoFactor::class,
|
||||
SetLocale::class,
|
||||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
// Deliberately NOT here: AddLinkHeadersForPreloadedAssets. It
|
||||
// copies every Vite preload into a `Link:` response header,
|
||||
// and the head of the document already carries the identical
|
||||
// tags — twenty of them on the login page, more on a heavier
|
||||
// one. The copy is what a browser never reads and a proxy has
|
||||
// to buffer: it pushed /files past 6 KB of headers, where the
|
||||
// 4 KB proxy_buffer_size that nginx, and therefore Nginx Proxy
|
||||
// Manager, defaults to answers 502. Some pages fit and some do
|
||||
// not, so it reads as an intermittent fault rather than a
|
||||
// header that is always too big (#1664). Nothing is lost but
|
||||
// 103 Early Hints, which this application does not send.
|
||||
]);
|
||||
|
||||
// The API group gets none of the web stack above — no session, no
|
||||
|
||||
@@ -21,6 +21,12 @@ services:
|
||||
ADMIN_NAME: ${ADMIN_NAME:-}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
|
||||
# The whole default stack declares one, so it comes back after a reboot
|
||||
# or a Docker restart instead of half of it coming back — the confusing
|
||||
# state, where the queue runs and the site is down (#1658). The dev-only
|
||||
# profile services below deliberately do not: you bring those up for a
|
||||
# session, not for the life of the machine.
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -39,6 +45,7 @@ services:
|
||||
volumes:
|
||||
- .:/var/www/html
|
||||
- ./docker/web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
@@ -95,6 +102,7 @@ services:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root}
|
||||
volumes:
|
||||
- db-data:/var/lib/mysql
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Loopback only: this forward exists for host-side DB GUIs, not for
|
||||
# the network. Without the prefix Docker publishes on 0.0.0.0 and
|
||||
@@ -109,6 +117,7 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -37,6 +37,38 @@ return [
|
||||
'root' => storage_path('app/files'),
|
||||
'serve' => false,
|
||||
'throw' => false,
|
||||
|
||||
// A download is not served by PHP. PHP authorizes it and
|
||||
// hands the web server the path with X-Accel-Redirect, so the
|
||||
// web server has to open a file PHP wrote. Where the two are
|
||||
// different users — cPanel and Plesk commonly do this — it
|
||||
// cannot: uploads land 0600 inside a 0700 directory, and
|
||||
// traversing 0700 means *being* its owner. Everything else on
|
||||
// the site keeps working, which is what makes it hard to
|
||||
// place (#1668).
|
||||
//
|
||||
// FILES_WEB_SERVER_READABLE relaxes both to 0644/0755. Opt-in,
|
||||
// because it is strictly weaker: those modes are readable by
|
||||
// every account on the machine, and on a single-user host they
|
||||
// buy nothing. The files stay off the web either way — nginx
|
||||
// only reaches them through an `internal` location.
|
||||
//
|
||||
// The two halves are enforced differently, and only one of
|
||||
// them is absolute. `visibility` makes Flysystem chmod each
|
||||
// file after writing it, so 0644 holds whatever the umask is.
|
||||
// Directories get no chmod — they are created by mkdir(), and
|
||||
// mkdir() masks its mode argument with the process umask — so
|
||||
// 0755 here is a ceiling, not a guarantee. A pool running at
|
||||
// umask 0077 still produces 0700 and still cannot be
|
||||
// traversed; INSTALL.md covers fixing that, because it cannot
|
||||
// be fixed from this file.
|
||||
// Spread rather than two ternaries so that leaving the flag
|
||||
// off is not merely equivalent to the old configuration but
|
||||
// literally it — no install that does not need this sees its
|
||||
// file modes change.
|
||||
...(env('FILES_WEB_SERVER_READABLE', false)
|
||||
? ['visibility' => 'public', 'permissions' => ['dir' => ['private' => 0755]]]
|
||||
: []),
|
||||
],
|
||||
|
||||
// Laravel's stock private disk. Nothing in this application writes
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
// Read here rather than in bootstrap/app.php's withMiddleware closure: that
|
||||
// closure runs when the HTTP kernel is resolved, which under PHP-FPM is
|
||||
// BEFORE the dotenv bootstrapper loads .env, so env('TRUSTED_PROXIES') is
|
||||
// null there on every web request (it works in artisan, which bootstraps
|
||||
// first — the discrepancy is invisible in CLI testing). Config files load
|
||||
// after dotenv, and the framework's TrustProxies middleware falls back to
|
||||
// this key on its own.
|
||||
return [
|
||||
'proxies' => env('TRUSTED_PROXIES'),
|
||||
];
|
||||
+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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -85,6 +85,19 @@ RUN delgroup www-data 2>/dev/null || true \
|
||||
RUN sed -i 's/^user nginx;/user www-data;/' /etc/nginx/nginx.conf \
|
||||
&& chown -R www-data:www-data /var/lib/nginx
|
||||
|
||||
# nginx is the one process here that does not log where supervisord can
|
||||
# see it. supervisord captures what a program writes to its own stdout,
|
||||
# but nginx opens the files named in the package's nginx.conf the moment
|
||||
# it reads its config, so its access and error logs went to
|
||||
# /var/log/nginx/ inside the container — invisible to `docker logs`, which
|
||||
# is the only place anybody looks. That is where the reason for every 502
|
||||
# and every 403 was being written, so a reverse-proxy problem presented as
|
||||
# no logs at all on either side. Point both at the container's own
|
||||
# streams, which is what the rest of this image already does.
|
||||
RUN sed -i -e 's#^error_log .*#error_log /dev/stderr warn;#' \
|
||||
-e 's#^\(\s*\)access_log .*#\1access_log /dev/stdout main;#' \
|
||||
/etc/nginx/nginx.conf
|
||||
|
||||
COPY docker/production/php.ini /usr/local/etc/php/conf.d/projectsend.ini
|
||||
COPY docker/production/www-pool.conf /usr/local/etc/php-fpm.d/zz-www-pool.conf
|
||||
COPY docker/production/nginx.conf /etc/nginx/http.d/default.conf
|
||||
@@ -126,6 +139,14 @@ RUN mkdir -p storage/app/files storage/framework/cache storage/framework/session
|
||||
# session and makes every encrypted column unreadable.
|
||||
VOLUME ["/var/www/html/storage"]
|
||||
|
||||
# How the application knows it is this image and not a container somebody
|
||||
# built from a checkout — the two upgrade completely differently, and it is
|
||||
# the application that prints the instructions. See Installation. It has to
|
||||
# be set here rather than inferred at runtime: an operator who bind-mounts
|
||||
# over /var/www/html can hide any file the image ships as its evidence, and
|
||||
# an environment variable survives that.
|
||||
ENV PROJECTSEND_IMAGE=1
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Laravel's health route (bootstrap/app.php: health: '/up'). Hitting it
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
#
|
||||
# 1. Edit the passwords and APP_URL below.
|
||||
# 2. docker compose -f compose.example.yaml up -d
|
||||
# 3. Open APP_URL. If you set ADMIN_EMAIL/ADMIN_PASSWORD the first
|
||||
# administrator already exists; otherwise the setup screen prompts.
|
||||
# 3. Open APP_URL — the setup screen creates your administrator account.
|
||||
#
|
||||
# This is the file the Docker Hub description points at, so it is written
|
||||
# for someone who has never seen the project before.
|
||||
@@ -58,11 +57,13 @@ services:
|
||||
# download log records the proxy's address.
|
||||
TRUSTED_PROXIES: "*"
|
||||
|
||||
# Optional: creates the first administrator so you skip the setup
|
||||
# screen. Ignored once any user exists.
|
||||
ADMIN_NAME: Administrator
|
||||
ADMIN_EMAIL: admin@example.com
|
||||
ADMIN_PASSWORD: change-me-admin
|
||||
# Optional: uncomment these — with a password of your own — to create
|
||||
# the first administrator unattended and skip the setup screen. Left
|
||||
# commented, the setup screen creates it instead. Ignored once any
|
||||
# user exists.
|
||||
# ADMIN_NAME: Administrator
|
||||
# ADMIN_EMAIL: admin@example.com
|
||||
# ADMIN_PASSWORD: change-me-admin
|
||||
volumes:
|
||||
# Every uploaded file lives here, along with the generated APP_KEY.
|
||||
# This is the volume to back up; losing it loses the data.
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
[](https://github.com/projectsend/projectsend)
|
||||
|
||||
[](https://github.com/projectsend/projectsend/releases "what is in each release, and the downloadable zip")
|
||||
[](https://hub.docker.com/r/projectsend/projectsend/tags "every published tag")
|
||||
[](https://hub.docker.com/r/projectsend/projectsend/tags "compressed size of the latest tag")
|
||||
[](https://github.com/projectsend/projectsend "source, issues and full documentation")
|
||||
[](https://discord.gg/VT9n6cyvXT "release news, and help when something is not behaving")
|
||||
[](https://github.com/projectsend/projectsend/blob/main/LICENSE "free software, and commercial licences for those who need them")
|
||||
|
||||
# ProjectSend
|
||||
|
||||
**Share files with your clients, from your own server.**
|
||||
@@ -11,6 +20,11 @@ per-seat pricing. It runs on your server, and the files stay there.
|
||||
|
||||
This is the official image for the Community edition — free software under the GPL v2 or later.
|
||||
|
||||

|
||||
|
||||
*The dashboard — what is in the installation, and what has been happening in it. More screenshots
|
||||
are in the [README](https://github.com/projectsend/projectsend#screenshots).*
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
@@ -46,11 +60,13 @@ services:
|
||||
# container — including the reverse proxy you should be running.
|
||||
TRUSTED_PROXIES: "*"
|
||||
|
||||
# Optional: creates the first administrator so you skip the setup
|
||||
# screen. Ignored once any user exists.
|
||||
ADMIN_NAME: Administrator
|
||||
ADMIN_EMAIL: admin@example.com
|
||||
ADMIN_PASSWORD: change-me-admin
|
||||
# Optional: uncomment these — with a password of your own — to create
|
||||
# the first administrator unattended and skip the setup screen. Left
|
||||
# commented, the setup screen creates it instead. Ignored once any
|
||||
# user exists.
|
||||
# ADMIN_NAME: Administrator
|
||||
# ADMIN_EMAIL: admin@example.com
|
||||
# ADMIN_PASSWORD: change-me-admin
|
||||
volumes:
|
||||
# Every uploaded file lives here, along with the generated APP_KEY.
|
||||
- storage:/var/www/html/storage
|
||||
@@ -86,8 +102,9 @@ volumes:
|
||||
redis-data:
|
||||
```
|
||||
|
||||
Then open `APP_URL`. If you set `ADMIN_EMAIL` and `ADMIN_PASSWORD` the first administrator already
|
||||
exists; otherwise the setup screen creates one.
|
||||
Then open `APP_URL` — the setup screen creates your administrator account. Uncomment
|
||||
`ADMIN_EMAIL` and `ADMIN_PASSWORD` above, with a password of your own, to create it unattended
|
||||
instead.
|
||||
|
||||
The first start is slower than later ones: the container waits for MySQL to accept connections
|
||||
before it migrates the database. That wait is normal, not a failure.
|
||||
@@ -162,21 +179,14 @@ instead of the real one. `*` is correct when nothing but your proxy can reach th
|
||||
|
||||
## Data and backups
|
||||
|
||||
Everything that must outlive the container is on one volume:
|
||||
Two things must outlive the container: **your MySQL database**, and the volume mounted at
|
||||
**`/var/www/html/storage`**. Uploaded files live on that volume, and so does the generated `.env`
|
||||
holding `APP_KEY` — losing the key makes the SMTP and LDAP passwords stored in your database
|
||||
undecryptable even if the rest of the backup is perfect.
|
||||
|
||||
```
|
||||
/var/www/html/storage
|
||||
```
|
||||
|
||||
Uploaded files live there, and so does the generated `.env` holding `APP_KEY`. **Back up that
|
||||
volume and your MySQL database.** Losing `APP_KEY` makes the SMTP and LDAP passwords stored in your
|
||||
database undecryptable even if the rest of the backup is perfect.
|
||||
|
||||
To keep the data on paths you chose rather than in a named volume, bind-mount a host directory —
|
||||
the container recreates the directory tree it needs at boot.
|
||||
|
||||
A backup nobody has ever restored is a hypothesis, not a backup. Test one, once, on a machine that
|
||||
is not your live one.
|
||||
**[DOCKER.md](https://github.com/projectsend/projectsend/blob/main/DOCKER.md) is the guide**: how
|
||||
to move both onto host paths you chose, the backup and restore commands, and how to move the whole
|
||||
installation to another server. Read it before you put real files in ProjectSend, not after.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+27
-1
@@ -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à.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Purga els fitxers orfes",
|
||||
"Purge read notifications": "Purga les notificacions llegides",
|
||||
"Purge stale chunked uploads": "Purga les pujades per parts abandonades",
|
||||
"Purge zip downloads": "Purga les descàrregues ZIP"
|
||||
"Purge zip downloads": "Purga les descàrregues ZIP",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Coincidències amb aquest filtre: :count, de la més recent a la més antiga",
|
||||
"A file was previewed via the public group listing": "S'ha previsualitzat un fitxer mitjançant el llistat públic de grups",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Qualsevol persona amb l'enllaç pot obrir una imatge, un vídeo, un fitxer d'àudio o un PDF al navegador en comptes de descarregar-lo. Desactiva-ho perquè descarregar sigui l'única manera de veure un fitxer públic.",
|
||||
"Clients can preview files": "Els clients poden previsualitzar fitxers",
|
||||
"Downloads & previews": "Descàrregues i vistes prèvies",
|
||||
"Downloads: :count": "Descàrregues: :count",
|
||||
"Let visitors preview public files": "Permet que els visitants previsualitzin fitxers públics",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Permet que els clients obrin una imatge, un vídeo, un fitxer d'àudio o un PDF al portal en comptes de només descarregar-lo. Desactiva-ho perquè descarregar sigui l'única manera de veure un fitxer. L'equip sempre pot previsualitzar.",
|
||||
"No downloads match these filters.": "Cap descàrrega coincideix amb aquests filtres.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Encara ningú no ha descarregat ni previsualitzat aquest fitxer.",
|
||||
"Preview :name": "Previsualitza :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Ha previsualitzat el fitxer \":subject\" mitjançant el llistat públic de grups",
|
||||
"Previews: :count": "Vistes prèvies: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Aquesta instal·lació construeix les seves pròpies imatges, així que baixar-ne una no canvia res. Si la versió ha modificat composer.lock o el frontend, executa també composer install i npm run build: UPDATE.md té el procediment complet.",
|
||||
"To update, run this in the directory you cloned:": "Per actualitzar, executa això al directori que has clonat:",
|
||||
"View all previews (:count)": "Mostra totes les vistes prèvies (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "El navegador no pot reproduir aquest fitxer. Descarrega'l per veure'l."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Vyčistit osiřelé soubory",
|
||||
"Purge read notifications": "Vyčistit přečtená upozornění",
|
||||
"Purge stale chunked uploads": "Vyčistit opuštěná dílčí nahrávání",
|
||||
"Purge zip downloads": "Vyčistit stažené ZIP archivy"
|
||||
"Purge zip downloads": "Vyčistit stažené ZIP archivy",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Odpovídá tomuto filtru: :count, od nejnovějších",
|
||||
"A file was previewed via the public group listing": "Soubor byl zobrazen v náhledu přes veřejný seznam skupin",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Kdokoli s odkazem může otevřít obrázek, video, zvukový soubor nebo PDF přímo v prohlížeči místo stahování. Vypněte to, pokud má být stažení jediný způsob, jak veřejný soubor zobrazit.",
|
||||
"Clients can preview files": "Klienti mohou zobrazovat náhledy souborů",
|
||||
"Downloads & previews": "Stažení a náhledy",
|
||||
"Downloads: :count": "Stažení: :count",
|
||||
"Let visitors preview public files": "Umožnit návštěvníkům zobrazit náhled veřejných souborů",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Umožní klientům otevřít obrázek, video, zvukový soubor nebo PDF přímo v portálu, ne si ho jen stáhnout. Vypněte to, pokud má být stažení jediný způsob, jak soubor zobrazit. Tým může náhled zobrazit vždy.",
|
||||
"No downloads match these filters.": "Těmto filtrům neodpovídá žádné stažení.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Tento soubor si zatím nikdo nestáhl ani nezobrazil v náhledu.",
|
||||
"Preview :name": "Náhled souboru :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Zobrazil(a) náhled souboru \":subject\" přes veřejný seznam skupin",
|
||||
"Previews: :count": "Náhledy: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Tato instalace si obrazy sestavuje sama, takže stažení hotového obrazu nic nezmění. Pokud vydání změnilo composer.lock nebo frontend, spusťte také composer install a npm run build — celý postup najdete v UPDATE.md.",
|
||||
"To update, run this in the directory you cloned:": "Aktualizaci provedete tímto příkazem v adresáři, který jste naklonovali:",
|
||||
"View all previews (:count)": "Zobrazit všechny náhledy (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Váš prohlížeč tento soubor nepřehraje. Stáhněte si ho a zobrazte si ho tak."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Verwaiste Dateien bereinigen",
|
||||
"Purge read notifications": "Gelesene Benachrichtigungen bereinigen",
|
||||
"Purge stale chunked uploads": "Abgebrochene Teil-Uploads bereinigen",
|
||||
"Purge zip downloads": "ZIP-Downloads bereinigen"
|
||||
"Purge zip downloads": "ZIP-Downloads bereinigen",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": ":count Treffer für diesen Filter, neueste zuerst",
|
||||
"A file was previewed via the public group listing": "Eine Datei wurde über das öffentliche Gruppenverzeichnis in der Vorschau geöffnet",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Wer den Link hat, kann ein Bild, ein Video, eine Audiodatei oder ein PDF im Browser öffnen, statt es herunterzuladen. Schalte es aus, damit sich eine öffentliche Datei nur per Download ansehen lässt.",
|
||||
"Clients can preview files": "Kunden können Dateien in der Vorschau öffnen",
|
||||
"Downloads & previews": "Downloads & Vorschauen",
|
||||
"Downloads: :count": "Downloads: :count",
|
||||
"Let visitors preview public files": "Besucher dürfen öffentliche Dateien in der Vorschau öffnen",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Lässt Kunden ein Bild, ein Video, eine Audiodatei oder ein PDF im Portal öffnen, statt es nur herunterzuladen. Schalte es aus, damit sich eine Datei nur per Download ansehen lässt. Das Team kann immer eine Vorschau öffnen.",
|
||||
"No downloads match these filters.": "Kein Download entspricht diesen Filtern.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Diese Datei hat noch niemand heruntergeladen oder in der Vorschau geöffnet.",
|
||||
"Preview :name": ":name in der Vorschau öffnen",
|
||||
"Previewed the file \":subject\" via the public group listing": "Hat die Datei \":subject\" über das öffentliche Gruppenverzeichnis in der Vorschau geöffnet",
|
||||
"Previews: :count": "Vorschauen: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Diese Installation baut ihre Images selbst, ein Pull ändert also nichts. Wenn das Release composer.lock oder das Frontend geändert hat, führe zusätzlich composer install und npm run build aus – die vollständige Anleitung steht in UPDATE.md.",
|
||||
"To update, run this in the directory you cloned:": "Zum Aktualisieren führe dies im geklonten Verzeichnis aus:",
|
||||
"View all previews (:count)": "Alle Vorschauen ansehen (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Dein Browser kann diese Datei nicht abspielen. Lade sie herunter, um sie anzusehen."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Purgar archivos huérfanos",
|
||||
"Purge read notifications": "Purgar notificaciones leídas",
|
||||
"Purge stale chunked uploads": "Purgar subidas por partes abandonadas",
|
||||
"Purge zip downloads": "Purgar descargas ZIP"
|
||||
"Purge zip downloads": "Purgar descargas ZIP",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Coinciden con este filtro: :count, de la más reciente a la más antigua",
|
||||
"A file was previewed via the public group listing": "Se previsualizó un archivo mediante el listado público de grupos",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Cualquiera con el enlace puede abrir una imagen, un video, un audio o un PDF en el navegador en lugar de descargarlo. Desactívalo para que descargar sea la única forma de ver un archivo público.",
|
||||
"Clients can preview files": "Los clientes pueden previsualizar archivos",
|
||||
"Downloads & previews": "Descargas y previsualizaciones",
|
||||
"Downloads: :count": "Descargas: :count",
|
||||
"Let visitors preview public files": "Permitir que los visitantes previsualicen archivos públicos",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Permite que los clientes abran una imagen, un video, un audio o un PDF en el portal en lugar de solo descargarlo. Desactívalo para que descargar sea la única forma de ver un archivo. El personal siempre puede previsualizar.",
|
||||
"No downloads match these filters.": "Ninguna descarga coincide con estos filtros.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Todavía nadie descargó ni previsualizó este archivo.",
|
||||
"Preview :name": "Previsualizar :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Previsualizó el archivo \":subject\" mediante el listado público de grupos",
|
||||
"Previews: :count": "Previsualizaciones: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Esta instalación construye sus propias imágenes, así que descargar una no cambia nada. Si la versión modificó composer.lock o el frontend, ejecuta también composer install y npm run build: UPDATE.md tiene el procedimiento completo.",
|
||||
"To update, run this in the directory you cloned:": "Para actualizar, ejecuta esto en el directorio que clonaste:",
|
||||
"View all previews (:count)": "Ver todas las previsualizaciones (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Tu navegador no puede reproducir este archivo. Descárgalo para verlo."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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é.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Purger les fichiers orphelins",
|
||||
"Purge read notifications": "Purger les notifications lues",
|
||||
"Purge stale chunked uploads": "Purger les envois par morceaux abandonnés",
|
||||
"Purge zip downloads": "Purger les téléchargements ZIP"
|
||||
"Purge zip downloads": "Purger les téléchargements ZIP",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Correspondances pour ce filtre : :count, de la plus récente à la plus ancienne",
|
||||
"A file was previewed via the public group listing": "Un fichier a été prévisualisé via l'annuaire public des groupes",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Toute personne disposant du lien peut ouvrir une image, une vidéo, un fichier audio ou un PDF dans le navigateur au lieu de le télécharger. Désactivez l'option pour que le téléchargement soit le seul moyen de voir un fichier public.",
|
||||
"Clients can preview files": "Les clients peuvent prévisualiser les fichiers",
|
||||
"Downloads & previews": "Téléchargements et aperçus",
|
||||
"Downloads: :count": "Téléchargements : :count",
|
||||
"Let visitors preview public files": "Autoriser les visiteurs à prévisualiser les fichiers publics",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Permet aux clients d'ouvrir une image, une vidéo, un fichier audio ou un PDF dans le portail au lieu de seulement le télécharger. Désactivez l'option pour que le téléchargement soit le seul moyen de voir un fichier. L'équipe peut toujours prévisualiser.",
|
||||
"No downloads match these filters.": "Aucun téléchargement ne correspond à ces filtres.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Personne n'a encore téléchargé ni prévisualisé ce fichier.",
|
||||
"Preview :name": "Prévisualiser :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "A prévisualisé le fichier \":subject\" via l'annuaire public des groupes",
|
||||
"Previews: :count": "Aperçus : :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Cette installation construit ses propres images : en télécharger une ne change rien. Si la version a modifié composer.lock ou le frontend, exécutez aussi composer install et npm run build — UPDATE.md décrit la procédure complète.",
|
||||
"To update, run this in the directory you cloned:": "Pour mettre à jour, exécutez ceci dans le répertoire que vous avez cloné :",
|
||||
"View all previews (:count)": "Voir tous les aperçus (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Votre navigateur ne peut pas lire ce fichier. Téléchargez-le pour le consulter."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Bersihkan berkas yatim",
|
||||
"Purge read notifications": "Bersihkan notifikasi yang sudah dibaca",
|
||||
"Purge stale chunked uploads": "Bersihkan unggahan bertahap yang terbengkalai",
|
||||
"Purge zip downloads": "Bersihkan unduhan ZIP"
|
||||
"Purge zip downloads": "Bersihkan unduhan ZIP",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Cocok dengan filter ini: :count, terbaru lebih dulu",
|
||||
"A file was previewed via the public group listing": "Sebuah berkas dipratinjau lewat daftar grup publik",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Siapa pun yang punya tautannya bisa membuka gambar, video, berkas audio, atau PDF di peramban alih-alih mengunduhnya. Matikan agar mengunduh menjadi satu-satunya cara melihat berkas publik.",
|
||||
"Clients can preview files": "Klien bisa mempratinjau berkas",
|
||||
"Downloads & previews": "Unduhan & pratinjau",
|
||||
"Downloads: :count": "Unduhan: :count",
|
||||
"Let visitors preview public files": "Izinkan pengunjung mempratinjau berkas publik",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Memungkinkan klien membuka gambar, video, berkas audio, atau PDF di portal, bukan sekadar mengunduhnya. Matikan agar mengunduh menjadi satu-satunya cara melihat berkas. Staf selalu bisa mempratinjau.",
|
||||
"No downloads match these filters.": "Tidak ada unduhan yang cocok dengan filter ini.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Belum ada yang mengunduh atau mempratinjau berkas ini.",
|
||||
"Preview :name": "Pratinjau :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Mempratinjau berkas \":subject\" lewat daftar grup publik",
|
||||
"Previews: :count": "Pratinjau: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Instalasi ini membangun image-nya sendiri, jadi menarik image tidak mengubah apa pun. Kalau rilis ini mengubah composer.lock atau frontend, jalankan juga composer install dan npm run build — prosedur lengkapnya ada di UPDATE.md.",
|
||||
"To update, run this in the directory you cloned:": "Untuk memperbarui, jalankan ini di direktori hasil klon:",
|
||||
"View all previews (:count)": "Lihat semua pratinjau (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Peramban Anda tidak bisa memutar berkas ini. Unduh berkasnya untuk melihat isinya."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Elimina i file orfani",
|
||||
"Purge read notifications": "Elimina le notifiche lette",
|
||||
"Purge stale chunked uploads": "Elimina i caricamenti a blocchi abbandonati",
|
||||
"Purge zip downloads": "Elimina i download ZIP"
|
||||
"Purge zip downloads": "Elimina i download ZIP",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Corrispondenze con questo filtro: :count, dalla più recente",
|
||||
"A file was previewed via the public group listing": "Un file è stato visualizzato in anteprima tramite l'elenco pubblico dei gruppi",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Chiunque abbia il link può aprire un'immagine, un video, un file audio o un PDF nel browser invece di scaricarlo. Disattivalo se vuoi che il download sia l'unico modo di vedere un file pubblico.",
|
||||
"Clients can preview files": "I clienti possono visualizzare i file in anteprima",
|
||||
"Downloads & previews": "Download e anteprime",
|
||||
"Downloads: :count": "Download: :count",
|
||||
"Let visitors preview public files": "Consenti ai visitatori di vedere l'anteprima dei file pubblici",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Consente ai clienti di aprire un'immagine, un video, un file audio o un PDF nel portale invece di doverlo solo scaricare. Disattivalo se vuoi che il download sia l'unico modo di vedere un file. Lo staff può sempre usare l'anteprima.",
|
||||
"No downloads match these filters.": "Nessun download corrisponde a questi filtri.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Nessuno ha ancora scaricato o visualizzato in anteprima questo file.",
|
||||
"Preview :name": "Anteprima di :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Ha visualizzato in anteprima il file \":subject\" tramite l'elenco pubblico dei gruppi",
|
||||
"Previews: :count": "Anteprime: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Questa installazione costruisce le proprie immagini, quindi scaricarne una non cambia nulla. Se la versione ha modificato composer.lock o il frontend, esegui anche composer install e npm run build: la procedura completa è in UPDATE.md.",
|
||||
"To update, run this in the directory you cloned:": "Per aggiornare, esegui questo nella cartella che hai clonato:",
|
||||
"View all previews (:count)": "Vedi tutte le anteprime (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Il tuo browser non può riprodurre questo file. Scaricalo per vederlo."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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 のアカウント申請を却下し、アカウントを削除します。",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "孤立ファイルを削除",
|
||||
"Purge read notifications": "既読の通知を削除",
|
||||
"Purge stale chunked uploads": "中断された分割アップロードを削除",
|
||||
"Purge zip downloads": "ZIP ダウンロードを削除"
|
||||
"Purge zip downloads": "ZIP ダウンロードを削除",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "このフィルターに一致: :count 件 (新しい順)",
|
||||
"A file was previewed via the public group listing": "公開グループ一覧からファイルがプレビューされました",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "リンクを知っている人は、画像・動画・音声ファイル・PDF をダウンロードせずにブラウザーで開けます。オフにすると、公開ファイルを見る方法はダウンロードだけになります。",
|
||||
"Clients can preview files": "クライアントはファイルをプレビューできます",
|
||||
"Downloads & previews": "ダウンロードとプレビュー",
|
||||
"Downloads: :count": "ダウンロード: :count",
|
||||
"Let visitors preview public files": "訪問者が公開ファイルをプレビューできるようにする",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "クライアントが画像・動画・音声ファイル・PDF をダウンロードするだけでなく、ポータル内で開けるようになります。オフにすると、ファイルを見る方法はダウンロードだけになります。スタッフは常にプレビューできます。",
|
||||
"No downloads match these filters.": "この条件に一致するダウンロードはありません。",
|
||||
"Nobody has downloaded or previewed this file yet.": "このファイルはまだ誰もダウンロードもプレビューもしていません。",
|
||||
"Preview :name": ":name をプレビュー",
|
||||
"Previewed the file \":subject\" via the public group listing": "公開グループ一覧からファイル「:subject」をプレビューしました",
|
||||
"Previews: :count": "プレビュー: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "このインストールはイメージを自分でビルドするため、イメージを取得しても何も変わりません。リリースで composer.lock やフロントエンドが変わっている場合は、composer install と npm run build も実行してください。詳しい手順は UPDATE.md にあります。",
|
||||
"To update, run this in the directory you cloned:": "更新するには、クローンしたディレクトリで次を実行してください:",
|
||||
"View all previews (:count)": "すべてのプレビューを表示 (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "お使いのブラウザーではこのファイルを再生できません。ダウンロードしてご覧ください。"
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Verweesde bestanden opschonen",
|
||||
"Purge read notifications": "Gelezen meldingen opschonen",
|
||||
"Purge stale chunked uploads": "Afgebroken deel-uploads opschonen",
|
||||
"Purge zip downloads": "ZIP-downloads opschonen"
|
||||
"Purge zip downloads": "ZIP-downloads opschonen",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Treffers voor dit filter: :count, nieuwste eerst",
|
||||
"A file was previewed via the public group listing": "Een bestand is via het openbare groepsoverzicht in het voorbeeld bekeken",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Iedereen met de link kan een afbeelding, video, audiobestand of PDF in de browser openen in plaats van het te downloaden. Zet dit uit als downloaden de enige manier moet zijn om een openbaar bestand te bekijken.",
|
||||
"Clients can preview files": "Klanten kunnen bestanden in voorvertoning bekijken",
|
||||
"Downloads & previews": "Downloads en voorvertoningen",
|
||||
"Downloads: :count": "Downloads: :count",
|
||||
"Let visitors preview public files": "Bezoekers openbare bestanden laten bekijken in voorvertoning",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Laat klanten een afbeelding, video, audiobestand of PDF in het portaal openen in plaats van het alleen te downloaden. Zet dit uit als downloaden de enige manier moet zijn om een bestand te bekijken. Het team kan altijd een voorvertoning bekijken.",
|
||||
"No downloads match these filters.": "Geen download voldoet aan deze filters.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Niemand heeft dit bestand nog gedownload of in het voorbeeld bekeken.",
|
||||
"Preview :name": ":name in voorvertoning bekijken",
|
||||
"Previewed the file \":subject\" via the public group listing": "Heeft het bestand \":subject\" via het openbare groepsoverzicht in het voorbeeld bekeken",
|
||||
"Previews: :count": "Voorvertoningen: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Deze installatie bouwt zijn eigen images, dus een image ophalen verandert niets. Heeft de release composer.lock of de frontend gewijzigd, voer dan ook composer install en npm run build uit — UPDATE.md beschrijft de volledige procedure.",
|
||||
"To update, run this in the directory you cloned:": "Voer dit uit in de map die je hebt gekloond om bij te werken:",
|
||||
"View all previews (:count)": "Alle voorvertoningen bekijken (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Je browser kan dit bestand niet afspelen. Download het om het te bekijken."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Wyczyść osierocone pliki",
|
||||
"Purge read notifications": "Wyczyść przeczytane powiadomienia",
|
||||
"Purge stale chunked uploads": "Wyczyść porzucone przesyłania częściowe",
|
||||
"Purge zip downloads": "Wyczyść pobrania ZIP"
|
||||
"Purge zip downloads": "Wyczyść pobrania ZIP",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Pasujących do tego filtra: :count, od najnowszych",
|
||||
"A file was previewed via the public group listing": "Wyświetlono podgląd pliku przez publiczną listę grup",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Każdy, kto ma link, może otworzyć obraz, wideo, plik audio lub PDF w przeglądarce zamiast go pobierać. Wyłącz tę opcję, jeśli pobieranie ma być jedynym sposobem obejrzenia pliku publicznego.",
|
||||
"Clients can preview files": "Klienci mogą wyświetlać podgląd plików",
|
||||
"Downloads & previews": "Pobrania i podglądy",
|
||||
"Downloads: :count": "Pobrania: :count",
|
||||
"Let visitors preview public files": "Zezwól odwiedzającym na podgląd plików publicznych",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Pozwala klientom otworzyć obraz, wideo, plik audio lub PDF w portalu, zamiast tylko go pobierać. Wyłącz tę opcję, jeśli pobieranie ma być jedynym sposobem obejrzenia pliku. Zespół zawsze ma dostęp do podglądu.",
|
||||
"No downloads match these filters.": "Żadne pobranie nie pasuje do tych filtrów.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Nikt jeszcze nie pobrał ani nie wyświetlił podglądu tego pliku.",
|
||||
"Preview :name": "Podgląd :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Wyświetlił(a) podgląd pliku \":subject\" przez publiczną listę grup",
|
||||
"Previews: :count": "Podglądy: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Ta instalacja sama buduje swoje obrazy, więc pobranie gotowego obrazu niczego nie zmienia. Jeśli wydanie zmieniło composer.lock lub frontend, uruchom także composer install i npm run build — pełną procedurę znajdziesz w UPDATE.md.",
|
||||
"To update, run this in the directory you cloned:": "Aby zaktualizować, uruchom to w sklonowanym katalogu:",
|
||||
"View all previews (:count)": "Pokaż wszystkie podglądy (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Twoja przeglądarka nie odtworzy tego pliku. Pobierz go, aby go obejrzeć."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Limpar arquivos órfãos",
|
||||
"Purge read notifications": "Limpar notificações lidas",
|
||||
"Purge stale chunked uploads": "Limpar envios em partes abandonados",
|
||||
"Purge zip downloads": "Limpar downloads ZIP"
|
||||
"Purge zip downloads": "Limpar downloads ZIP",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Correspondem a este filtro: :count, da mais recente à mais antiga",
|
||||
"A file was previewed via the public group listing": "Um arquivo foi pré-visualizado pela listagem pública de grupos",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Qualquer pessoa com o link pode abrir uma imagem, um vídeo, um arquivo de áudio ou um PDF no navegador em vez de baixá-lo. Desative para que baixar seja a única forma de ver um arquivo público.",
|
||||
"Clients can preview files": "Os clientes podem pré-visualizar arquivos",
|
||||
"Downloads & previews": "Downloads e pré-visualizações",
|
||||
"Downloads: :count": "Downloads: :count",
|
||||
"Let visitors preview public files": "Permitir que visitantes pré-visualizem arquivos públicos",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Permite que os clientes abram uma imagem, um vídeo, um arquivo de áudio ou um PDF no portal em vez de apenas baixá-lo. Desative para que baixar seja a única forma de ver um arquivo. A equipe sempre pode pré-visualizar.",
|
||||
"No downloads match these filters.": "Nenhum download corresponde a estes filtros.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Ninguém baixou nem pré-visualizou este arquivo ainda.",
|
||||
"Preview :name": "Pré-visualizar :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Pré-visualizou o arquivo \":subject\" pela listagem pública de grupos",
|
||||
"Previews: :count": "Pré-visualizações: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Esta instalação constrói as próprias imagens, então baixar uma não muda nada. Se a versão mexeu no composer.lock ou no frontend, rode também composer install e npm run build — o UPDATE.md tem o procedimento completo.",
|
||||
"To update, run this in the directory you cloned:": "Para atualizar, rode isto no diretório que você clonou:",
|
||||
"View all previews (:count)": "Ver todas as pré-visualizações (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Seu navegador não consegue reproduzir este arquivo. Baixe o arquivo para visualizá-lo."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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 на учётную запись будет отклонена, а сама запись удалена.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Очистить осиротевшие файлы",
|
||||
"Purge read notifications": "Очистить прочитанные уведомления",
|
||||
"Purge stale chunked uploads": "Очистить брошенные частичные загрузки",
|
||||
"Purge zip downloads": "Очистить ZIP-архивы для скачивания"
|
||||
"Purge zip downloads": "Очистить ZIP-архивы для скачивания",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Соответствует этому фильтру: :count, начиная с последнего",
|
||||
"A file was previewed via the public group listing": "Файл просмотрен через публичный список групп",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Любой, у кого есть ссылка, может открыть изображение, видео, аудиофайл или PDF в браузере, не скачивая его. Отключите, если единственным способом увидеть публичный файл должно быть скачивание.",
|
||||
"Clients can preview files": "Клиенты могут просматривать файлы",
|
||||
"Downloads & previews": "Скачивания и просмотры",
|
||||
"Downloads: :count": "Скачиваний: :count",
|
||||
"Let visitors preview public files": "Разрешить посетителям просматривать публичные файлы",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Позволяет клиентам открывать изображение, видео, аудиофайл или PDF прямо в портале, а не только скачивать. Отключите, если единственным способом увидеть файл должно быть скачивание. Команда может просматривать файлы всегда.",
|
||||
"No downloads match these filters.": "Нет скачиваний, подходящих под эти фильтры.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Этот файл пока никто не скачивал и не просматривал.",
|
||||
"Preview :name": "Просмотреть :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Просмотрел(а) файл «:subject» через публичный список групп",
|
||||
"Previews: :count": "Просмотров: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Эта установка собирает образы сама, поэтому загрузка готового образа ничего не изменит. Если в выпуске изменились composer.lock или фронтенд, выполните также composer install и npm run build — полная процедура описана в UPDATE.md.",
|
||||
"To update, run this in the directory you cloned:": "Чтобы обновить, выполните это в каталоге, который вы клонировали:",
|
||||
"View all previews (:count)": "Показать все просмотры (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Ваш браузер не может воспроизвести этот файл. Скачайте его, чтобы посмотреть."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Safisha mafaili yatima",
|
||||
"Purge read notifications": "Safisha arifa zilizosomwa",
|
||||
"Purge stale chunked uploads": "Safisha upakiaji wa vipande ulioachwa",
|
||||
"Purge zip downloads": "Safisha vipakuliwa vya ZIP"
|
||||
"Purge zip downloads": "Safisha vipakuliwa vya ZIP",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Zinazolingana na kichujio hiki: :count, mpya kwanza",
|
||||
"A file was previewed via the public group listing": "Faili limehakikiwa kupitia orodha ya umma ya vikundi",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Yeyote mwenye kiungo anaweza kufungua picha, video, faili la sauti au PDF katika kivinjari badala ya kulipakua. Zima ili kupakua kuwe njia pekee ya kuona faili la umma.",
|
||||
"Clients can preview files": "Wateja wanaweza kuhakiki faili",
|
||||
"Downloads & previews": "Vipakuliwa na hakiki",
|
||||
"Downloads: :count": "Vipakuliwa: :count",
|
||||
"Let visitors preview public files": "Ruhusu wageni kuhakiki faili za umma",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Huruhusu wateja kufungua picha, video, faili la sauti au PDF ndani ya lango badala ya kulipakua tu. Zima ili kupakua kuwe njia pekee ya kuona faili. Wafanyakazi wanaweza kuhakiki kila wakati.",
|
||||
"No downloads match these filters.": "Hakuna kipakuliwa kinacholingana na vichujio hivi.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Bado hakuna aliyepakua wala kuhakiki faili hili.",
|
||||
"Preview :name": "Hakiki :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Amehakiki faili \":subject\" kupitia orodha ya umma ya vikundi",
|
||||
"Previews: :count": "Hakiki: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Usakinishaji huu hujenga picha zake mwenyewe, kwa hivyo kuvuta picha hakubadilishi chochote. Ikiwa toleo limebadilisha composer.lock au sehemu ya mbele, endesha pia composer install na npm run build — UPDATE.md ina utaratibu kamili.",
|
||||
"To update, run this in the directory you cloned:": "Ili kusasisha, endesha hii katika saraka uliyoiklona:",
|
||||
"View all previews (:count)": "Angalia hakiki zote (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Kivinjari chako hakiwezi kucheza faili hili. Lipakue ili kuliona."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Sahipsiz dosyaları temizle",
|
||||
"Purge read notifications": "Okunmuş bildirimleri temizle",
|
||||
"Purge stale chunked uploads": "Yarım kalmış parçalı yüklemeleri temizle",
|
||||
"Purge zip downloads": "ZIP indirmelerini temizle"
|
||||
"Purge zip downloads": "ZIP indirmelerini temizle",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Bu filtreye uyan: :count, en yenisi başta",
|
||||
"A file was previewed via the public group listing": "Bir dosya herkese açık grup listesi üzerinden önizlendi",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Bağlantıya sahip olan herkes bir görseli, videoyu, ses dosyasını veya PDF'yi indirmek yerine tarayıcıda açabilir. Herkese açık bir dosyayı görmenin tek yolu indirmek olsun istiyorsanız bunu kapatın.",
|
||||
"Clients can preview files": "Müşteriler dosyaları önizleyebilir",
|
||||
"Downloads & previews": "İndirmeler ve önizlemeler",
|
||||
"Downloads: :count": "İndirmeler: :count",
|
||||
"Let visitors preview public files": "Ziyaretçiler herkese açık dosyaları önizleyebilsin",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Müşterilerin bir görseli, videoyu, ses dosyasını veya PDF'yi yalnızca indirmek yerine portalda açmasına izin verir. Bir dosyayı görmenin tek yolu indirmek olsun istiyorsanız bunu kapatın. Ekip her zaman önizleyebilir.",
|
||||
"No downloads match these filters.": "Bu filtrelere uyan indirme yok.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Bu dosyayı henüz kimse indirmedi veya önizlemedi.",
|
||||
"Preview :name": ":name dosyasını önizle",
|
||||
"Previewed the file \":subject\" via the public group listing": "\":subject\" dosyasını herkese açık grup listesi üzerinden önizledi",
|
||||
"Previews: :count": "Önizlemeler: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Bu kurulum kendi imajlarını derler, dolayısıyla hazır bir imaj çekmek hiçbir şeyi değiştirmez. Sürüm composer.lock dosyasını veya ön yüzü değiştirdiyse composer install ve npm run build komutlarını da çalıştırın — tüm yordam UPDATE.md içinde.",
|
||||
"To update, run this in the directory you cloned:": "Güncellemek için bunu klonladığınız dizinde çalıştırın:",
|
||||
"View all previews (:count)": "Tüm önizlemeleri gör (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Tarayıcınız bu dosyayı oynatamıyor. Görüntülemek için indirin."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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.",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "Dọn các tệp mồ côi",
|
||||
"Purge read notifications": "Dọn các thông báo đã đọc",
|
||||
"Purge stale chunked uploads": "Dọn các lần tải lên theo phần bị bỏ dở",
|
||||
"Purge zip downloads": "Dọn các tệp ZIP đã tải xuống"
|
||||
"Purge zip downloads": "Dọn các tệp ZIP đã tải xuống",
|
||||
":action (:count)": ":action (:count)",
|
||||
":count matching this filter, newest first": "Khớp với bộ lọc này: :count, mới nhất trước",
|
||||
"A file was previewed via the public group listing": "Một tệp đã được xem trước qua danh sách nhóm công khai",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "Bất kỳ ai có liên kết đều có thể mở hình ảnh, video, tệp âm thanh hoặc PDF ngay trong trình duyệt thay vì tải xuống. Hãy tắt tuỳ chọn này nếu tải xuống phải là cách duy nhất để xem tệp công khai.",
|
||||
"Clients can preview files": "Khách hàng có thể xem trước tệp",
|
||||
"Downloads & previews": "Lượt tải xuống và xem trước",
|
||||
"Downloads: :count": "Lượt tải xuống: :count",
|
||||
"Let visitors preview public files": "Cho phép khách truy cập xem trước tệp công khai",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "Cho phép khách hàng mở hình ảnh, video, tệp âm thanh hoặc PDF ngay trong cổng thông tin thay vì chỉ tải xuống. Hãy tắt tuỳ chọn này nếu tải xuống phải là cách duy nhất để xem tệp. Nhân sự luôn có thể xem trước.",
|
||||
"No downloads match these filters.": "Không có lượt tải xuống nào khớp với các bộ lọc này.",
|
||||
"Nobody has downloaded or previewed this file yet.": "Chưa có ai tải xuống hay xem trước tệp này.",
|
||||
"Preview :name": "Xem trước :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "Đã xem trước tệp “:subject” qua danh sách nhóm công khai",
|
||||
"Previews: :count": "Lượt xem trước: :count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "Bản cài đặt này tự dựng image của mình, nên kéo image về cũng không thay đổi gì. Nếu bản phát hành có đụng tới composer.lock hoặc phần giao diện, hãy chạy thêm composer install và npm run build — UPDATE.md có quy trình đầy đủ.",
|
||||
"To update, run this in the directory you cloned:": "Để cập nhật, hãy chạy lệnh này trong thư mục bạn đã sao chép:",
|
||||
"View all previews (:count)": "Xem tất cả lượt xem trước (:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "Trình duyệt của bạn không phát được tệp này. Hãy tải xuống để xem."
|
||||
}
|
||||
|
||||
+27
-1
@@ -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 的账户申请将被拒绝,账户也会被删除。",
|
||||
@@ -1832,5 +1840,23 @@
|
||||
"Purge orphan files": "清理孤立文件",
|
||||
"Purge read notifications": "清理已读通知",
|
||||
"Purge stale chunked uploads": "清理中断的分块上传",
|
||||
"Purge zip downloads": "清理 ZIP 下载"
|
||||
"Purge zip downloads": "清理 ZIP 下载",
|
||||
":action (:count)": ":action(:count)",
|
||||
":count matching this filter, newest first": "符合此筛选条件::count 条,最新在前",
|
||||
"A file was previewed via the public group listing": "文件通过公开群组列表被预览",
|
||||
"Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.": "拿到链接的任何人都可以在浏览器中直接打开图片、视频、音频或 PDF,而不必先下载。关闭后,下载将是查看公开文件的唯一方式。",
|
||||
"Clients can preview files": "客户可以预览文件",
|
||||
"Downloads & previews": "下载与预览",
|
||||
"Downloads: :count": "下载::count",
|
||||
"Let visitors preview public files": "允许访客预览公开文件",
|
||||
"Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.": "让客户可以在门户中直接打开图片、视频、音频或 PDF,而不必只能下载。关闭后,下载将是查看文件的唯一方式。团队成员始终可以预览。",
|
||||
"No downloads match these filters.": "没有符合这些筛选条件的下载记录。",
|
||||
"Nobody has downloaded or previewed this file yet.": "还没有人下载或预览过这个文件。",
|
||||
"Preview :name": "预览 :name",
|
||||
"Previewed the file \":subject\" via the public group listing": "通过公开群组列表预览了文件“:subject”",
|
||||
"Previews: :count": "预览::count",
|
||||
"This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.": "本安装会自行构建镜像,因此拉取镜像不会有任何变化。如果这个版本改动了 composer.lock 或前端,请同时执行 composer install 和 npm run build——完整步骤见 UPDATE.md。",
|
||||
"To update, run this in the directory you cloned:": "如需更新,请在克隆得到的目录中执行:",
|
||||
"View all previews (:count)": "查看全部预览记录(:count)",
|
||||
"Your browser cannot play this file. Download it to view it.": "你的浏览器无法播放这个文件。请下载后查看。"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { usePage } from '@inertiajs/react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { isContainerInstall } from '@/components/update-instructions';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { type SharedData } from '@/types';
|
||||
|
||||
@@ -27,7 +28,10 @@ export function CodeNoticeBanner() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = notice.install_kind === 'container';
|
||||
// Both container kinds answer this the same way: what fixes it is
|
||||
// recreating the container, whether its image came from a registry or
|
||||
// from a build on this machine.
|
||||
const container = isContainerInstall(notice.install_kind);
|
||||
|
||||
// Stated as a fact first, cause second. A deliberate rollback lands
|
||||
// here too, and telling somebody to reload PHP-FPM when they meant to
|
||||
|
||||
@@ -3,14 +3,108 @@ import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { previewKind, type PreviewKind } from '@/lib/previews';
|
||||
|
||||
/** Wide enough to read detail in; audio has nothing to look at. */
|
||||
export function previewDialogWidth(kind: PreviewKind): string {
|
||||
return kind === 'audio' ? 'max-w-lg' : 'max-w-4xl';
|
||||
}
|
||||
|
||||
/**
|
||||
* What a preview actually shows, in whichever element the file's type
|
||||
* calls for — shared by both ways of opening one (clicking the thumbnail,
|
||||
* via FilePreviewDialog; or the explicit Preview action, via
|
||||
* PreviewAction), so the players themselves exist once.
|
||||
*
|
||||
* Rendered only while the dialog is open, so closing it stops playback and
|
||||
* drops the connection rather than leaving a video buffering behind a
|
||||
* hidden panel. Callers enforce that by mounting this conditionally.
|
||||
*/
|
||||
export function FilePreviewBody({ previewUrl, mimeType, fileName }: { previewUrl: string; mimeType: string; fileName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const kind = previewKind(mimeType);
|
||||
|
||||
if (kind === 'image') {
|
||||
return <img src={previewUrl} alt={fileName} className="max-h-[80vh] w-full rounded object-contain" />;
|
||||
}
|
||||
|
||||
if (kind === 'video') {
|
||||
return (
|
||||
// preload="metadata" so opening the dialog costs a few
|
||||
// kilobytes and a duration, not the file — the rest arrives in
|
||||
// ranges once someone presses play.
|
||||
<video src={previewUrl} controls preload="metadata" className="max-h-[80vh] w-full rounded bg-black">
|
||||
{t('Your browser cannot play this file. Download it to view it.')}
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'audio') {
|
||||
return (
|
||||
<audio src={previewUrl} controls preload="metadata" className="w-full">
|
||||
{t('Your browser cannot play this file. Download it to view it.')}
|
||||
</audio>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'pdf') {
|
||||
return (
|
||||
// No `sandbox` attribute, and that is deliberate: Chrome
|
||||
// refuses to run its PDF viewer in a sandboxed frame at all
|
||||
// (ERR_BLOCKED_BY_CLIENT, with or without allow-same-origin),
|
||||
// so adding one does not harden this — it removes the feature.
|
||||
// Nor would it add anything if it worked: /protected-files/
|
||||
// already answers with `Content-Security-Policy: sandbox;
|
||||
// default-src 'none'`, which measurably does put a <video>
|
||||
// frame in an opaque origin — and which Chrome exempts its PDF
|
||||
// viewer from either way.
|
||||
//
|
||||
// What actually holds here is PreviewKind: only
|
||||
// `application/pdf` reaches this element, never text/html or
|
||||
// image/svg+xml, which are the types that would really execute
|
||||
// script against this origin. A PDF's own JavaScript runs
|
||||
// inside the browser's PDF sandbox, with no DOM and no cookies
|
||||
// — the same footing every site that displays a PDF stands on.
|
||||
<iframe src={previewUrl} title={t('Preview of :name', { name: fileName })} className="h-[80vh] w-full rounded border" />
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thumbnail (or a row's icon) turned into a click target that opens the
|
||||
* file for a look.
|
||||
*
|
||||
* This is the *implicit* way in — the picture you can obviously click.
|
||||
* The explicit one is <PreviewAction>, the labelled button or eye icon
|
||||
* that sits with a row's other actions; a surface generally offers both,
|
||||
* because a photograph invites a click and a PDF icon does not.
|
||||
*
|
||||
* Takes a URL rather than an id because the same dialog serves three
|
||||
* surfaces that address a file differently: staff and the client portal
|
||||
* build route('files.preview', id), while a public page is handed a
|
||||
* server-decided URL. Null means this viewer is not offered a preview at
|
||||
* all — the setting is off, or the server declined to advertise one — and
|
||||
* is treated exactly like an unpreviewable type.
|
||||
*
|
||||
* `children` is whatever the calling surface already draws — a thumbnail
|
||||
* for an image, that theme's own icon for anything else — so a theme keeps
|
||||
* its own look and only gains a click target. A type with no inline view
|
||||
* renders `children` untouched and adds nothing, so a caller whose
|
||||
* fallback looks the same either way can wrap unconditionally; one whose
|
||||
* fallback needs different markup (a grid tile that has to keep its own
|
||||
* box) branches on isPreviewable() itself.
|
||||
*/
|
||||
export function FilePreviewDialog({
|
||||
fileId,
|
||||
previewUrl,
|
||||
mimeType,
|
||||
fileName,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
fileId: number;
|
||||
previewUrl: string | null;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
@@ -18,19 +112,33 @@ export function FilePreviewDialog({
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const kind = previewKind(mimeType);
|
||||
|
||||
if (previewUrl === null || kind === null) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn('block appearance-none border-0 bg-transparent p-0 text-left', className)}
|
||||
// Named for a screen reader, because what it wraps is a
|
||||
// decorative thumbnail (alt="") or a bare icon — without
|
||||
// this it announces as an unlabelled button.
|
||||
aria-label={t('Preview :name', { name: fileName })}
|
||||
title={t('Preview :name', { name: fileName })}
|
||||
className={cn(
|
||||
'block cursor-pointer appearance-none border-0 bg-transparent p-0 text-left transition-opacity hover:opacity-80',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogContent className={previewDialogWidth(kind)}>
|
||||
<DialogTitle className="sr-only">{t('Preview of :name', { name: fileName })}</DialogTitle>
|
||||
{open && <img src={route('files.preview', fileId)} alt={fileName} className="max-h-[80vh] w-full rounded object-contain" />}
|
||||
{open && <FilePreviewBody previewUrl={previewUrl} mimeType={mimeType} fileName={fileName} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Eye } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { FilePreviewBody, previewDialogWidth } from '@/components/file-preview-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { previewKind } from '@/lib/previews';
|
||||
|
||||
/**
|
||||
* The explicit way to open a file for a look, sitting with a row's other
|
||||
* actions — the twin of <DownloadAction>, and shaped like it on purpose:
|
||||
* a theme renders it, never the rule behind it.
|
||||
*
|
||||
* <FilePreviewDialog> already makes a thumbnail clickable, and that stays
|
||||
* the nicest way in for a photograph. It is not enough on its own: a PDF
|
||||
* or an audio file has no thumbnail, only a generic icon, and nothing
|
||||
* about a generic icon says "click me". This is what makes the affordance
|
||||
* findable rather than discoverable by accident.
|
||||
*
|
||||
* Two shapes, because the surfaces genuinely differ. A roomy list row has
|
||||
* space for a labelled button beside Download and reads better for it; a
|
||||
* dense table row or a grid card's footer does not, and gets the eye icon
|
||||
* instead. That is the one place a theme decides anything here — pass
|
||||
* `iconOnly`.
|
||||
*
|
||||
* Renders nothing when there is no preview to offer (`previewUrl` null,
|
||||
* or a type no browser plays), so a caller never needs its own guard.
|
||||
*/
|
||||
export function PreviewAction({
|
||||
previewUrl,
|
||||
mimeType,
|
||||
fileName,
|
||||
variant = 'outline',
|
||||
size = 'sm',
|
||||
iconOnly = false,
|
||||
className,
|
||||
iconClassName = 'size-4',
|
||||
}: {
|
||||
previewUrl: string | null;
|
||||
mimeType: string;
|
||||
fileName: string;
|
||||
variant?: 'outline' | 'ghost' | 'default' | 'secondary';
|
||||
size?: 'sm' | 'lg' | 'default' | 'icon';
|
||||
/** Render the label for screen readers only, for dense rows and cards. */
|
||||
iconOnly?: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const kind = previewKind(mimeType);
|
||||
|
||||
if (previewUrl === null || kind === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t('Preview');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={variant} size={size} className={className} onClick={() => setOpen(true)} title={label}>
|
||||
<Eye className={iconClassName} />
|
||||
{iconOnly ? <span className="sr-only">{label}</span> : label}
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className={previewDialogWidth(kind)}>
|
||||
<DialogTitle className="sr-only">{t('Preview of :name', { name: fileName })}</DialogTitle>
|
||||
{open && <FilePreviewBody previewUrl={previewUrl} mimeType={mimeType} fileName={fileName} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -2,7 +2,17 @@ import { UpdateOptionsDialog } from '@/components/update-options-dialog';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type InstallKind = 'container' | 'manual';
|
||||
export type InstallKind = 'container' | 'container-source' | 'manual';
|
||||
|
||||
/**
|
||||
* Whether this installation runs in a container, whichever way it got
|
||||
* there. Everything that is about the runtime — restarting it, recreating
|
||||
* it — is the same answer for both container kinds; only the upgrade
|
||||
* itself differs.
|
||||
*/
|
||||
export function isContainerInstall(kind: InstallKind): boolean {
|
||||
return kind === 'container' || kind === 'container-source';
|
||||
}
|
||||
|
||||
/**
|
||||
* How to actually apply an available update, for this server.
|
||||
@@ -15,6 +25,13 @@ export type InstallKind = 'container' | 'manual';
|
||||
* have, for a stack they are not using, at the exact moment they were trying
|
||||
* to do the right thing.
|
||||
*
|
||||
* The same string was wrong a second way, for containers this time: a stack
|
||||
* built from a checkout has no image to pull, so both commands succeed,
|
||||
* report success, and change nothing at all. That install then keeps being
|
||||
* offered the same release for as long as its operator keeps following the
|
||||
* instructions on this screen (#1661) — which is why 'container' and
|
||||
* 'container-source' are told different things.
|
||||
*
|
||||
* `compact` is for the dashboard card, a narrow column beside other widgets.
|
||||
* `codeClassName` exists for the same caller — its code sits inside a warning
|
||||
* alert and has to match it.
|
||||
@@ -44,6 +61,24 @@ export function UpdateInstructions({
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === 'container-source') {
|
||||
return (
|
||||
<>
|
||||
{/* On its own line rather than inline like the image's one-liner:
|
||||
this command is long enough to wrap inside the dashboard's
|
||||
narrow column, and a command split across two lines mid-word
|
||||
is one somebody retypes wrongly. */}
|
||||
<p className="text-muted-foreground mb-1">{t('To update, run this in the directory you cloned:')}</p>
|
||||
<code className={cn('block rounded px-2 py-1.5 break-words', codeClassName ?? 'bg-muted')}>git pull && docker compose up -d --build</code>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t(
|
||||
'This installation builds its own images, so pulling one changes nothing. If the release moved composer.lock or the frontend, run composer install and npm run build as well — UPDATE.md has the full procedure.',
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="text-muted-foreground space-y-1">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Mirrors App\Modules\Files\Preview\PreviewKind on the backend — which
|
||||
// element, if any, shows this file inline. Read that enum's docblock
|
||||
// before adding a type: this is a security allowlist on the server, and
|
||||
// widening it here only produces a player pointed at a URL that 404s.
|
||||
//
|
||||
// Distinct from isThumbnailable() in ./thumbnails, which answers the
|
||||
// narrower question of whether there is a thumbnail image to put in a
|
||||
// listing row. Every thumbnailable file is previewable; a PDF is
|
||||
// previewable with no thumbnail to click.
|
||||
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf';
|
||||
|
||||
const VIDEO_MIME_TYPES = ['video/mp4', 'video/webm', 'video/ogg'];
|
||||
|
||||
const AUDIO_MIME_TYPES = [
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/vnd.wave',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/mp4',
|
||||
'audio/x-m4a',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
];
|
||||
|
||||
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
export function previewKind(mimeType: string): PreviewKind | null {
|
||||
if (IMAGE_MIME_TYPES.includes(mimeType)) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (VIDEO_MIME_TYPES.includes(mimeType)) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (AUDIO_MIME_TYPES.includes(mimeType)) {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
if (mimeType === 'application/pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPreviewable(mimeType: string): boolean {
|
||||
return previewKind(mimeType) !== null;
|
||||
}
|
||||
@@ -3,10 +3,13 @@ import { Head, Link } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import Heading from '@/components/heading';
|
||||
import { FilterField, ListToolbar } from '@/components/list-toolbar';
|
||||
import { Pagination, PaginationMeta } from '@/components/pagination';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useFormatDate } from '@/hooks/use-format-date';
|
||||
import { useListQuery } from '@/hooks/use-list-query';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
|
||||
@@ -20,9 +23,19 @@ interface DownloadEntry {
|
||||
file_url?: string | null;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
file: string | null;
|
||||
user: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
interface ActivityDownloadsProps {
|
||||
entries: DownloadEntry[];
|
||||
pagination: PaginationMeta;
|
||||
// Only the installation-wide page filters: a single file's history is
|
||||
// already narrowed to the one thing its filters would ask about.
|
||||
filters?: Filters;
|
||||
// Present only when scoped to a single file/folder (the details
|
||||
// panel's "View all downloads" destination). Absent for the
|
||||
// installation-wide /downloads page, which shows a File column
|
||||
@@ -31,12 +44,23 @@ interface ActivityDownloadsProps {
|
||||
back_url?: string;
|
||||
}
|
||||
|
||||
export default function ActivityDownloads({ entries, pagination, subject_name, back_url }: ActivityDownloadsProps) {
|
||||
export default function ActivityDownloads({ entries, pagination, filters, subject_name, back_url }: ActivityDownloadsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dateTime } = useFormatDate();
|
||||
|
||||
const scoped = subject_name !== undefined && back_url !== undefined;
|
||||
|
||||
const { values, set, reset, hasFilters } = useListQuery(
|
||||
'downloads.index',
|
||||
{
|
||||
file: filters?.file ?? '',
|
||||
user: filters?.user ?? '',
|
||||
from: filters?.from ?? '',
|
||||
to: filters?.to ?? '',
|
||||
},
|
||||
{ file: '', user: '', from: '', to: '' },
|
||||
);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = scoped
|
||||
? [
|
||||
{ title: t('Activity log'), href: '/activity' },
|
||||
@@ -45,7 +69,11 @@ export default function ActivityDownloads({ entries, pagination, subject_name, b
|
||||
: [{ title: t('Download history'), href: '/downloads' }];
|
||||
|
||||
const title = scoped ? t('Download history for :name', { name: subject_name }) : t('Download history');
|
||||
const description = scoped ? t('Every download, newest first') : t('Every download across the installation, newest first');
|
||||
const description = scoped
|
||||
? t('Every download, newest first')
|
||||
: hasFilters
|
||||
? t(':count matching this filter, newest first', { count: pagination.total })
|
||||
: t('Every download across the installation, newest first');
|
||||
|
||||
return (
|
||||
<AppLayout breadcrumbs={breadcrumbs}>
|
||||
@@ -64,6 +92,40 @@ export default function ActivityDownloads({ entries, pagination, subject_name, b
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filters !== undefined && (
|
||||
<ListToolbar showClear={hasFilters} onClear={reset}>
|
||||
<FilterField label={t('File')} htmlFor="filter-file">
|
||||
<Input
|
||||
id="filter-file"
|
||||
type="search"
|
||||
placeholder={t('Search by name')}
|
||||
className="w-56"
|
||||
value={values.file}
|
||||
onChange={(e) => set('file', e.target.value, true)}
|
||||
/>
|
||||
</FilterField>
|
||||
|
||||
<FilterField label={t('Downloaded by')} htmlFor="filter-user">
|
||||
<Input
|
||||
id="filter-user"
|
||||
type="search"
|
||||
placeholder={t('Search by name')}
|
||||
className="w-48"
|
||||
value={values.user}
|
||||
onChange={(e) => set('user', e.target.value, true)}
|
||||
/>
|
||||
</FilterField>
|
||||
|
||||
<FilterField label={t('From')} htmlFor="filter-from">
|
||||
<Input id="filter-from" type="date" className="w-40" value={values.from} onChange={(e) => set('from', e.target.value)} />
|
||||
</FilterField>
|
||||
|
||||
<FilterField label={t('To')} htmlFor="filter-to">
|
||||
<Input id="filter-to" type="date" className="w-40" value={values.to} onChange={(e) => set('to', e.target.value)} />
|
||||
</FilterField>
|
||||
</ListToolbar>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -78,7 +140,7 @@ export default function ActivityDownloads({ entries, pagination, subject_name, b
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={scoped ? 3 : 4} className="text-muted-foreground px-4 py-8 text-center">
|
||||
{t('No downloads recorded yet.')}
|
||||
{hasFilters ? t('No downloads match these filters.') : t('No downloads recorded yet.')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -3,11 +3,15 @@ import { Head, Link } from '@inertiajs/react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
import Heading from '@/components/heading';
|
||||
import { FilterField, ListToolbar } from '@/components/list-toolbar';
|
||||
import { Pagination, PaginationMeta } from '@/components/pagination';
|
||||
import { TableShell } from '@/components/table-shell';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useFormatDate } from '@/hooks/use-format-date';
|
||||
import { ALL, useListQuery } from '@/hooks/use-list-query';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { activityActorLabel as actorLabel } from '@/lib/activity-actor';
|
||||
@@ -23,17 +27,51 @@ interface ActivityEntry {
|
||||
replacements: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
action: string | null;
|
||||
actor: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
interface ActivitySubjectProps {
|
||||
entries: ActivityEntry[];
|
||||
pagination: PaginationMeta;
|
||||
filters: Filters;
|
||||
/** Only the actions this subject's own history contains, with their counts. */
|
||||
action_options: { key: string; label: string; count: number }[];
|
||||
subject_name: string;
|
||||
back_url: string;
|
||||
/** This same page, for the filter links — a file's history and a folder's are different routes. */
|
||||
route_name: string;
|
||||
route_params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function ActivitySubject({ entries, pagination, subject_name, back_url }: ActivitySubjectProps) {
|
||||
export default function ActivitySubject({
|
||||
entries,
|
||||
pagination,
|
||||
filters,
|
||||
action_options,
|
||||
subject_name,
|
||||
back_url,
|
||||
route_name,
|
||||
route_params,
|
||||
}: ActivitySubjectProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dateTime } = useFormatDate();
|
||||
|
||||
const { values, set, reset, hasFilters } = useListQuery(
|
||||
route_name,
|
||||
{
|
||||
action: filters.action ?? ALL,
|
||||
actor: filters.actor ?? '',
|
||||
from: filters.from ?? '',
|
||||
to: filters.to ?? '',
|
||||
},
|
||||
{ action: ALL, actor: '', from: '', to: '' },
|
||||
route_params,
|
||||
);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: t('Activity log'), href: '/activity' },
|
||||
{ title: subject_name, href: back_url },
|
||||
@@ -45,7 +83,16 @@ export default function ActivitySubject({ entries, pagination, subject_name, bac
|
||||
|
||||
<div className="px-4 py-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Heading title={t('Activity history for :name', { name: subject_name })} description={t('Every recorded action, newest first')} />
|
||||
{/* Once a filter is on, "every recorded action" is no
|
||||
longer what the table shows — say how many it does. */}
|
||||
<Heading
|
||||
title={t('Activity history for :name', { name: subject_name })}
|
||||
description={
|
||||
hasFilters
|
||||
? t(':count matching this filter, newest first', { count: pagination.total })
|
||||
: t('Every recorded action, newest first')
|
||||
}
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={back_url}>
|
||||
<ArrowLeft className="size-4" />
|
||||
@@ -54,10 +101,47 @@ export default function ActivitySubject({ entries, pagination, subject_name, bac
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListToolbar showClear={hasFilters} onClear={reset}>
|
||||
<FilterField label={t('Action')} htmlFor="filter-action">
|
||||
<Select value={values.action} onValueChange={(v) => set('action', v)}>
|
||||
<SelectTrigger id="filter-action" className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t('All actions')}</SelectItem>
|
||||
{action_options.map((option) => (
|
||||
<SelectItem key={option.key} value={option.key}>
|
||||
{t(':action (:count)', { action: t(option.label), count: option.count })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FilterField>
|
||||
|
||||
<FilterField label={t('Account')} htmlFor="filter-actor">
|
||||
<Input
|
||||
id="filter-actor"
|
||||
type="search"
|
||||
placeholder={t('Search by name')}
|
||||
className="w-48"
|
||||
value={values.actor}
|
||||
onChange={(e) => set('actor', e.target.value, true)}
|
||||
/>
|
||||
</FilterField>
|
||||
|
||||
<FilterField label={t('From')} htmlFor="filter-from">
|
||||
<Input id="filter-from" type="date" className="w-40" value={values.from} onChange={(e) => set('from', e.target.value)} />
|
||||
</FilterField>
|
||||
|
||||
<FilterField label={t('To')} htmlFor="filter-to">
|
||||
<Input id="filter-to" type="date" className="w-40" value={values.to} onChange={(e) => set('to', e.target.value)} />
|
||||
</FilterField>
|
||||
</ListToolbar>
|
||||
|
||||
<TableShell
|
||||
columns={[t('Date'), t('Account'), t('Action')]}
|
||||
isEmpty={entries.length === 0}
|
||||
emptyMessage={<>{t('No activity recorded yet.')}</>}
|
||||
emptyMessage={<>{hasFilters ? t('No activity matches these filters.') : t('No activity recorded yet.')}</>}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-b last:border-0">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { Head, router, useForm, usePage } from '@inertiajs/react';
|
||||
import { Check, Copy, Download, X } from 'lucide-react';
|
||||
import { FormEventHandler, useState } from 'react';
|
||||
import { Check, Copy, Download, Eye, File as FileIcon, Loader2, X } from 'lucide-react';
|
||||
import { FormEventHandler, useEffect, useState } from 'react';
|
||||
|
||||
import { CommentThread } from '@/components/comments/comment-thread';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
@@ -22,8 +22,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useFormatDate } from '@/hooks/use-format-date';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { activityActorLabel } from '@/lib/activity-actor';
|
||||
import { categoryColor } from '@/lib/category-colors';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { isPreviewable } from '@/lib/previews';
|
||||
import { isThumbnailable } from '@/lib/thumbnails';
|
||||
import { slugify } from '@/lib/utils';
|
||||
|
||||
@@ -47,7 +49,34 @@ interface CategoryTag {
|
||||
color: string;
|
||||
}
|
||||
|
||||
type Tab = 'general' | 'sharing' | 'links' | 'versions' | 'comments';
|
||||
type Tab = 'general' | 'sharing' | 'links' | 'versions' | 'comments' | 'access' | 'activity';
|
||||
|
||||
/** One line of this file's history, as /files/{id}/activity presents it. */
|
||||
interface ActivityEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
actor_name: string | null;
|
||||
actor_type: string | null;
|
||||
/** Separates an unauthenticated visitor from the scheduler; both have no actor. */
|
||||
origin: string;
|
||||
template: string;
|
||||
replacements: Record<string, string>;
|
||||
}
|
||||
|
||||
/** One download or preview of this file, as /files/{id}/access presents it. */
|
||||
interface AccessEntry extends ActivityEntry {
|
||||
/** Null wherever the privacy settings say not to record an address. */
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
interface Access {
|
||||
entries: AccessEntry[];
|
||||
downloads_total: number;
|
||||
previews_total: number;
|
||||
/** The file's own history, pre-filtered — the server owns which filter value that is. */
|
||||
downloads_url: string;
|
||||
previews_url: string;
|
||||
}
|
||||
|
||||
interface FilesEditProps {
|
||||
file: {
|
||||
@@ -84,6 +113,7 @@ interface FilesEditProps {
|
||||
can_update: boolean;
|
||||
can_delete: boolean;
|
||||
can_manage_public: boolean;
|
||||
can_view_activity: boolean;
|
||||
can_set_commentable: boolean;
|
||||
comments_enabled: boolean;
|
||||
assigned_clients: Named[];
|
||||
@@ -109,6 +139,7 @@ export default function FilesEdit({
|
||||
can_update,
|
||||
can_delete,
|
||||
can_manage_public,
|
||||
can_view_activity,
|
||||
can_set_commentable,
|
||||
comments_enabled,
|
||||
assigned_clients,
|
||||
@@ -121,7 +152,7 @@ export default function FilesEdit({
|
||||
can_limit_downloads,
|
||||
}: FilesEditProps) {
|
||||
const { t } = useTranslation();
|
||||
const { date } = useFormatDate();
|
||||
const { date, dateTime } = useFormatDate();
|
||||
const pageErrors = usePage().props.errors as Record<string, string>;
|
||||
// A comment notification and the activity log both land here, so honour
|
||||
// ?tab=comments. Somebody who may read the file but not edit it gets the
|
||||
@@ -130,8 +161,13 @@ export default function FilesEdit({
|
||||
const requested = new URLSearchParams(window.location.search).get('tab');
|
||||
|
||||
if (requested === 'comments' && comments_enabled) return 'comments';
|
||||
if (requested === 'activity' && can_view_activity) return 'activity';
|
||||
|
||||
return can_update ? 'general' : 'comments';
|
||||
if (can_update) return 'general';
|
||||
|
||||
// Whatever is left: somebody who may read the file but not edit it
|
||||
// has at most these two, and Comments is the one they came for.
|
||||
return comments_enabled ? 'comments' : 'activity';
|
||||
});
|
||||
const [target, setTarget] = useState('');
|
||||
const [shareExpiresAt, setShareExpiresAt] = useState('');
|
||||
@@ -145,6 +181,38 @@ export default function FilesEdit({
|
||||
// (an already-populated slug counts as touched so we never clobber a
|
||||
// deliberate value).
|
||||
const [slugTouched, setSlugTouched] = useState(file.slug !== '');
|
||||
// The file's own history, fetched from the same endpoint the library's
|
||||
// details panel reads — the most recent 20 entries plus how many there
|
||||
// are in total, so the link below can say what "all" amounts to. Null
|
||||
// until the tab has been opened: a page that nobody opens the tab on
|
||||
// should not pay for the query.
|
||||
const [activity, setActivity] = useState<ActivityEntry[] | null>(null);
|
||||
const [activityTotal, setActivityTotal] = useState(0);
|
||||
|
||||
// "Did they ever actually get it?" — the same twenty-entry shape as
|
||||
// the Activity tab, narrowed to the two actions that answer it.
|
||||
const [access, setAccess] = useState<Access | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'access' || access !== null || !can_view_activity) return;
|
||||
|
||||
fetch(route('files.access', file.id), { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
|
||||
.then((r) => r.json())
|
||||
.then(setAccess);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'activity' || activity !== null || !can_view_activity) return;
|
||||
|
||||
fetch(route('files.activity', file.id), { headers: { Accept: 'application/json' }, credentials: 'same-origin' })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setActivity(d.entries);
|
||||
setActivityTotal(d.total);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab]);
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{ title: t('All files'), href: '/files' },
|
||||
@@ -238,9 +306,20 @@ export default function FilesEdit({
|
||||
<div className="px-4 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
{isThumbnailable(file.mime_type) && (
|
||||
<FilePreviewDialog fileId={file.id} fileName={file.original_name} className="shrink-0">
|
||||
<img src={route('files.thumbnail', file.id)} alt="" className="size-16 rounded border object-cover" />
|
||||
{isPreviewable(file.mime_type) && (
|
||||
<FilePreviewDialog
|
||||
previewUrl={route('files.preview', file.id)}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<img src={route('files.thumbnail', file.id)} alt="" className="size-16 rounded border object-cover" />
|
||||
) : (
|
||||
<span className="bg-muted flex size-16 items-center justify-center rounded border">
|
||||
<FileIcon className="text-muted-foreground size-8" strokeWidth={1.25} />
|
||||
</span>
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
)}
|
||||
<Heading
|
||||
@@ -278,19 +357,20 @@ export default function FilesEdit({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(can_update || comments_enabled) && (
|
||||
<nav className="mt-6 flex gap-1 border-b">
|
||||
{(can_update || comments_enabled || can_view_activity) && (
|
||||
<nav className="mt-6 flex gap-1 overflow-x-auto border-b">
|
||||
{[
|
||||
...(can_update ? (['general', 'sharing', 'links'] as Tab[]) : []),
|
||||
...(can_set_version ? (['versions'] as Tab[]) : []),
|
||||
...(comments_enabled ? (['comments'] as Tab[]) : []),
|
||||
...(can_view_activity ? (['access', 'activity'] as Tab[]) : []),
|
||||
]
|
||||
.filter((tabKey) => tabKey !== 'links' || can_manage_public)
|
||||
.map((tabKey) => (
|
||||
<button
|
||||
key={tabKey}
|
||||
onClick={() => setTab(tabKey)}
|
||||
className={`border-b-2 px-3 py-2 text-sm ${tab === tabKey ? 'border-primary text-foreground font-medium' : 'text-muted-foreground border-transparent'}`}
|
||||
className={`border-b-2 px-3 py-2 text-sm whitespace-nowrap ${tab === tabKey ? 'border-primary text-foreground font-medium' : 'text-muted-foreground border-transparent'}`}
|
||||
>
|
||||
{tabKey === 'general'
|
||||
? t('General')
|
||||
@@ -300,14 +380,110 @@ export default function FilesEdit({
|
||||
? t('Public')
|
||||
: tabKey === 'versions'
|
||||
? t('Versions')
|
||||
: t('Comments')}
|
||||
: tabKey === 'comments'
|
||||
? t('Comments')
|
||||
: tabKey === 'access'
|
||||
? t('Downloads & previews')
|
||||
: t('Activity')}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
{tab === 'comments' ? (
|
||||
{tab === 'access' ? (
|
||||
<div className="max-w-2xl">
|
||||
{access === null ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> {t('Loading…')}
|
||||
</div>
|
||||
) : access.entries.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t('Nobody has downloaded or previewed this file yet.')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mb-3 text-sm">
|
||||
{t('Downloads: :count', { count: access.downloads_total })}
|
||||
{' · '}
|
||||
{t('Previews: :count', { count: access.previews_total })}
|
||||
</p>
|
||||
|
||||
<div className="divide-y rounded-md border">
|
||||
{access.entries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-baseline justify-between gap-4 px-4 py-3 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">{t(activityActorLabel(entry).key)}</span>{' '}
|
||||
<span className="text-muted-foreground">{t(entry.template, entry.replacements)}</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground shrink-0 text-right text-xs">
|
||||
{dateTime(entry.created_at)}
|
||||
{entry.ip_address !== null && <span className="block font-mono">{entry.ip_address}</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Each button is the same history page the Activity
|
||||
tab links to, arriving with one question already
|
||||
asked. Offered only where there is something to
|
||||
read: a filter over nothing is a dead end. */}
|
||||
{access !== null && (access.downloads_total > 0 || access.previews_total > 0) && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{access.downloads_total > 0 && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={access.downloads_url}>
|
||||
<Download className="size-4" />
|
||||
{t('View all downloads (:count)', { count: access.downloads_total })}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{access.previews_total > 0 && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={access.previews_url}>
|
||||
<Eye className="size-4" />
|
||||
{t('View all previews (:count)', { count: access.previews_total })}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : tab === 'activity' ? (
|
||||
<div className="max-w-2xl">
|
||||
{activity === null ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" /> {t('Loading…')}
|
||||
</div>
|
||||
) : activity.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t('No activity recorded yet.')}</p>
|
||||
) : (
|
||||
<div className="divide-y rounded-md border">
|
||||
{activity.map((entry) => (
|
||||
<div key={entry.id} className="flex items-baseline justify-between gap-4 px-4 py-3 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">{t(activityActorLabel(entry).key)}</span>{' '}
|
||||
<span className="text-muted-foreground">{t(entry.template, entry.replacements)}</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground shrink-0 text-xs">{dateTime(entry.created_at)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Offered whenever there is any history at all, the
|
||||
same as the library's details panel: the full
|
||||
page is filterable and paged, not merely longer. */}
|
||||
{activityTotal > 0 && (
|
||||
<div className="mt-3">
|
||||
<Button variant="link" size="sm" className="px-0" asChild>
|
||||
<a href={route('files.activity.history', file.id)}>
|
||||
{t('View full history (:count)', { count: activityTotal })}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : tab === 'comments' ? (
|
||||
// The same thread the library's slide-over renders —
|
||||
// one component, so the two can never disagree about
|
||||
// what a comment looks like or who may write one.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { DetailsPanel, DetailsTarget } from '@/components/details-panel';
|
||||
import { DragChip, DragData, DropZone, useFolderDrop, useRowDrag } from '@/components/file-dnd';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { VersionBadge, type VersionLinks } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
import { FilterField, ListToolbar } from '@/components/list-toolbar';
|
||||
@@ -33,6 +34,7 @@ import { useZipDownload } from '@/hooks/use-zip-download';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { categoryColor } from '@/lib/category-colors';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { isPreviewable } from '@/lib/previews';
|
||||
import { isThumbnailable } from '@/lib/thumbnails';
|
||||
import { slugify } from '@/lib/utils';
|
||||
|
||||
@@ -689,13 +691,18 @@ function FileRow({
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-start gap-2">
|
||||
{isThumbnailable(row.mime_type) ? (
|
||||
<FilePreviewDialog fileId={row.id} fileName={row.original_name} className="shrink-0">
|
||||
<FilePreviewDialog
|
||||
previewUrl={route('files.preview', row.id)}
|
||||
mimeType={row.mime_type}
|
||||
fileName={row.original_name}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isThumbnailable(row.mime_type) ? (
|
||||
<img src={route('files.thumbnail', row.id)} alt="" className="size-10 rounded border object-cover" draggable={false} />
|
||||
</FilePreviewDialog>
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground mt-0.5 size-10 shrink-0" strokeWidth={1.25} />
|
||||
)}
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground mt-0.5 size-10 shrink-0" strokeWidth={1.25} />
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link href={route('files.edit', row.id)} draggable={false} className="font-medium">
|
||||
@@ -765,6 +772,14 @@ function FileRow({
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<PreviewAction
|
||||
previewUrl={route('files.preview', row.id)}
|
||||
mimeType={row.mime_type}
|
||||
fileName={row.original_name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
<DownloadAction href={route('files.download', row.id)} limit={row.download_limit} variant="ghost" size="sm" iconOnly />
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={route('files.edit', row.id)}>{row.can_update ? t('Edit') : t('View')}</Link>
|
||||
@@ -904,14 +919,25 @@ function FileCard({
|
||||
className="bg-background/80 absolute top-3 left-3 z-10"
|
||||
/>
|
||||
|
||||
{isThumbnailable(row.mime_type) ? (
|
||||
<FilePreviewDialog fileId={row.id} fileName={row.original_name} className="bg-muted block aspect-square w-full overflow-hidden">
|
||||
<img
|
||||
src={route('files.thumbnail', row.id)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
{isPreviewable(row.mime_type) ? (
|
||||
<FilePreviewDialog
|
||||
previewUrl={route('files.preview', row.id)}
|
||||
mimeType={row.mime_type}
|
||||
fileName={row.original_name}
|
||||
className="bg-muted block aspect-square w-full overflow-hidden"
|
||||
>
|
||||
{isThumbnailable(row.mime_type) ? (
|
||||
<img
|
||||
src={route('files.thumbnail', row.id)}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FileIcon className="text-muted-foreground size-10" strokeWidth={1.25} />
|
||||
</div>
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
) : (
|
||||
<div className="bg-muted flex aspect-square items-center justify-center">
|
||||
@@ -969,6 +995,14 @@ function FileCard({
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<PreviewAction
|
||||
previewUrl={route('files.preview', row.id)}
|
||||
mimeType={row.mime_type}
|
||||
fileName={row.original_name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
<DownloadAction href={route('files.download', row.id)} limit={row.download_limit} variant="ghost" size="sm" iconOnly />
|
||||
{row.can_delete && (
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Archive, File as FileIcon, Folder as FolderIcon, Globe, Upload } from '
|
||||
import { CommentsShellCompact } from '@/components/comments/shells/comments-shell-compact';
|
||||
import { DownloadAction } from '@/components/download-action';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
@@ -46,6 +47,7 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
can_upload_here,
|
||||
can_create_folders,
|
||||
comments_enabled,
|
||||
preview_enabled,
|
||||
} = props;
|
||||
const {
|
||||
zip,
|
||||
@@ -191,17 +193,22 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<div className="flex items-start gap-1.5">
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<FilePreviewDialog fileId={file.id} fileName={file.original_name} className="shrink-0">
|
||||
<FilePreviewDialog
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<img
|
||||
src={route('files.thumbnail', file.id)}
|
||||
alt=""
|
||||
className="size-5 border border-neutral-300 object-cover dark:border-neutral-700"
|
||||
/>
|
||||
</FilePreviewDialog>
|
||||
) : (
|
||||
<FileIcon className="mt-0.5 size-3.5 shrink-0 text-neutral-400" strokeWidth={1.5} />
|
||||
)}
|
||||
) : (
|
||||
<FileIcon className="mt-0.5 size-3.5 shrink-0 text-neutral-400" strokeWidth={1.5} />
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate font-medium">{file.name}</p>
|
||||
@@ -231,6 +238,16 @@ export default function MyFilesCompact(props: MyFilesFolderManagementProps) {
|
||||
unread={file.unread_comments_count}
|
||||
/>
|
||||
)}
|
||||
<PreviewAction
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0"
|
||||
iconClassName="size-3.5"
|
||||
iconOnly
|
||||
/>
|
||||
<DownloadAction
|
||||
href={route('files.download', file.id)}
|
||||
limit={file.download_limit}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Archive, File as FileIcon, Folder as FolderIcon, Globe, Upload } from '
|
||||
import { CommentsShellDefault } from '@/components/comments/shells/comments-shell-default';
|
||||
import { DownloadAction } from '@/components/download-action';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
@@ -26,6 +27,7 @@ import { useTranslation } from '@/hooks/use-translation';
|
||||
import { useViewMode } from '@/hooks/use-view-mode';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { isPreviewable } from '@/lib/previews';
|
||||
import { isThumbnailable } from '@/lib/thumbnails';
|
||||
import { type MyFilesFolderManagementProps } from '@/types/portal';
|
||||
|
||||
@@ -42,6 +44,7 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
can_upload_here,
|
||||
can_create_folders,
|
||||
comments_enabled,
|
||||
preview_enabled,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -164,20 +167,31 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
))}
|
||||
|
||||
{files.map((file) => (
|
||||
<div key={`file-${file.id}`} className="bg-card flex items-center justify-between gap-4 rounded-lg border px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div key={`file-${file.id}`} className="bg-card flex items-center gap-4 rounded-lg border px-4 py-3">
|
||||
{/* flex-1, not justify-between: with three
|
||||
children the middle one lands wherever
|
||||
the name block happens to end, so the
|
||||
comment icon sat at a different place on
|
||||
every row. The name takes the slack and
|
||||
the actions are one group at the end. */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Checkbox
|
||||
checked={selectedFileIds.has(file.id)}
|
||||
onCheckedChange={() => toggleFile(file.id)}
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
/>
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<FilePreviewDialog fileId={file.id} fileName={file.original_name} className="shrink-0">
|
||||
<FilePreviewDialog
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<img src={route('files.thumbnail', file.id)} alt="" className="size-10 rounded border object-cover" />
|
||||
</FilePreviewDialog>
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground size-10 shrink-0" strokeWidth={1.25} />
|
||||
)}
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground size-10 shrink-0" strokeWidth={1.25} />
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
@@ -195,16 +209,30 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
<CategoryBadges categories={file.categories} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
{comments_enabled && (
|
||||
<CommentsShellDefault
|
||||
fileId={file.id}
|
||||
defaultOpen={deepLinkedComments === file.id}
|
||||
fileName={file.name}
|
||||
count={file.comments_count}
|
||||
unread={file.unread_comments_count}
|
||||
{/* One actions group, right-aligned: icons in
|
||||
fixed-width slots so they form a column
|
||||
down the list, then the buttons. */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{comments_enabled && (
|
||||
<div className="flex w-10 shrink-0 justify-center">
|
||||
<CommentsShellDefault
|
||||
fileId={file.id}
|
||||
defaultOpen={deepLinkedComments === file.id}
|
||||
fileName={file.name}
|
||||
count={file.comments_count}
|
||||
unread={file.unread_comments_count}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PreviewAction
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<DownloadAction href={route('files.download', file.id)} limit={file.download_limit} variant="outline" size="sm" />
|
||||
<DownloadAction href={route('files.download', file.id)} limit={file.download_limit} variant="outline" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -253,18 +281,33 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
className="bg-background/80 absolute top-3 left-3 z-10"
|
||||
/>
|
||||
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
{preview_enabled && isPreviewable(file.mime_type) ? (
|
||||
<FilePreviewDialog
|
||||
fileId={file.id}
|
||||
previewUrl={route('files.preview', file.id)}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="bg-muted block aspect-square w-full overflow-hidden"
|
||||
>
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<img
|
||||
src={route('files.thumbnail', file.id)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FileIcon className="text-muted-foreground size-10" strokeWidth={1.25} />
|
||||
</div>
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
) : isThumbnailable(file.mime_type) ? (
|
||||
<div className="bg-muted aspect-square w-full overflow-hidden">
|
||||
<img
|
||||
src={route('files.thumbnail', file.id)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</FilePreviewDialog>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted flex aspect-square items-center justify-center">
|
||||
<FileIcon className="text-muted-foreground size-10" strokeWidth={1.25} />
|
||||
@@ -288,22 +331,32 @@ export default function MyFiles(props: MyFilesFolderManagementProps) {
|
||||
</p>
|
||||
<CategoryBadges categories={file.categories} className="mt-1" />
|
||||
</div>
|
||||
{comments_enabled && (
|
||||
<CommentsShellDefault
|
||||
fileId={file.id}
|
||||
defaultOpen={deepLinkedComments === file.id}
|
||||
fileName={file.name}
|
||||
count={file.comments_count}
|
||||
unread={file.unread_comments_count}
|
||||
<div className="flex shrink-0 items-center">
|
||||
{comments_enabled && (
|
||||
<CommentsShellDefault
|
||||
fileId={file.id}
|
||||
defaultOpen={deepLinkedComments === file.id}
|
||||
fileName={file.name}
|
||||
count={file.comments_count}
|
||||
unread={file.unread_comments_count}
|
||||
/>
|
||||
)}
|
||||
<PreviewAction
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
<DownloadAction
|
||||
href={route('files.download', file.id)}
|
||||
limit={file.download_limit}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
<DownloadAction
|
||||
href={route('files.download', file.id)}
|
||||
limit={file.download_limit}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Archive, Folder as FolderIcon, Globe, Upload } from 'lucide-react';
|
||||
import { CommentsShellDrive } from '@/components/comments/shells/comments-shell-drive';
|
||||
import { DownloadAction } from '@/components/download-action';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
@@ -47,6 +48,7 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
can_upload_here,
|
||||
can_create_folders,
|
||||
comments_enabled,
|
||||
preview_enabled,
|
||||
} = props;
|
||||
const {
|
||||
zip,
|
||||
@@ -197,13 +199,18 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
aria-label={t('Select :name', { name: file.name })}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<FilePreviewDialog fileId={file.id} fileName={file.original_name} className="shrink-0">
|
||||
<FilePreviewDialog
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="shrink-0"
|
||||
>
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<img src={route('files.thumbnail', file.id)} alt="" className="size-9 rounded object-cover" />
|
||||
</FilePreviewDialog>
|
||||
) : (
|
||||
<Icon className={`size-6 shrink-0 ${color}`} strokeWidth={1.5} />
|
||||
)}
|
||||
) : (
|
||||
<Icon className={`size-6 shrink-0 ${color}`} strokeWidth={1.5} />
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-sm font-medium text-neutral-800 dark:text-neutral-200">{file.name}</p>
|
||||
@@ -222,15 +229,30 @@ export default function MyFilesDrive(props: MyFilesFolderManagementProps) {
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-20 text-right text-sm text-neutral-500">{formatBytes(file.size)}</span>
|
||||
{/* Fixed-width slots, so the icons form a
|
||||
column down the list instead of sliding
|
||||
about with each row's comment count. */}
|
||||
{comments_enabled && (
|
||||
<CommentsShellDrive
|
||||
fileId={file.id}
|
||||
defaultOpen={deepLinkedComments === file.id}
|
||||
fileName={file.name}
|
||||
count={file.comments_count}
|
||||
unread={file.unread_comments_count}
|
||||
/>
|
||||
<div className="flex w-9 shrink-0 justify-center">
|
||||
<CommentsShellDrive
|
||||
fileId={file.id}
|
||||
defaultOpen={deepLinkedComments === file.id}
|
||||
fileName={file.name}
|
||||
count={file.comments_count}
|
||||
unread={file.unread_comments_count}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<PreviewAction
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-9"
|
||||
iconClassName="size-4 text-blue-600"
|
||||
iconOnly
|
||||
/>
|
||||
<DownloadAction
|
||||
href={route('files.download', file.id)}
|
||||
limit={file.download_limit}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Archive, File as FileIcon, Folder as FolderIcon, Globe, Upload } from '
|
||||
import { CommentsShellGallery } from '@/components/comments/shells/comments-shell-gallery';
|
||||
import { DownloadAction } from '@/components/download-action';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { CategoryBadges } from '@/components/files/category-badges';
|
||||
import { VersionBadge } from '@/components/files/version-badge';
|
||||
import Heading from '@/components/heading';
|
||||
@@ -22,6 +23,7 @@ import { useFormatDate } from '@/hooks/use-format-date';
|
||||
import { usePortalFiles } from '@/hooks/use-portal-files';
|
||||
import { useTranslation } from '@/hooks/use-translation';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { isPreviewable } from '@/lib/previews';
|
||||
import { isThumbnailable } from '@/lib/thumbnails';
|
||||
import { type MyFilesFolderManagementProps } from '@/types/portal';
|
||||
|
||||
@@ -47,6 +49,7 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
can_upload_here,
|
||||
can_create_folders,
|
||||
comments_enabled,
|
||||
preview_enabled,
|
||||
} = props;
|
||||
const {
|
||||
zip,
|
||||
@@ -180,26 +183,41 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
badge truncates it to nothing. */}
|
||||
<VersionBadge version={file.version} variant="gallery" className="absolute top-3 right-3 z-10" />
|
||||
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
{preview_enabled && isPreviewable(file.mime_type) ? (
|
||||
<FilePreviewDialog
|
||||
fileId={file.id}
|
||||
previewUrl={route('files.preview', file.id)}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="bg-muted block aspect-square overflow-hidden"
|
||||
className="bg-muted block aspect-square w-full overflow-hidden"
|
||||
>
|
||||
{isThumbnailable(file.mime_type) ? (
|
||||
<img
|
||||
src={route('files.thumbnail', file.id)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<FileIcon className="text-muted-foreground size-10" strokeWidth={1.25} />
|
||||
</div>
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
) : isThumbnailable(file.mime_type) ? (
|
||||
<div className="bg-muted aspect-square overflow-hidden">
|
||||
<img
|
||||
src={route('files.thumbnail', file.id)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</FilePreviewDialog>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted flex aspect-square items-center justify-center">
|
||||
<FileIcon className="text-muted-foreground size-10" strokeWidth={1.25} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 p-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="truncate text-sm font-medium">{file.name}</p>
|
||||
{file.public && (
|
||||
@@ -214,22 +232,32 @@ export default function MyFilesGallery(props: MyFilesFolderManagementProps) {
|
||||
</p>
|
||||
<CategoryBadges categories={file.categories} className="mt-1" />
|
||||
</div>
|
||||
{comments_enabled && (
|
||||
<CommentsShellGallery
|
||||
fileId={file.id}
|
||||
defaultOpen={deepLinkedComments === file.id}
|
||||
fileName={file.name}
|
||||
count={file.comments_count}
|
||||
unread={file.unread_comments_count}
|
||||
<div className="flex shrink-0 items-center">
|
||||
{comments_enabled && (
|
||||
<CommentsShellGallery
|
||||
fileId={file.id}
|
||||
defaultOpen={deepLinkedComments === file.id}
|
||||
fileName={file.name}
|
||||
count={file.comments_count}
|
||||
unread={file.unread_comments_count}
|
||||
/>
|
||||
)}
|
||||
<PreviewAction
|
||||
previewUrl={preview_enabled ? route('files.preview', file.id) : null}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
<DownloadAction
|
||||
href={route('files.download', file.id)}
|
||||
limit={file.download_limit}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
<DownloadAction
|
||||
href={route('files.download', file.id)}
|
||||
limit={file.download_limit}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { File as FileIcon } from 'lucide-react';
|
||||
|
||||
import { CommentsShellCompact } from '@/components/comments/shells/comments-shell-compact';
|
||||
import { DownloadAction } from '@/components/download-action';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
|
||||
import { type VersionLinks } from '@/components/files/version-badge';
|
||||
import { VersionNotice } from '@/components/files/version-notice';
|
||||
import PublicLayoutCompact from '@/layouts/public-layout-compact';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { isThumbnailable } from '@/lib/thumbnails';
|
||||
import { type DownloadLimit } from '@/types/portal';
|
||||
|
||||
@@ -22,6 +25,12 @@ interface PublicFileShowProps {
|
||||
categories: CategoryTag[];
|
||||
};
|
||||
thumbnail_url: string | null;
|
||||
/**
|
||||
* Null unless this visitor is actually offered a preview — the
|
||||
* server has already weighed the setting, the file's type and its
|
||||
* download limit, so a theme never re-derives any of them.
|
||||
*/
|
||||
preview_url: string | null;
|
||||
download_url: string;
|
||||
download_limit: DownloadLimit;
|
||||
comments_enabled: boolean;
|
||||
@@ -31,6 +40,7 @@ interface PublicFileShowProps {
|
||||
export default function PublicFileShowCompact({
|
||||
file,
|
||||
thumbnail_url,
|
||||
preview_url,
|
||||
download_url,
|
||||
download_limit,
|
||||
comments_enabled,
|
||||
@@ -42,8 +52,21 @@ export default function PublicFileShowCompact({
|
||||
<Head title={file.name} />
|
||||
|
||||
<div className="flex items-start gap-4 rounded-md border p-4">
|
||||
{thumbnail_url && isThumbnailable(file.mime_type) && (
|
||||
<img src={thumbnail_url} alt="" className="size-24 shrink-0 rounded border object-cover" />
|
||||
{((thumbnail_url && isThumbnailable(file.mime_type)) || preview_url) && (
|
||||
<FilePreviewDialog
|
||||
previewUrl={preview_url}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="shrink-0"
|
||||
>
|
||||
{thumbnail_url && isThumbnailable(file.mime_type) ? (
|
||||
<img src={thumbnail_url} alt="" className="size-24 rounded border object-cover" />
|
||||
) : (
|
||||
<span className="bg-muted flex size-24 items-center justify-center rounded border">
|
||||
<FileIcon className="text-muted-foreground size-10" strokeWidth={1.25} />
|
||||
</span>
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
@@ -53,7 +76,16 @@ export default function PublicFileShowCompact({
|
||||
|
||||
<VersionNotice version={file.version} className="w-full" />
|
||||
|
||||
<DownloadAction href={download_url} limit={download_limit} size="sm" />
|
||||
<div className="flex items-center gap-2">
|
||||
<PreviewAction
|
||||
previewUrl={preview_url}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
/>
|
||||
<DownloadAction href={download_url} limit={download_limit} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import { File as FileIcon } from 'lucide-react';
|
||||
|
||||
import { CommentsShellDefault } from '@/components/comments/shells/comments-shell-default';
|
||||
import { DownloadAction } from '@/components/download-action';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
|
||||
import { type VersionLinks } from '@/components/files/version-badge';
|
||||
import { VersionNotice } from '@/components/files/version-notice';
|
||||
import PublicLayout from '@/layouts/public-layout';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { isThumbnailable } from '@/lib/thumbnails';
|
||||
import { type DownloadLimit } from '@/types/portal';
|
||||
|
||||
@@ -22,6 +25,12 @@ interface PublicFileShowProps {
|
||||
categories: CategoryTag[];
|
||||
};
|
||||
thumbnail_url: string | null;
|
||||
/**
|
||||
* Null unless this visitor is actually offered a preview — the
|
||||
* server has already weighed the setting, the file's type and its
|
||||
* download limit, so a theme never re-derives any of them.
|
||||
*/
|
||||
preview_url: string | null;
|
||||
download_url: string;
|
||||
download_limit: DownloadLimit;
|
||||
comments_enabled: boolean;
|
||||
@@ -31,6 +40,7 @@ interface PublicFileShowProps {
|
||||
export default function PublicFileShow({
|
||||
file,
|
||||
thumbnail_url,
|
||||
preview_url,
|
||||
download_url,
|
||||
download_limit,
|
||||
comments_enabled,
|
||||
@@ -42,8 +52,16 @@ export default function PublicFileShow({
|
||||
<Head title={file.name} />
|
||||
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{thumbnail_url && isThumbnailable(file.mime_type) && (
|
||||
<img src={thumbnail_url} alt="" className="max-h-80 max-w-full rounded-lg border object-contain" />
|
||||
{((thumbnail_url && isThumbnailable(file.mime_type)) || preview_url) && (
|
||||
<FilePreviewDialog previewUrl={preview_url} mimeType={file.mime_type} fileName={file.original_name}>
|
||||
{thumbnail_url && isThumbnailable(file.mime_type) ? (
|
||||
<img src={thumbnail_url} alt="" className="max-h-80 max-w-full rounded-lg border object-contain" />
|
||||
) : (
|
||||
<span className="bg-muted flex size-32 items-center justify-center rounded-lg border">
|
||||
<FileIcon className="text-muted-foreground size-12" strokeWidth={1.25} />
|
||||
</span>
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
)}
|
||||
|
||||
{file.description && <p className="text-muted-foreground text-center text-sm">{file.description}</p>}
|
||||
@@ -52,7 +70,16 @@ export default function PublicFileShow({
|
||||
|
||||
<VersionNotice version={file.version} className="w-full" />
|
||||
|
||||
<DownloadAction href={download_url} limit={download_limit} size="default" />
|
||||
<div className="flex items-center gap-2">
|
||||
<PreviewAction
|
||||
previewUrl={preview_url}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
variant="outline"
|
||||
size="default"
|
||||
/>
|
||||
<DownloadAction href={download_url} limit={download_limit} size="default" />
|
||||
</div>
|
||||
|
||||
{comments_enabled && (
|
||||
<div className="w-full max-w-lg">
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Head } from '@inertiajs/react';
|
||||
|
||||
import { CommentsShellDrive } from '@/components/comments/shells/comments-shell-drive';
|
||||
import { DownloadAction } from '@/components/download-action';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
|
||||
import { type VersionLinks } from '@/components/files/version-badge';
|
||||
import { VersionNotice } from '@/components/files/version-notice';
|
||||
import PublicLayoutDrive from '@/layouts/public-layout-drive';
|
||||
import { driveFileIcon } from '@/lib/drive-file-icon';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { isThumbnailable } from '@/lib/thumbnails';
|
||||
import { type DownloadLimit } from '@/types/portal';
|
||||
|
||||
@@ -23,6 +25,12 @@ interface PublicFileShowProps {
|
||||
categories: CategoryTag[];
|
||||
};
|
||||
thumbnail_url: string | null;
|
||||
/**
|
||||
* Null unless this visitor is actually offered a preview — the
|
||||
* server has already weighed the setting, the file's type and its
|
||||
* download limit, so a theme never re-derives any of them.
|
||||
*/
|
||||
preview_url: string | null;
|
||||
download_url: string;
|
||||
download_limit: DownloadLimit;
|
||||
comments_enabled: boolean;
|
||||
@@ -32,6 +40,7 @@ interface PublicFileShowProps {
|
||||
export default function PublicFileShowDrive({
|
||||
file,
|
||||
thumbnail_url,
|
||||
preview_url,
|
||||
download_url,
|
||||
download_limit,
|
||||
comments_enabled,
|
||||
@@ -45,11 +54,18 @@ export default function PublicFileShowDrive({
|
||||
|
||||
<div className="mx-auto flex max-w-lg flex-col items-center gap-6">
|
||||
<div className="flex w-full items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
{thumbnail_url && isThumbnailable(file.mime_type) ? (
|
||||
<img src={thumbnail_url} alt="" className="max-h-80 w-full object-contain" />
|
||||
) : (
|
||||
<Icon className={`m-16 size-16 ${color}`} strokeWidth={1.5} />
|
||||
)}
|
||||
<FilePreviewDialog
|
||||
previewUrl={preview_url}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="flex w-full items-center justify-center"
|
||||
>
|
||||
{thumbnail_url && isThumbnailable(file.mime_type) ? (
|
||||
<img src={thumbnail_url} alt="" className="max-h-80 w-full object-contain" />
|
||||
) : (
|
||||
<Icon className={`m-16 size-16 ${color}`} strokeWidth={1.5} />
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
</div>
|
||||
|
||||
{file.description && <p className="text-center text-sm text-neutral-500">{file.description}</p>}
|
||||
@@ -58,13 +74,23 @@ export default function PublicFileShowDrive({
|
||||
|
||||
<VersionNotice version={file.version} className="w-full" />
|
||||
|
||||
<DownloadAction
|
||||
href={download_url}
|
||||
limit={download_limit}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
variant="default"
|
||||
size="default"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<PreviewAction
|
||||
previewUrl={preview_url}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="border-blue-200 text-blue-700 hover:text-blue-800 dark:border-blue-900 dark:text-blue-400"
|
||||
variant="outline"
|
||||
size="default"
|
||||
/>
|
||||
<DownloadAction
|
||||
href={download_url}
|
||||
limit={download_limit}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
variant="default"
|
||||
size="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{comments_enabled && (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -3,10 +3,12 @@ import { File as FileIcon } from 'lucide-react';
|
||||
|
||||
import { CommentsShellGallery } from '@/components/comments/shells/comments-shell-gallery';
|
||||
import { DownloadAction } from '@/components/download-action';
|
||||
import { PreviewAction } from '@/components/preview-action';
|
||||
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
|
||||
import { type VersionLinks } from '@/components/files/version-badge';
|
||||
import { VersionNotice } from '@/components/files/version-notice';
|
||||
import { formatBytes } from '@/lib/format-bytes';
|
||||
import { FilePreviewDialog } from '@/components/file-preview-dialog';
|
||||
import { isThumbnailable } from '@/lib/thumbnails';
|
||||
import { type DownloadLimit } from '@/types/portal';
|
||||
import PublicLayoutGallery from '../../../../layouts/public-layout-gallery';
|
||||
@@ -23,6 +25,12 @@ interface PublicFileShowProps {
|
||||
categories: CategoryTag[];
|
||||
};
|
||||
thumbnail_url: string | null;
|
||||
/**
|
||||
* Null unless this visitor is actually offered a preview — the
|
||||
* server has already weighed the setting, the file's type and its
|
||||
* download limit, so a theme never re-derives any of them.
|
||||
*/
|
||||
preview_url: string | null;
|
||||
download_url: string;
|
||||
download_limit: DownloadLimit;
|
||||
comments_enabled: boolean;
|
||||
@@ -32,6 +40,7 @@ interface PublicFileShowProps {
|
||||
export default function PublicFileShowGallery({
|
||||
file,
|
||||
thumbnail_url,
|
||||
preview_url,
|
||||
download_url,
|
||||
download_limit,
|
||||
comments_enabled,
|
||||
@@ -44,11 +53,18 @@ export default function PublicFileShowGallery({
|
||||
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center gap-6">
|
||||
<div className="bg-muted flex w-full items-center justify-center overflow-hidden rounded-xl border shadow-lg">
|
||||
{thumbnail_url && isThumbnailable(file.mime_type) ? (
|
||||
<img src={thumbnail_url} alt="" className="max-h-[32rem] w-full object-contain" />
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground m-24 size-20" strokeWidth={1.25} />
|
||||
)}
|
||||
<FilePreviewDialog
|
||||
previewUrl={preview_url}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
className="flex w-full items-center justify-center"
|
||||
>
|
||||
{thumbnail_url && isThumbnailable(file.mime_type) ? (
|
||||
<img src={thumbnail_url} alt="" className="max-h-[32rem] w-full object-contain" />
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground m-24 size-20" strokeWidth={1.25} />
|
||||
)}
|
||||
</FilePreviewDialog>
|
||||
</div>
|
||||
|
||||
{file.description && <p className="text-muted-foreground text-center text-sm">{file.description}</p>}
|
||||
@@ -57,7 +73,16 @@ export default function PublicFileShowGallery({
|
||||
|
||||
<VersionNotice version={file.version} className="w-full" />
|
||||
|
||||
<DownloadAction href={download_url} limit={download_limit} size="lg" />
|
||||
<div className="flex items-center gap-2">
|
||||
<PreviewAction
|
||||
previewUrl={preview_url}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.original_name}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
/>
|
||||
<DownloadAction href={download_url} limit={download_limit} size="lg" />
|
||||
</div>
|
||||
|
||||
{comments_enabled && (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -19,6 +19,7 @@ interface ClientSettingsProps {
|
||||
clients_can_select_group: string;
|
||||
clients_membership_deny_cooldown_days: number;
|
||||
default_client_storage_quota_mb: number;
|
||||
clients_can_preview_files: boolean;
|
||||
groups: { id: number; name: string }[];
|
||||
}
|
||||
|
||||
@@ -29,6 +30,7 @@ export default function ClientSettings({
|
||||
clients_can_select_group,
|
||||
clients_membership_deny_cooldown_days,
|
||||
default_client_storage_quota_mb,
|
||||
clients_can_preview_files,
|
||||
groups,
|
||||
}: ClientSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -45,6 +47,7 @@ export default function ClientSettings({
|
||||
clients_can_select_group: clients_can_select_group,
|
||||
clients_membership_deny_cooldown_days: String(clients_membership_deny_cooldown_days),
|
||||
default_client_storage_quota_mb: String(default_client_storage_quota_mb),
|
||||
clients_can_preview_files: clients_can_preview_files,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
@@ -172,6 +175,25 @@ export default function ClientSettings({
|
||||
<InputError message={errors.default_client_storage_quota_mb} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="clients_can_preview_files"
|
||||
checked={data.clients_can_preview_files}
|
||||
onCheckedChange={(checked) => setData('clients_can_preview_files', checked === true)}
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="clients_can_preview_files" className="font-normal">
|
||||
{t('Clients can preview files')}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
'Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<InputError message={errors.clients_can_preview_files} />
|
||||
|
||||
<SaveButton processing={processing} recentlySuccessful={recentlySuccessful} />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,14 @@ import AppLayout from '@/layouts/app-layout';
|
||||
interface PublicListingSettingsProps {
|
||||
public_listing_enabled: boolean;
|
||||
public_listing_slug: string;
|
||||
public_listing_preview_enabled: boolean;
|
||||
}
|
||||
|
||||
export default function PublicListingSettings({ public_listing_enabled, public_listing_slug }: PublicListingSettingsProps) {
|
||||
export default function PublicListingSettings({
|
||||
public_listing_enabled,
|
||||
public_listing_slug,
|
||||
public_listing_preview_enabled,
|
||||
}: PublicListingSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
@@ -29,6 +34,7 @@ export default function PublicListingSettings({ public_listing_enabled, public_l
|
||||
const { data, setData, patch, errors, processing, recentlySuccessful } = useForm({
|
||||
public_listing_enabled: public_listing_enabled,
|
||||
public_listing_slug: public_listing_slug,
|
||||
public_listing_preview_enabled: public_listing_preview_enabled,
|
||||
});
|
||||
|
||||
const submit: FormEventHandler = (e) => {
|
||||
@@ -116,6 +122,31 @@ export default function PublicListingSettings({ public_listing_enabled, public_l
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Outside the directory switch above on purpose: a public
|
||||
group's page works whether or not the directory is
|
||||
enabled, so its preview has to be configurable
|
||||
independently of it. */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="public_listing_preview_enabled"
|
||||
checked={data.public_listing_preview_enabled}
|
||||
onCheckedChange={(checked) => setData('public_listing_preview_enabled', checked === true)}
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="public_listing_preview_enabled" className="font-normal">
|
||||
{t('Let visitors preview public files')}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t(
|
||||
'Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<InputError message={errors.public_listing_preview_enabled} />
|
||||
</div>
|
||||
|
||||
<SaveButton processing={processing} recentlySuccessful={recentlySuccessful} />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -88,6 +88,15 @@ export interface MyFilesProps {
|
||||
* a conversation the settings have turned off.
|
||||
*/
|
||||
comments_enabled: boolean;
|
||||
/**
|
||||
* Whether this install lets clients look at a file as well as take it
|
||||
* (Setting::ClientsCanPreviewFiles). Per page, not per file: which
|
||||
* types can be shown is decided from the row's mime type by
|
||||
* previewKind(), so all a theme needs from the server is whether the
|
||||
* affordance is offered here at all. With it false a row is exactly
|
||||
* what it was before preview existed — a name and a download.
|
||||
*/
|
||||
preview_enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -125,6 +125,7 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('files/{file}/details', [FileDetailsController::class, 'show'])->middleware('staff')->name('files.details');
|
||||
Route::get('files/{file}/activity', [FileDetailsController::class, 'activity'])->middleware('staff')->name('files.activity');
|
||||
Route::get('files/{file}/activity/history', [FileDetailsController::class, 'activityHistory'])->middleware('staff')->name('files.activity.history');
|
||||
Route::get('files/{file}/access', [FileDetailsController::class, 'access'])->middleware('staff')->name('files.access');
|
||||
Route::get('files/{file}/downloads', [FileDetailsController::class, 'downloads'])->middleware('staff')->name('files.downloads');
|
||||
Route::get('files/{file}/downloads/history', [FileDetailsController::class, 'downloadsHistory'])->middleware('staff')->name('files.downloads.history');
|
||||
// Must be registered before files/{file} below, or "bulk-edit" would be
|
||||
@@ -358,6 +359,11 @@ Route::post('{publicSlug}/files/{file:slug}/comments', [PublicFileCommentsContro
|
||||
// on this list, which is why the number is high. It is still a hard ceiling
|
||||
// against scraping the whole listing.
|
||||
Route::get('{publicSlug}/files/{file:slug}/thumbnail', [PublicGroupsController::class, 'thumbnail'])->middleware('throttle:240,1,public-thumbnail')->name('public.thumbnail')->fallback();
|
||||
// High for the same shape of reason as thumbnails, arrived at differently:
|
||||
// one <video> playing is a long tail of Range requests against this single
|
||||
// URL, and a viewer who scrubs a recording generates them faster than a
|
||||
// download ever would. Its own bucket, like every route in this block.
|
||||
Route::get('{publicSlug}/files/{file:slug}/preview', [PublicGroupsController::class, 'preview'])->middleware('throttle:240,1,public-preview')->name('public.preview')->fallback();
|
||||
Route::get('{publicSlug}/files/{file:slug}/download', [PublicGroupsController::class, 'download'])->middleware('throttle:30,1,public-download')->name('public.download')->fallback();
|
||||
// Must be registered before the generic {groupSlug} catch-all below, or
|
||||
// "folders" would be swallowed as a group slug value first.
|
||||
|
||||
@@ -178,3 +178,69 @@ test('a client-scoped viewer does see entries for their own clients and their ow
|
||||
// permission alone, so it never points at a 403.
|
||||
->and($rows->firstWhere('file_name', 'quarterly-report')['file_url'])->not->toBeNull();
|
||||
});
|
||||
|
||||
test('the installation-wide download history filters by file, by account and by date', function () {
|
||||
$report = uploadImageFile($this->admin, 'quarterly-report.jpg');
|
||||
$photo = uploadImageFile($this->admin, 'holiday-photo.jpg');
|
||||
$client = User::factory()->client()->create(['name' => 'Acme Design']);
|
||||
|
||||
$log = function ($file, ?User $actor, string $when) {
|
||||
ActivityLog::create([
|
||||
'action' => $actor === null ? Action::ShareLinkDownloaded : Action::FileDownloaded,
|
||||
'subject_type' => $file->getMorphClass(),
|
||||
'subject_id' => $file->id,
|
||||
'subject_name' => $file->name,
|
||||
'actor_id' => $actor?->id,
|
||||
'actor_name' => $actor?->name,
|
||||
'actor_type' => $actor === null ? null : 'client',
|
||||
'created_at' => $when,
|
||||
]);
|
||||
};
|
||||
|
||||
$log($report, $client, '2026-08-10 09:00:00');
|
||||
$log($report, $this->admin, '2026-08-12 09:00:00');
|
||||
$log($photo, $client, '2026-08-14 09:00:00');
|
||||
// Anonymous: no account name to match, so the user filter can never
|
||||
// return it — which is what the column already says on screen.
|
||||
$log($photo, null, '2026-08-16 09:00:00');
|
||||
|
||||
$entries = fn (string $query) => $this->actingAs($this->admin)->get("/downloads{$query}")->assertOk();
|
||||
|
||||
$entries('?file=report')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 2)->where('filters.file', 'report'),
|
||||
);
|
||||
|
||||
$entries('?user=Acme')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 2)->where('entries.0.actor_name', 'Acme Design'),
|
||||
);
|
||||
|
||||
// Both filters at once narrow to the one row that satisfies each.
|
||||
$entries('?file=report&user=Acme')->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 1));
|
||||
|
||||
$entries('?from=2026-08-13')->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 2));
|
||||
$entries('?to=2026-08-11')->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 1));
|
||||
$entries('?from=2026-08-11&to=2026-08-15')->assertInertia(fn (AssertableInertia $page) => $page->has('entries', 2));
|
||||
|
||||
// A range that ends before it starts is rejected rather than silently
|
||||
// returning nothing.
|
||||
$this->actingAs($this->admin)->get('/downloads?from=2026-08-15&to=2026-08-11')->assertSessionHasErrors('to');
|
||||
});
|
||||
|
||||
test('a client-scoped viewer cannot widen the download history with a filter', function () {
|
||||
$secret = uploadImageFile($this->admin, 'board-minutes-confidential.jpg');
|
||||
$this->actingAs($this->admin)->get("/files/{$secret->id}/download")->assertOk();
|
||||
|
||||
$manager = User::factory()->role(SystemRole::ClientManager)->create();
|
||||
$manager->assignedClients()->sync([]);
|
||||
|
||||
// Out of this viewer's library scope, so it stays invisible however
|
||||
// precisely it is searched for: the filters narrow the scoped query,
|
||||
// they do not replace it.
|
||||
$this->actingAs($manager)->get('/downloads?file=board-minutes')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 0),
|
||||
);
|
||||
|
||||
$this->actingAs($this->admin)->get('/downloads?file=board-minutes')->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 1),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -101,6 +101,7 @@ test('staff can update client settings and they take effect', function () {
|
||||
'clients_can_select_group' => 'none',
|
||||
'clients_membership_deny_cooldown_days' => 30,
|
||||
'default_client_storage_quota_mb' => 0,
|
||||
'clients_can_preview_files' => true,
|
||||
])->assertRedirect()->assertSessionDoesntHaveErrors();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Modules\Identity\Models\RolePermission;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -236,3 +237,27 @@ test('a file with no limit says so rather than reporting a zero', function () {
|
||||
->assertJsonPath('expires_at', null)
|
||||
->assertJsonPath('expired', false);
|
||||
});
|
||||
|
||||
test("the file's own page carries an activity tab, behind the same permission", function () {
|
||||
$this->actingAs($this->admin)->post('/files', [
|
||||
'file' => UploadedFile::fake()->create('a.pdf', 10, 'application/pdf'), 'name' => '', 'description' => '',
|
||||
]);
|
||||
$file = File::query()->sole();
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}")->assertOk()->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->component('files/edit')->where('can_view_activity', true),
|
||||
);
|
||||
|
||||
// The uploader of *this* file, but with no view_actions_log: the page
|
||||
// still loads, without the tab that would show the log.
|
||||
$role = Role::query()->create(['name' => 'No Log']);
|
||||
foreach (['upload', 'edit_own_files'] as $permission) {
|
||||
RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]);
|
||||
}
|
||||
$noLog = User::factory()->create(['role_id' => $role->id]);
|
||||
$file->update(['uploaded_by' => $noLog->id]);
|
||||
|
||||
$this->actingAs($noLog)->get("/files/{$file->id}")->assertOk()->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->component('files/edit')->where('can_view_activity', false),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -197,3 +197,186 @@ test('the details panel downloads summary is bounded but reports the true total'
|
||||
->and($response->json('downloaders'))->toHaveCount(1)
|
||||
->and($response->json('downloaders.0.count'))->toBe(500);
|
||||
});
|
||||
|
||||
test('the file activity history filters by action, and offers only the actions that happened', function () {
|
||||
$file = uploadImageFile($this->admin);
|
||||
$client = User::factory()->client()->create(['name' => 'Downloading Client']);
|
||||
|
||||
$log = function (Action $action, ?User $actor = null) use ($file): void {
|
||||
ActivityLog::create([
|
||||
'action' => $action,
|
||||
'subject_type' => $file->getMorphClass(),
|
||||
'subject_id' => $file->id,
|
||||
'subject_name' => $file->name,
|
||||
'actor_id' => $actor?->id,
|
||||
'actor_name' => $actor?->name,
|
||||
'actor_type' => $actor === null ? null : 'client',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
};
|
||||
|
||||
$log(Action::FileDownloaded, $client);
|
||||
$log(Action::FileDownloaded, $client);
|
||||
$log(Action::ShareLinkDownloaded);
|
||||
$log(Action::FilePreviewed, $client);
|
||||
|
||||
$options = $this->actingAs($this->admin)->get("/files/{$file->id}/activity/history")
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->component('activity/subject'))
|
||||
->viewData('page')['props']['action_options'];
|
||||
|
||||
$byKey = collect($options)->keyBy('key');
|
||||
|
||||
// The upload happened; the eighty-odd actions that cannot apply to a
|
||||
// file are not offered at all.
|
||||
expect($byKey->keys()->all())->toEqualCanonicalizing(['downloads', 'file.uploaded', 'file.downloaded', 'file.previewed', 'share_link.downloaded'])
|
||||
->and($byKey['downloads']['count'])->toBe(3)
|
||||
->and($byKey['file.downloaded']['count'])->toBe(2);
|
||||
|
||||
// A file whose log holds only one flavour of download gets no group:
|
||||
// it would filter to exactly what its single member already offers.
|
||||
$simple = uploadImageFile($this->admin, 'simple.jpg');
|
||||
ActivityLog::create([
|
||||
'action' => Action::FileDownloaded,
|
||||
'subject_type' => $simple->getMorphClass(),
|
||||
'subject_id' => $simple->id,
|
||||
'subject_name' => $simple->name,
|
||||
'actor_id' => $this->admin->id,
|
||||
'actor_name' => $this->admin->name,
|
||||
'actor_type' => 'staff',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$simpleOptions = $this->actingAs($this->admin)->get("/files/{$simple->id}/activity/history")
|
||||
->viewData('page')['props']['action_options'];
|
||||
|
||||
expect(collect($simpleOptions)->pluck('key')->all())->toEqualCanonicalizing(['file.uploaded', 'file.downloaded']);
|
||||
|
||||
// The group covers all three download flavours, one of them anonymous.
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=downloads")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 3)->where('filters.action', 'downloads'),
|
||||
);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=file.previewed")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 1)->where('entries.0.actor_name', 'Downloading Client'),
|
||||
);
|
||||
|
||||
// Narrowing by who acted, and by day, works the same as the main log.
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?actor=Downloading")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 3),
|
||||
);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?from=".now()->addDay()->toDateString())->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 0),
|
||||
);
|
||||
|
||||
// An action that exists but never touched this file filters to nothing
|
||||
// rather than erroring; a value that is neither action nor group is
|
||||
// rejected outright.
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=user.created")->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 0),
|
||||
);
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=nonsense")->assertSessionHasErrors('action');
|
||||
});
|
||||
|
||||
test('the access endpoint answers who downloaded and who previewed the file', function () {
|
||||
$file = uploadImageFile($this->admin);
|
||||
$client = User::factory()->client()->create(['name' => 'Acme Design']);
|
||||
|
||||
$log = function (Action $action, ?User $actor = null, ?string $ip = null) use ($file): void {
|
||||
ActivityLog::create([
|
||||
'action' => $action,
|
||||
'subject_type' => $file->getMorphClass(),
|
||||
'subject_id' => $file->id,
|
||||
'subject_name' => $file->name,
|
||||
'actor_id' => $actor?->id,
|
||||
'actor_name' => $actor?->name,
|
||||
'actor_type' => $actor === null ? null : 'client',
|
||||
'ip_address' => $ip,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
};
|
||||
|
||||
$log(Action::FileDownloaded, $client, '10.0.0.1');
|
||||
$log(Action::ShareLinkDownloaded, null, '10.0.0.2');
|
||||
$log(Action::PublicFileDownloaded);
|
||||
$log(Action::FilePreviewed, $client);
|
||||
// Neither a download nor a preview: this tab is not the activity log.
|
||||
$log(Action::FileUpdated, $this->admin);
|
||||
|
||||
$response = $this->actingAs($this->admin)->getJson("/files/{$file->id}/access")->assertOk();
|
||||
|
||||
expect($response->json('downloads_total'))->toBe(3)
|
||||
->and($response->json('previews_total'))->toBe(1)
|
||||
// Four rows, not five: the edit entry (and the upload) are excluded.
|
||||
->and($response->json('entries'))->toHaveCount(4)
|
||||
->and($response->json('entries.0.template'))->toBe('Previewed the file ":subject"')
|
||||
->and($response->json('entries.0.actor_name'))->toBe('Acme Design')
|
||||
->and($response->json('entries.3.ip_address'))->toBe('10.0.0.1');
|
||||
|
||||
// The buttons carry the filter the history page understands: a group
|
||||
// for downloads, the action itself for previews (which have no group
|
||||
// while only one action records them).
|
||||
expect($response->json('downloads_url'))->toBe("/files/{$file->id}/activity/history?action=downloads")
|
||||
->and($response->json('previews_url'))->toBe("/files/{$file->id}/activity/history?action=file.previewed");
|
||||
|
||||
// Following either one lands on a history page filtered to just that.
|
||||
$this->actingAs($this->admin)->get($response->json('downloads_url'))->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->component('activity/subject')->has('entries', 3),
|
||||
);
|
||||
$this->actingAs($this->admin)->get($response->json('previews_url'))->assertInertia(
|
||||
fn (AssertableInertia $page) => $page->has('entries', 1),
|
||||
);
|
||||
});
|
||||
|
||||
test('the access endpoint is capped at 20 rows and honours the activity-log permission', function () {
|
||||
$file = uploadImageFile($this->admin);
|
||||
|
||||
for ($i = 0; $i < 24; $i++) {
|
||||
ActivityLog::create([
|
||||
'action' => Action::FileDownloaded,
|
||||
'subject_type' => $file->getMorphClass(),
|
||||
'subject_id' => $file->id,
|
||||
'subject_name' => $file->name,
|
||||
'actor_id' => $this->admin->id,
|
||||
'actor_name' => $this->admin->name,
|
||||
'actor_type' => 'staff',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->actingAs($this->admin)->getJson("/files/{$file->id}/access")->assertOk();
|
||||
expect($response->json('entries'))->toHaveCount(20)
|
||||
->and($response->json('downloads_total'))->toBe(24);
|
||||
|
||||
$role = Role::query()->create(['name' => 'No Access Log', 'is_administrator' => false, 'is_system' => false]);
|
||||
RolePermission::query()->insert([['role_id' => $role->id, 'permission' => 'upload']]);
|
||||
$restricted = User::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
$this->actingAs($restricted)->getJson("/files/{$file->id}/access")->assertForbidden();
|
||||
});
|
||||
|
||||
test('a filter arrived at from a button stays visible in the dropdown at a count of zero', function () {
|
||||
$file = uploadImageFile($this->admin);
|
||||
ActivityLog::create([
|
||||
'action' => Action::FileDownloaded,
|
||||
'subject_type' => $file->getMorphClass(),
|
||||
'subject_id' => $file->id,
|
||||
'subject_name' => $file->name,
|
||||
'actor_id' => $this->admin->id,
|
||||
'actor_name' => $this->admin->name,
|
||||
'actor_type' => 'staff',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// One download flavour, so the group would normally be left out — but
|
||||
// the "View all downloads" button links straight to it, and a select
|
||||
// whose value is missing from its own options renders blank.
|
||||
$options = $this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=downloads")
|
||||
->viewData('page')['props']['action_options'];
|
||||
expect(collect($options)->firstWhere('key', 'downloads'))->not->toBeNull();
|
||||
|
||||
// Same for a single action the file has never seen.
|
||||
$options = $this->actingAs($this->admin)->get("/files/{$file->id}/activity/history?action=file.previewed")
|
||||
->viewData('page')['props']['action_options'];
|
||||
expect(collect($options)->firstWhere('key', 'file.previewed'))->toMatchArray(['count' => 0]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// What the `files` disk writes to disk, in modes rather than in config keys.
|
||||
//
|
||||
// A download is not served by PHP: PHP authorizes it and hands the web server
|
||||
// the path with X-Accel-Redirect. So on a host where those are different
|
||||
// users the mode on a directory decides whether downloads work at all, and
|
||||
// nothing else on the site notices (#1668).
|
||||
//
|
||||
// The umask cases are the point of this file. `visibility` makes Flysystem
|
||||
// chmod each file after writing it, so it holds regardless; a directory is
|
||||
// created by mkdir(), which masks its mode argument, so the same setting is a
|
||||
// ceiling there rather than a guarantee. That asymmetry is invisible from the
|
||||
// configuration and is exactly what someone would "simplify" away.
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Points the `files` disk at a scratch root, configured the way
|
||||
* config/filesystems.php configures it for the given flag.
|
||||
*/
|
||||
function filesDiskWith(bool $webServerReadable): string
|
||||
{
|
||||
$root = storage_path('app/files-permission-test');
|
||||
|
||||
config(['filesystems.disks.files' => [
|
||||
'driver' => 'local',
|
||||
'root' => $root,
|
||||
'serve' => false,
|
||||
'throw' => false,
|
||||
...($webServerReadable
|
||||
? ['visibility' => 'public', 'permissions' => ['dir' => ['private' => 0755]]]
|
||||
: []),
|
||||
]]);
|
||||
|
||||
Storage::forgetDisk('files');
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
function modeOf(string $path): string
|
||||
{
|
||||
clearstatcache(true, $path);
|
||||
|
||||
return substr(sprintf('%o', fileperms($path)), -4);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->originalUmask = umask();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
umask($this->originalUmask);
|
||||
File::deleteDirectory(storage_path('app/files-permission-test'));
|
||||
Storage::forgetDisk('files');
|
||||
});
|
||||
|
||||
test('by default an upload lands in a directory only its owner can traverse', function () {
|
||||
umask(0022);
|
||||
$root = filesDiskWith(webServerReadable: false);
|
||||
|
||||
Storage::disk('files')->put('2026/08/report.pdf', 'contents');
|
||||
|
||||
// 0700: a web server running as another user cannot open anything
|
||||
// underneath this, whatever the file's own mode says.
|
||||
expect(modeOf($root.'/2026/08'))->toBe('0700');
|
||||
});
|
||||
|
||||
test('the flag opens both the file and the directory for another user', function () {
|
||||
umask(0022);
|
||||
$root = filesDiskWith(webServerReadable: true);
|
||||
|
||||
Storage::disk('files')->put('2026/08/report.pdf', 'contents');
|
||||
|
||||
expect(modeOf($root.'/2026/08/report.pdf'))->toBe('0644')
|
||||
->and(modeOf($root.'/2026/08'))->toBe('0755');
|
||||
});
|
||||
|
||||
// Both halves of the same claim, on a pool that denies group and other by
|
||||
// default. The file still comes out readable because it is chmod'ed after the
|
||||
// write; the directory does not, because mkdir() masked it — so the flag alone
|
||||
// does not rescue a host like this and INSTALL.md has to say so.
|
||||
test('a restrictive umask still caps the directory, though not the file', function () {
|
||||
umask(0077);
|
||||
$root = filesDiskWith(webServerReadable: true);
|
||||
|
||||
Storage::disk('files')->put('2026/08/report.pdf', 'contents');
|
||||
|
||||
expect(modeOf($root.'/2026/08/report.pdf'))->toBe('0644')
|
||||
->and(modeOf($root.'/2026/08'))->toBe('0700');
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Preview beyond images: the types a browser plays natively, which this
|
||||
* app never decodes and therefore never renders, caches or watermarks.
|
||||
* The allowlist that admits them is PreviewKind, deliberately separate
|
||||
* from the rendition allowlist asserted in FileThumbnailsTest.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
$this->admin = User::factory()->create();
|
||||
|
||||
// Settings survive RefreshDatabase's rollback through their cache, so
|
||||
// a test that means "the default" has to say so rather than assume it.
|
||||
app(Settings::class)->set(Setting::ClientsCanPreviewFiles, true);
|
||||
});
|
||||
|
||||
function uploadMediaFile(User $as, string $name, string $mimeType): File
|
||||
{
|
||||
test()->actingAs($as)->post('/files', [
|
||||
'file' => UploadedFile::fake()->create($name, 16, $mimeType),
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
]);
|
||||
|
||||
return File::query()->latest('id')->firstOrFail();
|
||||
}
|
||||
|
||||
test('a media file is served inline, as itself, from the local disk', function (string $name, string $mimeType) {
|
||||
$file = uploadMediaFile($this->admin, $name, $mimeType);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', $mimeType)
|
||||
->assertHeader('Content-Disposition', 'inline; filename="'.$name.'"')
|
||||
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path)
|
||||
->assertHeader('Content-Length', (string) $file->size);
|
||||
})->with([
|
||||
'mp4 video' => ['clip.mp4', 'video/mp4'],
|
||||
'webm video' => ['clip.webm', 'video/webm'],
|
||||
'mp3 audio' => ['song.mp3', 'audio/mpeg'],
|
||||
'wav audio' => ['song.wav', 'audio/x-wav'],
|
||||
'flac audio' => ['song.flac', 'audio/flac'],
|
||||
'pdf' => ['contract.pdf', 'application/pdf'],
|
||||
]);
|
||||
|
||||
// A format the browser would only show a black rectangle for is not
|
||||
// previewable, however happily it uploads and downloads.
|
||||
test('a media container no browser decodes is not previewable', function (string $name, string $mimeType) {
|
||||
$file = uploadMediaFile($this->admin, $name, $mimeType);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertNotFound();
|
||||
})->with([
|
||||
'quicktime' => ['clip.mov', 'video/quicktime'],
|
||||
'avi' => ['clip.avi', 'video/x-msvideo'],
|
||||
'matroska' => ['clip.mkv', 'video/x-matroska'],
|
||||
]);
|
||||
|
||||
// One deliberate act, however many Range requests the browser turns it
|
||||
// into. Without the guard, watching one video buries the activity log.
|
||||
test('replaying a preview logs it once, not once per request', function () {
|
||||
$file = uploadMediaFile($this->admin, 'clip.mp4', 'video/mp4');
|
||||
|
||||
foreach (range(1, 5) as $ignored) {
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertOk();
|
||||
}
|
||||
|
||||
expect(ActivityLog::query()->where('action', Action::FilePreviewed)->where('subject_id', $file->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
// Keyed by viewer, so one person's playback cannot swallow the record of
|
||||
// somebody else looking at the same file.
|
||||
test('two viewers of the same file are each logged', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = uploadMediaFile($this->admin, 'clip.mp4', 'video/mp4');
|
||||
shareFileWith($file, $client);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertOk();
|
||||
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertOk();
|
||||
|
||||
$actors = ActivityLog::query()->where('action', Action::FilePreviewed)->where('subject_id', $file->id)
|
||||
->pluck('actor_id')->sort()->values()->all();
|
||||
|
||||
expect($actors)->toBe(collect([$this->admin->id, $client->id])->sort()->values()->all());
|
||||
});
|
||||
|
||||
test('with client preview switched off a client cannot preview, and staff still can', function () {
|
||||
app(Settings::class)->set(Setting::ClientsCanPreviewFiles, false);
|
||||
|
||||
$client = User::factory()->client()->create();
|
||||
$file = uploadMediaFile($this->admin, 'contract.pdf', 'application/pdf');
|
||||
shareFileWith($file, $client);
|
||||
|
||||
$this->actingAs($client)->get("/files/{$file->id}/preview")->assertNotFound();
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertOk();
|
||||
});
|
||||
|
||||
// The switch is about looking, never about having: a client who is
|
||||
// refused a preview downloads exactly as before.
|
||||
test('a client refused a preview can still download the file', function () {
|
||||
app(Settings::class)->set(Setting::ClientsCanPreviewFiles, false);
|
||||
|
||||
$client = User::factory()->client()->create();
|
||||
$file = uploadMediaFile($this->admin, 'contract.pdf', 'application/pdf');
|
||||
shareFileWith($file, $client);
|
||||
|
||||
$this->actingAs($client)->get("/files/{$file->id}/download")->assertOk();
|
||||
});
|
||||
|
||||
test('the portal tells its theme whether preview is offered', function () {
|
||||
app(Settings::class)->set(Setting::Theme, 'default');
|
||||
$client = User::factory()->client()->create();
|
||||
|
||||
$this->actingAs($client)->get('/my-files')
|
||||
->assertInertia(fn ($page) => $page->where('preview_enabled', true));
|
||||
|
||||
app(Settings::class)->set(Setting::ClientsCanPreviewFiles, false);
|
||||
|
||||
$this->actingAs($client)->get('/my-files')
|
||||
->assertInertia(fn ($page) => $page->where('preview_enabled', false));
|
||||
});
|
||||
@@ -70,9 +70,27 @@ test('the preview endpoint serves the original file inline and logs a preview, n
|
||||
expect($entry->actor_id)->toBe($this->admin->id);
|
||||
});
|
||||
|
||||
test('a non-renderable file 404s when a preview is requested', function () {
|
||||
// A PDF has no thumbnail (nothing here decodes one) but does have a
|
||||
// preview, which is the whole reason the two allowlists are separate.
|
||||
test('a file with no thumbnail can still be previewed', function () {
|
||||
$file = uploadPdfFile($this->admin);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'application/pdf')
|
||||
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
||||
});
|
||||
|
||||
test('a file of a type no browser plays 404s when a preview is requested', function () {
|
||||
$file = File::factory()->create([
|
||||
'uploaded_by' => $this->admin->id,
|
||||
'name' => 'archive',
|
||||
'original_name' => 'archive.zip',
|
||||
'path' => '2026/08/'.Str::uuid()->toString().'.zip',
|
||||
'mime_type' => 'application/zip',
|
||||
'size' => 64,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->admin)->get("/files/{$file->id}/preview")->assertNotFound();
|
||||
});
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -213,6 +213,29 @@ test('core asks whether a preview must be rendered, and defaults to no', functio
|
||||
expect($asked)->toBe([[ImageAudience::External, ImageRendition::Preview, false]]);
|
||||
});
|
||||
|
||||
// The trap this closes: the watermark listener decides on audience
|
||||
// alone, so if a PDF or a video reached the resolving hook it would come
|
||||
// back "render this" — and ThumbnailGenerator has no rendition for
|
||||
// either, which would 404 every non-image preview on a watermarking
|
||||
// installation. Core never asks about a type it cannot render.
|
||||
test('core does not ask about rendering a file it could never render', function () {
|
||||
$client = User::factory()->client()->create();
|
||||
$file = uploadDocumentFile($this->admin);
|
||||
shareFileWith($file, $client);
|
||||
|
||||
$asked = [];
|
||||
Event::listen(ResolvingImageRendering::class, function (ResolvingImageRendering $event) use (&$asked): void {
|
||||
$asked[] = $event->mimeType;
|
||||
$event->required = true;
|
||||
});
|
||||
|
||||
$this->actingAs($client)->get("/files/{$file->id}/preview")
|
||||
->assertOk()
|
||||
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
||||
|
||||
expect($asked)->toBe([]);
|
||||
});
|
||||
|
||||
// A preview is a rendered view of a file, not the file — so a listener
|
||||
// that intends to decorate it (the watermark) can have it rendered, and
|
||||
// what the client then sees is the decorated copy rather than the
|
||||
|
||||
@@ -180,6 +180,7 @@ test('the client settings screen validates the group options', function () {
|
||||
'clients_can_select_group' => 'public',
|
||||
'clients_membership_deny_cooldown_days' => 0,
|
||||
'default_client_storage_quota_mb' => 0,
|
||||
'clients_can_preview_files' => true,
|
||||
])->assertSessionDoesntHaveErrors();
|
||||
|
||||
expect(app(Settings::class)->get(Setting::ClientsAutoGroup))->toBe($group->id);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* The anonymous half of preview: a public file shown in the browser
|
||||
* rather than only handed over. Its own switch, separate from the
|
||||
* client-portal one, because the audiences are different — anyone with
|
||||
* the link, versus someone with an account.
|
||||
*
|
||||
* Shares publicListingFile()/publicListingImageFile() with
|
||||
* PublicGroupsTest, which is where the surrounding public-listing
|
||||
* behaviour (slugs, expiry, the directory switch) is covered.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
|
||||
// EnsureSetupIsComplete sends every guest to /setup until staff exist.
|
||||
$this->staff = User::factory()->create();
|
||||
|
||||
app(Settings::class)->set(Setting::PublicListingEnabled, true);
|
||||
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
|
||||
app(Settings::class)->set(Setting::Theme, 'default');
|
||||
app(Settings::class)->set(Setting::PublicListingPreviewEnabled, true);
|
||||
});
|
||||
|
||||
test('a visitor can preview a public file, and it is logged as a public preview', function () {
|
||||
$file = publicListingFile();
|
||||
|
||||
$this->get(route('public.preview', ['public', $file->slug]))
|
||||
->assertOk()
|
||||
->assertHeader('Content-Type', 'application/pdf')
|
||||
->assertHeader('Content-Disposition', 'inline; filename="report.pdf"')
|
||||
->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path);
|
||||
|
||||
expect(ActivityLog::query()->where('action', Action::PublicFilePreviewed)->where('subject_id', $file->id)->exists())->toBeTrue()
|
||||
// Previewing is not taking. The download counters must not move.
|
||||
->and(ActivityLog::query()->where('action', Action::PublicFileDownloaded)->where('subject_id', $file->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a file that is not public, or has expired, has no preview', function () {
|
||||
$private = publicListingFile(['public' => false]);
|
||||
$expired = publicListingFile(['expires_at' => now()->subDay()]);
|
||||
|
||||
$this->get(route('public.preview', ['public', $private->slug]))->assertNotFound();
|
||||
$this->get(route('public.preview', ['public', $expired->slug]))->assertNotFound();
|
||||
});
|
||||
|
||||
test('with visitor preview switched off the route is gone but the download is not', function () {
|
||||
app(Settings::class)->set(Setting::PublicListingPreviewEnabled, false);
|
||||
|
||||
$file = publicListingFile();
|
||||
|
||||
$this->get(route('public.preview', ['public', $file->slug]))->assertNotFound();
|
||||
$this->get(route('public.download', ['public', $file->slug]))->assertOk();
|
||||
});
|
||||
|
||||
test('a type no browser renders has no public preview either', function () {
|
||||
$file = publicListingFile(['original_name' => 'archive.zip', 'mime_type' => 'application/zip']);
|
||||
|
||||
$this->get(route('public.preview', ['public', $file->slug]))->assertNotFound();
|
||||
});
|
||||
|
||||
// 403, not 404, for the same reason download() does it: the link never
|
||||
// broke, the file has simply been taken as often as it was meant to be.
|
||||
test('a spent download limit closes the preview too', function () {
|
||||
$file = publicListingFile(['download_limit' => 1]);
|
||||
|
||||
$this->get(route('public.download', ['public', $file->slug]))->assertOk();
|
||||
|
||||
$this->get(route('public.preview', ['public', $file->slug]))->assertForbidden();
|
||||
});
|
||||
|
||||
test('the public file page carries a preview url only when a visitor may use one', function () {
|
||||
$file = publicListingFile();
|
||||
|
||||
$this->get(route('public.file', ['public', $file->slug]))->assertInertia(
|
||||
fn ($page) => $page->component('public/themes/default/file')
|
||||
->where('preview_url', route('public.preview', ['public', $file->slug])),
|
||||
);
|
||||
|
||||
app(Settings::class)->set(Setting::PublicListingPreviewEnabled, false);
|
||||
|
||||
$this->get(route('public.file', ['public', $file->slug]))
|
||||
->assertInertia(fn ($page) => $page->where('preview_url', null));
|
||||
});
|
||||
|
||||
// Offering a button whose only possible answer is 403 is worse than
|
||||
// offering none, so the page stops advertising a spent file.
|
||||
test('a public file whose limit is spent stops offering a preview', function () {
|
||||
$file = publicListingFile(['download_limit' => 1]);
|
||||
|
||||
$this->get(route('public.download', ['public', $file->slug]))->assertOk();
|
||||
|
||||
$this->get(route('public.file', ['public', $file->slug]))
|
||||
->assertInertia(fn ($page) => $page->where('preview_url', null));
|
||||
});
|
||||
@@ -11,10 +11,8 @@ use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Testing\TestResponse;
|
||||
|
||||
/**
|
||||
@@ -27,38 +25,6 @@ function publicPageProps(TestResponse $response): array
|
||||
return $page['props'];
|
||||
}
|
||||
|
||||
function publicListingFile(array $overrides = []): File
|
||||
{
|
||||
return File::factory()->create(array_merge([
|
||||
'uploaded_by' => User::factory()->create()->id,
|
||||
'name' => 'Report',
|
||||
'original_name' => 'report.pdf',
|
||||
'path' => '2026/08/'.Str::uuid()->toString().'.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size' => 2048,
|
||||
'public' => true,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
/**
|
||||
* A real, thumbnailable public image on the faked "files" disk — unlike
|
||||
* publicListingFile()'s bare PDF row, this one has actual bytes GD can
|
||||
* decode, needed to exercise the thumbnail-generation path.
|
||||
*/
|
||||
function publicListingImageFile(User $uploader): File
|
||||
{
|
||||
test()->actingAs($uploader)->post('/files', [
|
||||
'file' => UploadedFile::fake()->image('photo.jpg', 200, 100),
|
||||
'name' => '',
|
||||
'description' => '',
|
||||
]);
|
||||
|
||||
$file = File::query()->latest('id')->firstOrFail();
|
||||
$file->update(['public' => true]);
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('files');
|
||||
|
||||
@@ -344,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'));
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user