mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-03 21:03:17 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
|
||||
@@ -13,6 +13,66 @@ 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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **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.
|
||||
|
||||
+96
-24
@@ -68,7 +68,22 @@ 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.
|
||||
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 S3-compatible object storage instead (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
|
||||
@@ -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
|
||||
@@ -388,28 +463,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 +565,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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Delivery;
|
||||
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* A stored file's own bytes, served to be looked at rather than saved.
|
||||
*
|
||||
* The two preview endpoints — FileThumbnailController::preview for
|
||||
* someone signed in, PublicGroupsController::preview for a visitor —
|
||||
* reach this after they have each authorized in their own way. It
|
||||
* authorizes nothing itself; it only knows how to put bytes on the wire
|
||||
* for whichever disk the file lives on.
|
||||
*
|
||||
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
|
||||
* touches the bytes. That matters more here than it does for a download,
|
||||
* because a <video> seeking through an hour of footage issues a long tail
|
||||
* of Range requests; nginx's static handler answers those with 206s on
|
||||
* its own, and drops the Content-Length below in favour of the range it
|
||||
* actually served. Anything else — S3 and friends — gets a short-lived
|
||||
* presigned URL carrying an inline disposition, which the object store
|
||||
* ranges just as well.
|
||||
*
|
||||
* Callers must have established that the mime type is inline-safe first;
|
||||
* PreviewKind is the allowlist, and the reason there is one.
|
||||
*/
|
||||
class InlineFileResponse
|
||||
{
|
||||
public function make(File $file): Response|RedirectResponse
|
||||
{
|
||||
if ($file->disk !== 'files') {
|
||||
$url = Storage::disk($file->disk)->temporaryUrl(
|
||||
$file->path,
|
||||
now()->addHour(),
|
||||
['ResponseContentDisposition' => ContentDisposition::inline($file->original_name)],
|
||||
);
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
||||
'Content-Length' => (string) $file->size,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,15 +8,20 @@ use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\InlineFileResponse;
|
||||
use App\Modules\Files\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\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 +37,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 +73,8 @@ class FileThumbnailController extends Controller
|
||||
private readonly ThumbnailGenerator $thumbnails,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly InlineFileResponse $inline,
|
||||
private readonly Settings $settings,
|
||||
) {}
|
||||
|
||||
public function thumbnail(Request $request, File $file): Response
|
||||
@@ -82,66 +97,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->inline->make($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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,11 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\InlineFileResponse;
|
||||
use App\Modules\Files\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\ThumbnailGenerator;
|
||||
@@ -79,6 +81,7 @@ class PublicGroupsController extends Controller
|
||||
private readonly PublicThemeRegistry $themes,
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly InlineFileResponse $inline,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
||||
@@ -208,6 +211,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
|
||||
@@ -251,6 +261,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->inline->make($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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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.": "お使いのブラウザーではこのファイルを再生できません。ダウンロードしてご覧ください。"
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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ć."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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.": "Ваш браузер не может воспроизвести этот файл. Скачайте его, чтобы посмотреть."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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."
|
||||
}
|
||||
|
||||
+19
-1
@@ -1832,5 +1832,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -11,19 +11,30 @@ use Inertia\Testing\AssertableInertia;
|
||||
|
||||
/**
|
||||
* The application tells administrators how to upgrade, and the answer is
|
||||
* different for a container and for files unpacked onto a server. Getting it
|
||||
* wrong is worse than saying nothing: before this existed, both surfaces
|
||||
* printed `docker compose pull` to everybody, including the people INSTALL.md
|
||||
* was written for, who have no docker to run it with.
|
||||
* different for the published image, for a container somebody builds from a
|
||||
* checkout, and for files unpacked onto a server. Getting it wrong is worse
|
||||
* than saying nothing: before this existed, every surface printed
|
||||
* `docker compose pull` to everybody, including the people INSTALL.md was
|
||||
* written for, who have no docker to run it with — and including the
|
||||
* clone-and-build stacks where those commands succeed without updating
|
||||
* anything (#1661).
|
||||
*/
|
||||
class FakeInstallation extends Installation
|
||||
{
|
||||
public function __construct(private readonly bool $container) {}
|
||||
public function __construct(
|
||||
private readonly bool $container,
|
||||
private readonly bool $source = false,
|
||||
) {}
|
||||
|
||||
protected function inContainer(): bool
|
||||
{
|
||||
return $this->container;
|
||||
}
|
||||
|
||||
protected function builtFromSource(): bool
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
@@ -44,37 +55,74 @@ function anUpdateIsAvailable(): void
|
||||
$settings->set(Setting::LatestReleasePublishedAt, '2026-08-09T00:00:00Z');
|
||||
}
|
||||
|
||||
test('a container reports itself as one', function () {
|
||||
test('a container running the published image reports itself as one', function () {
|
||||
expect((new FakeInstallation(container: true))->kind())->toBe(InstallationKind::Container);
|
||||
});
|
||||
|
||||
test('a container built from a checkout is told apart from the image', function () {
|
||||
// `docker compose pull` on this stack skips every ProjectSend service
|
||||
// and then reports success, so being handed that command is how an
|
||||
// installation stays on the version it is on.
|
||||
expect((new FakeInstallation(container: true, source: true))->kind())->toBe(InstallationKind::ContainerSource);
|
||||
});
|
||||
|
||||
test('a working tree is only asked about inside a container', function () {
|
||||
expect((new FakeInstallation(container: false, source: true))->kind())->toBe(InstallationKind::Manual);
|
||||
});
|
||||
|
||||
test('the image says so itself, whatever is on disk', function () {
|
||||
// Precedence, asserted where it matters: the test suite runs from a
|
||||
// working tree, so without the marker this same object answers
|
||||
// ContainerSource. An operator bind-mounting a checkout into the
|
||||
// published image is still updating it by pulling.
|
||||
$installation = new class extends Installation
|
||||
{
|
||||
protected function inContainer(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
expect($installation->kind())->toBe(InstallationKind::ContainerSource);
|
||||
|
||||
putenv('PROJECTSEND_IMAGE=1');
|
||||
|
||||
try {
|
||||
expect($installation->kind())->toBe(InstallationKind::Container);
|
||||
} finally {
|
||||
putenv('PROJECTSEND_IMAGE');
|
||||
}
|
||||
})->skip(fn () => ! file_exists(base_path('.git')), 'Asserts precedence over a working tree, and there is none here.');
|
||||
|
||||
test('anything else is treated as a manual install', function () {
|
||||
// The safer wrong answer: manual instructions are steps a person reads
|
||||
// and checks, the container command is one they would paste.
|
||||
expect((new FakeInstallation(container: false))->kind())->toBe(InstallationKind::Manual);
|
||||
});
|
||||
|
||||
test('the dashboard tells the frontend which kind of install this is', function (bool $container, string $expected) {
|
||||
app()->instance(Installation::class, new FakeInstallation($container));
|
||||
test('the dashboard tells the frontend which kind of install this is', function (bool $container, bool $source, string $expected) {
|
||||
app()->instance(Installation::class, new FakeInstallation($container, $source));
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->get('/dashboard')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('system.install_kind', $expected));
|
||||
})->with([
|
||||
'container' => [true, 'container'],
|
||||
'manual' => [false, 'manual'],
|
||||
'the published image' => [true, false, 'container'],
|
||||
'built from a checkout' => [true, true, 'container-source'],
|
||||
'manual' => [false, false, 'manual'],
|
||||
]);
|
||||
|
||||
test('the update notice carries the install kind too', function (bool $container, string $expected) {
|
||||
app()->instance(Installation::class, new FakeInstallation($container));
|
||||
test('the update notice carries the install kind too', function (bool $container, bool $source, string $expected) {
|
||||
app()->instance(Installation::class, new FakeInstallation($container, $source));
|
||||
anUpdateIsAvailable();
|
||||
|
||||
$this->actingAs($this->admin)
|
||||
->get('/dashboard')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page->where('update_notice.install_kind', $expected));
|
||||
})->with([
|
||||
'container' => [true, 'container'],
|
||||
'manual' => [false, 'manual'],
|
||||
'the published image' => [true, false, 'container'],
|
||||
'built from a checkout' => [true, true, 'container-source'],
|
||||
'manual' => [false, false, 'manual'],
|
||||
]);
|
||||
|
||||
test('no update means no notice, whatever the install kind', function () {
|
||||
|
||||
@@ -164,6 +164,7 @@ test('staff can enable the public listing and configure its base URL segment', f
|
||||
$this->patch('/system/settings/public-listing', [
|
||||
'public_listing_enabled' => true,
|
||||
'public_listing_slug' => 'shared',
|
||||
'public_listing_preview_enabled' => true,
|
||||
])->assertRedirect();
|
||||
|
||||
expect(app(Settings::class)->get(Setting::PublicListingEnabled))->toBeTrue()
|
||||
|
||||
@@ -18,12 +18,20 @@ use App\Modules\Platform\Settings\Settings;
|
||||
*/
|
||||
class NoticeInstallation extends Installation
|
||||
{
|
||||
public function __construct(private readonly bool $container) {}
|
||||
public function __construct(
|
||||
private readonly bool $container,
|
||||
private readonly bool $source = false,
|
||||
) {}
|
||||
|
||||
protected function inContainer(): bool
|
||||
{
|
||||
return $this->container;
|
||||
}
|
||||
|
||||
protected function builtFromSource(): bool
|
||||
{
|
||||
return $this->source;
|
||||
}
|
||||
}
|
||||
|
||||
function applied(string $version): void
|
||||
@@ -107,14 +115,15 @@ test('it is not gated on the edition', function () {
|
||||
expect(noticeFor($this->admin))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('it names the command this kind of installation can actually run', function (bool $container, string $expected) {
|
||||
app()->instance(Installation::class, new NoticeInstallation($container));
|
||||
test('it names the command this kind of installation can actually run', function (bool $container, bool $source, string $expected) {
|
||||
app()->instance(Installation::class, new NoticeInstallation($container, $source));
|
||||
applied('2.2.0');
|
||||
|
||||
expect(noticeFor($this->admin)['install_kind'])->toBe($expected);
|
||||
})->with([
|
||||
'a container' => [true, InstallationKind::Container->value],
|
||||
'a server somebody administers' => [false, InstallationKind::Manual->value],
|
||||
'a container from the published image' => [true, false, InstallationKind::Container->value],
|
||||
'a container built from a checkout' => [true, true, InstallationKind::ContainerSource->value],
|
||||
'a server somebody administers' => [false, false, InstallationKind::Manual->value],
|
||||
]);
|
||||
|
||||
// The rollback story, asserted end to end: whatever the marker said, the
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* The trusted proxy list has to be read from configuration, not from
|
||||
* bootstrap/app.php.
|
||||
*
|
||||
* The `withMiddleware` closure runs when the HTTP kernel is resolved, and
|
||||
* that happens *before* the dotenv bootstrapper reads .env. Anything set
|
||||
* only in .env is therefore invisible to `env()` in that closure, on every
|
||||
* web request — while artisan, which bootstraps in the other order, reports
|
||||
* the setting as working. That combination is what makes the bug so hard to
|
||||
* see from the outside: the operator sets TRUSTED_PROXIES, a CLI check
|
||||
* agrees it is set, and the web app ignores it anyway.
|
||||
*
|
||||
* With the proxy untrusted, Laravel falls back to the connecting address and
|
||||
* the plain scheme, so behind a TLS-terminating proxy every generated URL
|
||||
* and every redirect comes out as `http://` on a page the browser loaded
|
||||
* over `https://`. The browser then refuses to send the session cookie to
|
||||
* that other origin, the session looks empty, and the write fails with a 419
|
||||
* that reads as "your session expired".
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Route::get('/__proxy-probe', fn () => response()->json([
|
||||
'root' => request()->getSchemeAndHttpHost(),
|
||||
'ip' => request()->ip(),
|
||||
'secure' => request()->isSecure(),
|
||||
]));
|
||||
});
|
||||
|
||||
test('forwarded scheme, host and client address are honoured when the proxy is trusted', function () {
|
||||
config()->set('trustedproxy.proxies', '*');
|
||||
|
||||
$this->get('/__proxy-probe', [
|
||||
'X-Forwarded-Proto' => 'https',
|
||||
'X-Forwarded-Host' => 'files.example.com',
|
||||
'X-Forwarded-For' => '203.0.113.9',
|
||||
])->assertOk()->assertJson([
|
||||
'root' => 'https://files.example.com',
|
||||
'ip' => '203.0.113.9',
|
||||
'secure' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
test('forwarded headers are ignored when no proxy is trusted', function () {
|
||||
config()->set('trustedproxy.proxies', null);
|
||||
|
||||
// Asserted against the forwarded values rather than a literal expected
|
||||
// host: the test environment's APP_URL supplies the host here, and
|
||||
// isSecure() is already true from it, so neither is a signal on its own.
|
||||
// What discriminates is that the proxy's claims are not adopted.
|
||||
$json = $this->get('/__proxy-probe', [
|
||||
'X-Forwarded-Proto' => 'https',
|
||||
'X-Forwarded-Host' => 'files.example.com',
|
||||
'X-Forwarded-For' => '203.0.113.9',
|
||||
])->assertOk()->json();
|
||||
|
||||
expect($json['ip'])->toBe('127.0.0.1')
|
||||
->and($json['root'])->not->toContain('files.example.com');
|
||||
});
|
||||
|
||||
test('the config key the framework falls back to is wired to TRUSTED_PROXIES', function () {
|
||||
// Illuminate\Http\Middleware\TrustProxies reads `trustedproxy.proxies`
|
||||
// when nothing called trustProxies(at:). That is the only path that sees
|
||||
// a value coming from .env, so the key has to stay spelled this way and
|
||||
// has to keep reading that variable. Evaluated directly rather than
|
||||
// through config(), which already holds the value loaded at boot.
|
||||
$_ENV['TRUSTED_PROXIES'] = '10.0.0.1,10.0.0.2';
|
||||
$_SERVER['TRUSTED_PROXIES'] = '10.0.0.1,10.0.0.2';
|
||||
|
||||
try {
|
||||
expect(require base_path('config/trustedproxy.php'))
|
||||
->toBe(['proxies' => '10.0.0.1,10.0.0.2']);
|
||||
} finally {
|
||||
unset($_ENV['TRUSTED_PROXIES'], $_SERVER['TRUSTED_PROXIES']);
|
||||
}
|
||||
});
|
||||
|
||||
test('bootstrap/app.php does not read TRUSTED_PROXIES from the environment', function () {
|
||||
// Reintroducing this read is the regression: it works when the value is
|
||||
// a real environment variable (Docker `environment:`), and silently does
|
||||
// nothing when it comes from .env, which is the documented way to set it.
|
||||
expect(file_get_contents(base_path('bootstrap/app.php')))
|
||||
->not->toContain('TRUSTED_PROXIES');
|
||||
});
|
||||
@@ -209,3 +209,39 @@ function activateExternalStorage(): void
|
||||
])->save();
|
||||
app(ExternalStorageConfigApplier::class)->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* A public file row, with no bytes behind it — enough for any listing or
|
||||
* permission assertion that never opens the file.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user