26 Commits

Author SHA1 Message Date
elibrachas 027e8532d2 Name the 419 as a symptom of an untrusted proxy
DOCKER.md said getting TRUSTED_PROXIES wrong gives you wrong client IPs
or wrong links but never affects whether a request succeeds. It does: it
is what makes the create-your-admin form come back as a 419, which is the
first thing a new install behind a proxy hits and gives no hint about the
cause. Said so, and kept the point that a 502 is a different problem.

INSTALL.md told operators never to run config:cache, and the only reason
it gave was that doing so disabled TRUSTED_PROXIES. That read now goes
through the config layer, so the reason is gone and the section claimed
something untrue. Replaced with the caveat that does apply to a cached
config: re-run it after editing .env.

Also a troubleshooting entry under the symptom people search for — 419 on
login, or being returned to the login screen at random — since the
existing proxy entry only covered rate limiting and the download log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:41:49 -03:00
elibrachas 2a82335e07 Move the shared public-listing helpers to tests/Helpers.php
publicListingFile() and publicListingImageFile() were defined in
PublicGroupsTest.php and used from PublicFilePreviewTest.php too. Pest
declares a test file's functions as ordinary globals, so that works only
once the defining file has been loaded — which under --parallel depends
on how the runner happens to distribute files across processes. Adding
any unrelated test file anywhere in the suite reshuffles that and takes
PublicFilePreviewTest.php down with "Call to undefined function", and
running it on its own with --filter never worked at all.

tests/Helpers.php exists for exactly this and its docblock describes this
failure; these two had just been missed. publicPageProps() stays where it
is, since only one file uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:41:49 -03:00
elibrachas 1aaab1bf66 Read TRUSTED_PROXIES late enough for it to be seen
The value was read with env() inside the withMiddleware closure in
bootstrap/app.php. That closure runs when the HTTP kernel is resolved,
which is before the dotenv bootstrapper reads .env — so on every web
request env() returned null for anything set in .env, and the proxy was
never trusted. It worked when the value came from a real environment
variable, which is why the Docker compose path was fine and the manual
install described in INSTALL.md, where we tell people to put it in .env,
was not. Artisan bootstraps in the other order, so a check from the
command line reported the setting as working the whole time.

Behind a TLS-terminating proxy the consequence is not subtle. Laravel
falls back to the connecting address and the plain scheme, builds every
link and redirect with http:// while the browser is on https://, and
marks the session cookie non-secure. The browser then declines to send
that cookie to what it reads as a different, less secure origin, the
session arrives empty, and the first write fails with a 419 that reads as
"your session expired" — most often on the create-your-admin form, which
is the first thing a new install submits. Afterwards each redirect leaves
and re-enters over the wrong scheme, which is the random bounce back to
the login screen people report as flakiness.

Moved to config/trustedproxy.php, the key the framework's TrustProxies
middleware already falls back to on its own. Config files load after
dotenv, so the value is there whether it comes from .env or from the
environment.

This was also the only env() read outside config/, which means
config:cache is no longer dangerous on this application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:41:31 -03:00
ignacionelson 503676647f Let a split-user host serve downloads
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 those are different users — cPanel and Plesk
commonly arrange it that way — it cannot: uploads land 0600 inside a 0700
directory, and traversing 0700 means being its owner. Nothing else on the
site shows a symptom. Uploading works, the library lists everything, and
only downloads fail, as ERR_INVALID_RESPONSE in the browser and
`open() ... failed (13: Permission denied)` in the web server's log.

FILES_WEB_SERVER_READABLE writes uploads 0644/0755 instead. Opt-in and
spread into the disk configuration rather than switched by a ternary, so
an install that does not set it keeps byte-for-byte the configuration it
had: the relaxed modes are readable by every account on the machine,
which is the wrong trade wherever the web server and PHP are one user, as
in the image and on most self-administered servers.

The two halves are not enforced alike, which is the part worth knowing.
`visibility` has Flysystem chmod each file after writing it, so 0644
holds under any umask. A directory is created by mkdir(), which masks its
mode argument, so 0755 is a ceiling: a pool at umask 0077 still produces
0700 and still cannot be traversed. That cannot be fixed from config, so
INSTALL.md carries it — how to tell the two users apart, the one-time
chmod for files already on disk, and the pool setting for the umask.
FilePermissionsTest asserts all three modes, umask cases included, since
the asymmetry is invisible from the configuration.

Reported by @denkfabrik-li (#1668), who diagnosed it and verified the
remedy on the affected host.
2026-08-21 16:34:14 -03:00
ignacionelson 5a7c9938dd Work properly behind a reverse proxy
Three findings from one report of intermittent 502s behind Nginx Proxy
Manager, all of them ours.

Stop sending the Link: preload header. AddLinkHeadersForPreloadedAssets
copied every Vite preload into a response header, duplicating tags the
document already carried in its head — twenty on the login page. nginx
buffers a response's headers into a single block defaulting to 4 KB, so
/files, at 6060 bytes 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, which is why it read as intermittent rather than
as a header that is always too big: the login screen fitted, the
application did not. Removing it takes /files to 1247 bytes and
/dashboard from 4544 to 1247. Nothing is lost — the browser reads the
tags in the document, and we send no 103 Early Hints.

Send nginx's logs to the container's streams. supervisord captures what
each program writes to its own stdout, but nginx opens the files named in
the package's nginx.conf as soon as it reads its config, so access and
error logs went to /var/log/nginx/ inside the container. That is where
the reason for every 502 and every 403 was written, and docker logs never
showed it — so a proxy problem presented as no logs on either side, which
is exactly how it was reported.

Document the thing neither guide covered. DOCKER.md had no reverse-proxy
section at all: no mention of proxies, of 502s, or of TRUSTED_PROXIES,
which until now was explained only in a comment in the compose example.
It gains one, including that TRUSTED_PROXIES cannot cause a 502 and is
the wrong place to dig. INSTALL.md's nginx-in-front-of-Apache path gains
the proxy_* buffer settings its fastcgi_* equivalents already had.

Reported by @denkfabrik-li (#1664), who traced it to the middleware
independently, and separately by a user running Nginx Proxy Manager who
found the too-big-header line in the proxy's own log.
2026-08-21 15:19:44 -03:00
Ignacio Nelson d1d1999216 Merge pull request #1671 from projectsend/feature/file-downloads-previews-tab
A Downloads & previews tab on the file page
2026-08-21 15:10:13 -03:00
ignacionelson 51eea30dda Answer "did they ever actually get it?" from the file itself
The two things staff most often want to know about a file — who
downloaded it, who looked at it — were answerable only by reading the
whole activity log past everything else that had happened to it, or by
going back to the library list for the details panel.

The file's own page now has a Downloads & previews tab: the twenty most
recent times it was taken or looked at, each with who did it and the
address it went to, over a running count of both. Below them, two
buttons open the file's full history already filtered — one to every
download, one to every preview — so the narrow question is one click
and the whole log is still one click further.

Which filter value stands for "every download" is decided server-side
and travels with the payload, because it is a fact about the log's
vocabulary: downloads are three actions and share a group, previews are
one action and are filtered by name. The history page now also keeps
whatever filter it was sent with visible in its dropdown even at a
count of zero, so a button cannot land somebody on an empty table above
a select that has gone blank.
2026-08-21 15:04:11 -03:00
Ignacio Nelson c18f2f0f73 Merge pull request #1670 from projectsend/fix/1661-update-instructions-for-source-builds
Tell a clone-and-build install to rebuild, not to pull
2026-08-21 14:55:19 -03:00
ignacionelson 3d6089a501 Docs: clear up two contradictions in the migration and Docker guides
Step 2 of the v1 migration guide said Direct hardlinks your files instead of
copying them. It does not: copy is the default in both the command and the
screen, and hardlink is one of the four strategies you choose in step 3a. Say
that where the choice is first mentioned.

DOCKER.md's "Move the data you already have" reads like it is about the data in
a Legacy install. It is about relocating an already-running install's named
volumes onto the host paths chosen a step earlier, which is why it opens by
telling a new installation to skip it. Retitle it and spell out that a new
install waiting for a v1 migration skips it too — that data arrives later,
through the migration tool, and the install has to be empty when it does.
2026-08-21 14:39:24 -03:00
ignacionelson 8f12c83d21 Tell a clone-and-build install to rebuild, not to pull
ProjectSend prints the update instructions for the way this server was
installed, and it knew two answers where it needed three: anything inside
a container was handed `docker compose pull && docker compose up -d`. On
the Compose stack that builds from a checkout there is no image behind
those containers, so `pull` skips every ProjectSend service and `up -d`
then finds them all current — the update reports success, changes
nothing, and the dashboard goes on offering the same release. Reported by
@mueller7382, who stayed on 2.0.0 that way while 2.1.0 was out (#1661).

Those installations are now their own kind, told to `git pull` and
rebuild, with the two steps a checkout needs that an image does not: its
dependencies and its compiled frontend live outside git, so a release
that moved either leaves them stale.

Two signals decide it, in that order. The published image now declares
itself with PROJECTSEND_IMAGE, which is the only evidence an operator
bind-mounting over /var/www/html can neither hide nor forge; failing that
— images published before this — a working tree in the install directory,
which the image never has and the repository's own stack always does.
getenv() rather than env(), because a cached configuration makes env()
outside a config file return null, and the answer would flip silently on
exactly the installs most likely to have cached it.

The stale-code banner keeps treating both container kinds alike: what
clears it is recreating the container, whichever way its image was built.

The changelog also credits the reporter of #1663, which was missed when
that entry was written.
2026-08-21 14:35:49 -03:00
Ignacio Nelson 11e6876826 Merge pull request #1669 from projectsend/feature/preview-video-audio-pdf
Preview video, audio and PDF, not only images
2026-08-21 14:21:31 -03:00
ignacionelson 88c182cf3b Preview video, audio and PDF, not only images
v1 could preview four kinds of file in a modal — images, video, audio and
PDF. v2 previewed only images, and not by decision: preview shipped as part
of the image *thumbnail* work (1c68aa1), so "previewable" quietly became a
synonym for "GD can decode it". FileThumbnailController::preview() gated on
ThumbnailGenerator::SUPPORTED_MIME_TYPES, the frontend mirrored the same
four types, and the dialog was a hardcoded <img>.

Rather than widen that list — it drives pathFor(), extensionFor(),
generate() and FileDiskCleanup, and a video reaching getimagesize() is a
500 — this separates the two questions. PreviewKind now answers "may these
bytes be served inline, and what element renders them?", while
ThumbnailGenerator keeps answering the narrower "can this app decode it
itself?", which is what renditions, the cache and the watermark hook
actually depend on. Image delegates to it so the two cannot drift.

The allowlist stays a security boundary: mime_type is sniffed from the
bytes, so text/html and image/svg+xml remain excluded, and PreviewKind is
deliberately narrower than "formats a browser might cope with" — no
quicktime, avi or matroska, because an embedded player for those shows a
black rectangle. Those still download exactly as before.

docs/security-audit-2026-08-05.md finding 1 recorded that adding
application/pdf "should be a conscious decision". This is that decision,
and three things were measured rather than assumed:

- An <iframe sandbox> cannot be used. Chrome refuses to run its PDF viewer
  in a sandboxed frame at all (ERR_BLOCKED_BY_CLIENT, with or without
  allow-same-origin) — the attribute removes the feature, it does not
  harden it.
- nginx's `Content-Security-Policy: sandbox; default-src 'none'` on
  /protected-files/ does work (a <video> frame lands in an opaque origin),
  but Chrome exempts its PDF viewer from it, so it is not what protects
  the PDF case.
- What does is the allowlist plus the browser's own PDF sandbox, where PDF
  JavaScript has no DOM and no cookies.

Range requests were verified end to end: 206 with a correct Content-Range,
a byte-perfect file reassembled from three ranges, and a real browser
seeking to 10s of a 20s clip. nginx drops the upstream Content-Length on
the X-Accel path, so there is no collision.

Two settings, both defaulting on so no installation loses what it has:
clients_can_preview_files and public_listing_preview_enabled. Staff are
never gated. The anonymous side needed a route of its own — there was no
public preview endpoint — with its own throttle bucket, since a bare
throttle: shares one counter across that whole block.

A preview now logs at most one FilePreviewed per viewer per file per five
minutes: a <video> turns one deliberate act into a long tail of Range
requests, and a row each would bury the log.

Also fixes a layout bug the tests could never catch. A portal file row was
flex justify-between with three children — name, comment trigger, download
— so the middle one settled wherever the name happened to end and the
comment icon sat at a different place on every row. The name block now
takes the slack and every action lives in one trailing group, with the
comment trigger in a fixed-width slot so the icons form a column. And
because half the previewable files have no thumbnail to click — a PDF, an
mp3 and an mp4 all render as a generic icon — every row gains an explicit
PreviewAction beside DownloadAction, matching whatever style that theme
gives its download control.
2026-08-21 14:14:23 -03:00
Ignacio Nelson 30f66cff2b Merge pull request #1667 from projectsend/feature/file-activity-tab-and-download-filters
Activity tab on a file's page, filters on both history screens
2026-08-21 13:33:44 -03:00
ignacionelson cca3d9c314 Group only the downloads, which are the actions that need it
The grouped filter had a second member, "All previews", built on a
public-preview action that does not exist: previewing is recorded one
way today, so its own option already answers "who previewed this?" in
full. Static analysis caught the reference; the group would have been
unreachable even if it had compiled, since a group with a single
present member is deliberately not offered.

Previews get a group here the day a second way to preview a file is
recorded separately, and the test now pins the single-member case on a
file whose log holds one flavour of download.
2026-08-21 13:12:26 -03:00
ignacionelson 7d1903f9db Let the download history be searched
The installation-wide download history listed every download newest
first and offered nothing else, so "did that client ever actually
download the contract?" meant paging through everything that had
happened since.

It now filters by file name, by who downloaded it, and by date range,
in the same toolbar every other list uses: the query string carries the
filters, so a narrowed view is a link somebody can be sent.

Both names are matched against what the entry snapshotted rather than
through a join, so a file or an account deleted since is still findable
by the name it went out under — often exactly what this page is being
asked. The filters narrow the viewer's already-scoped query rather than
replacing it, so a client-scoped staffer cannot search their way to a
download of a file outside their library.
2026-08-21 12:39:22 -03:00
ignacionelson 6b76c11192 Answer "what happened to this file?" on the file's own page
A file's history was only reachable from the library list, through the
details panel's Activity tab — so anyone who arrived at the file from a
link, a search or a notification had to go back and find the row they
came from to ask what had happened to it.

The file's own page now carries an Activity tab of its own, next to
General and Sharing: the twenty most recent entries, fetched only if the
tab is opened, and a link to the full history. It is behind the same
view_actions_log permission as everywhere else.

That full history is now filterable, which is the point of sending
somebody to it. The action list is built from the file's own log rather
than from the eighty-odd actions the software can record — all but a
handful of which can never apply to a file — and each option carries its
count. Downloads are three separate actions on purpose (a signed-in
recipient, a public link, the public group listing), so "All downloads"
asks that question once instead of three times; the group only appears
when the file's log actually holds more than one of its members.
Narrowing by who acted and by date range works the same as it does on
the main activity log, the reader's own calendar day included.
2026-08-21 12:39:16 -03:00
Ignacio Nelson 283c79bcd6 Merge pull request #1666 from projectsend/fix/1663-open-basedir-container-probe
Stop container detection from taking the dashboard down on shared hosting
2026-08-21 12:09:49 -03:00
ignacionelson 1b6513f0fb Stop container detection from taking the dashboard down on shared hosting
Deciding which update instructions to print starts with asking whether we
are running in a container, and that question is asked by looking for the
file a container runtime leaves in the root of the filesystem. Shared
hosting confines PHP to the webspace with open_basedir, where looking
outside it is a warning rather than a false — and the framework's error
handler turns warnings into exceptions, so the probe threw instead of
answering. The dashboard is the one page that asks, so it returned a 500
while everything else worked (#1663).

Suppress both probes. A host that keeps PHP inside a single directory is
not our published image, so false is the right answer as well as the
surviving one, and it lands on the manual instructions that shared
hosting wants anyway. Checking ini_get('open_basedir') instead would get
a hardened container wrong in the other direction, handing the manual
sequence to someone whose files are inside an image.

The dashboard was only the first symptom. updateNotice() reaches the same
call on every Inertia response once a newer release exists, and
RunningCodeState reaches it whenever the applied and running versions
disagree — so the next release, or the host's next update attempt, would
have taken every page rather than one.
2026-08-21 12:03:23 -03:00
Ignacio Nelson cb67a15e81 Merge pull request #1660 from projectsend/docs/quickstart-setup-screen-is-the-default
Let the setup screen be what the quickstart actually shows
2026-08-19 21:54:57 -03:00
ignacionelson 1c62036ed2 Let the setup screen be what the quickstart actually shows
The example compose file shipped with ADMIN_NAME/ADMIN_EMAIL/ADMIN_PASSWORD
filled in, so the entrypoint created the first administrator and nobody ever
reached the setup screen the README, the Docker Hub page and the website all
promise. Someone who followed the instructions literally — edit APP_URL and
the passwords — also ended up with a publicly reachable administrator on
admin@example.com with a password printed in a public file.

Comment the three variables out. Unattended provisioning still works for
anyone who wants it, it is just opt-in now, and the first thing a new install
shows is the setup screen again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:50:00 -03:00
Ignacio Nelson 5e23474e8b Merge pull request #1659 from projectsend/docs/docker-guide-follows-the-image
Point the Docker guide at the image people actually run
2026-08-19 15:39:27 -03:00
ignacionelson 202a1d7ad5 Point the Docker guide at the image people actually run
#1658 reports that app, web, db and redis have no restart policy, so the
stack does not come back after a reboot. True, and fixed here — but the
file it is about is the development stack, and the production example has
had the policy all along. The reporter got there by following DOCKER.md,
which is #1627 again: 745f24c fixed the README's pointer and left this
page's body describing a stack no user should be running.

Against an image install almost every procedure on it was wrong. It said
uploads live in `storage/app/files/` "in the project directory" and `.env`
beside it — both are on the storage volume, and the entrypoint generates
that `.env` itself. Its compose.override.yaml recipe bind-mounted into
app, web, worker and scheduler, which are one container under supervisord
in the image, at a path one level too deep to carry APP_KEY. It told
people to chown a directory the entrypoint already chowns, to rsync from a
host path that does not exist, and to `git pull` to upgrade. Its mysqldump
read ${DB_ROOT_PASSWORD} from a .env an image install does not have, so
the documented backup silently fell back to `root` and failed. Docker Hub
links this page as "where your data lives, backups, moving to another
server".

So it is now about the image, and shorter for it: two volumes instead of
three loose things, the key explained where people actually lose it, no
override file because the compose file is the operator's own, and a
reboot section — the answer to the issue for anyone who wrote their own
compose. The clone-and-build stack keeps one pointer to CONTRIBUTING.md,
which has been the correct place for it since #1627.

The Docker Hub page keeps the two facts a reader who never leaves it
needs and hands off the procedures, so the drift that caused this has one
copy to go wrong instead of two.

Adminer and mailpit stay without a restart policy on purpose: those come
up for a session, not for the life of the machine.

Refs #1658

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:21:12 -03:00
Ignacio Nelson 8b5d480670 Merge pull request #1657 from projectsend/docs/dockerhub-screenshot
Show the dashboard on the Docker Hub page
2026-08-19 12:06:43 -03:00
ignacionelson e279e83fd0 Show the dashboard on the Docker Hub page
The page described the application in three paragraphs and then went
straight to a compose file. Somebody deciding whether to pull it had no
idea what it looks like — and for a thing whose whole job is a screen your
clients use, that is the question they are actually asking.

The dashboard, after the paragraphs that say what this is and before the
quick start, which is the point in the page where a reader has decided they
are interested and not yet decided to spend ten minutes.

The same image the README uses, and the same alt text, which was written to
describe the screen rather than to name the file. One screenshot, not
three: the README has the other two and the caption says so, and a registry
description that scrolls past its own install instructions has stopped
being an install page.

Absolute raw.githubusercontent URL, because a repository-relative path
resolves to nothing on hub.docker.com — the same reason the badges point
there. .github/ is stripped from the release artifact, which does not
matter here: this file is pasted into a description, not shipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:47:40 -03:00
Ignacio Nelson 318550f866 Merge pull request #1656 from projectsend/docs/dockerhub-badges
Give the Docker Hub page a masthead
2026-08-18 22:40:39 -03:00
ignacionelson 9c991f495d Give the Docker Hub page a masthead
The page opened on a bare H1, which on a registry listing reads as an
unfinished description rather than a product. Docker Hub's own search
results, and every well-kept image beside ours, lead with a mark and a row
of badges — and the badges are not decoration there: version, size and
where to get help are the questions somebody has before they decide to
pull.

Six of them, each answering one of those: the current release, pull count,
compressed image size, stars, Discord, and the licence. Four are live
values rather than static text, so the page stops being something anybody
has to remember to update — the release badge already reads v2.1.0, and
image size already reads 78.4 MiB.

Pure markdown, no HTML. Docker Hub sanitises HTML out of descriptions, so
the centred layouts people write for GitHub silently collapse there; the
badges are consecutive markdown links, which is what actually renders as a
row. Each link carries a title, so hovering says what it is for.

The mark is apple-touch-icon.png and not the wordmark or the favicon, for a
reason worth writing down: favicon.svg has a viewBox and no width, so it
has no intrinsic size and renders at whatever the container offers — which
on a wide column is enormous. The PNG is 180x180 and renders as a mark.
That also matches README.md, which puts a small icon above the title
rather than a banner.

Colours are README.md's, not the ones on the page this was modelled after:
3b5bdb for the project, 0b7285 for the Docker facts, and Discord's own
brand colour where the badge is a Discord badge. The point is that the two
front doors look like the same project.

Every URL checked: twelve, all 200, and the four dynamic badges verified to
render real values rather than shields.io's "invalid".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:08:26 -03:00
82 changed files with 3524 additions and 462 deletions
+9
View File
@@ -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
+60
View File
@@ -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
+232 -92
View File
@@ -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
View File
@@ -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
+5 -3
View File
@@ -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.
---
+2 -1
View File
@@ -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
+3 -3
View File
@@ -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
+3
View File
@@ -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',
+1 -1
View File
@@ -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),
]);
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Modules\Files\Preview;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
/**
* What kind of inline view, if any, a stored file gets — the single
* answer to "may these bytes be served inline, and what element renders
* them?", shared by FileThumbnailController::preview (signed in) and
* PublicGroupsController::preview (anonymous).
*
* SECURITY: this is an allowlist, and it is the boundary. Preview serves
* a file's own bytes inline, from this app's origin, labelled with the
* mime type stored on the row — so anything a browser executes script
* from would be same-origin script execution with the viewer's session.
* Never add text/html, image/svg+xml, or any other document type, and
* never derive this list from Setting::AllowedUploadExtensions: that
* setting matches on the *extension* while mime_type is sniffed from the
* *bytes* (ChunkedUploadsController::complete), so a .txt holding HTML is
* stored as text/html and would arrive here looking allowed.
*
* Deliberately narrower than "files a browser might cope with": every
* type below is one every current browser decodes natively. Formats like
* video/quicktime, video/x-msvideo and video/x-matroska are left out
* because an embedded player for them shows a black rectangle. They
* upload and download exactly as before — only the inline view is
* withheld.
*
* Distinct from ThumbnailGenerator::SUPPORTED_MIME_TYPES, which answers a
* narrower question: which types this app can *decode and re-encode*
* itself, and therefore has renditions, a cache and a watermark hook for.
* Image delegates to it rather than restating it, so the two cannot drift.
*/
enum PreviewKind: string
{
/** Rendered with <img>; the only kind with thumbnails and renditions. */
case Image = 'image';
/** Rendered with <video controls>. */
case Video = 'video';
/** Rendered with <audio controls>. */
case Audio = 'audio';
/** Rendered in a sandboxed <iframe>, by the browser's own viewer. */
case Pdf = 'pdf';
/** @var list<string> */
private const VIDEO_MIME_TYPES = [
'video/mp4',
'video/webm',
'video/ogg',
];
/**
* More spellings than there are formats: the mime type is whatever
* finfo made of the bytes, and it is not consistent across systems —
* a .wav is audio/x-wav on one box and audio/vnd.wave on another, and
* an .m4a can come back as audio/mp4 or audio/x-m4a.
*
* @var list<string>
*/
private const AUDIO_MIME_TYPES = [
'audio/mpeg',
'audio/wav',
'audio/x-wav',
'audio/vnd.wave',
'audio/ogg',
'audio/webm',
'audio/mp4',
'audio/x-m4a',
'audio/aac',
'audio/flac',
'audio/x-flac',
];
public static function forMime(string $mimeType): ?self
{
if (ThumbnailGenerator::supports($mimeType)) {
return self::Image;
}
if (in_array($mimeType, self::VIDEO_MIME_TYPES, true)) {
return self::Video;
}
if (in_array($mimeType, self::AUDIO_MIME_TYPES, true)) {
return self::Audio;
}
if ($mimeType === 'application/pdf') {
return self::Pdf;
}
return null;
}
public static function supports(string $mimeType): bool
{
return self::forMime($mimeType) !== null;
}
}
@@ -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';
}
+21
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
+32
View File
@@ -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
+12
View File
@@ -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'),
];
+21
View File
@@ -85,6 +85,19 @@ RUN delgroup www-data 2>/dev/null || true \
RUN sed -i 's/^user nginx;/user www-data;/' /etc/nginx/nginx.conf \
&& chown -R www-data:www-data /var/lib/nginx
# nginx is the one process here that does not log where supervisord can
# see it. supervisord captures what a program writes to its own stdout,
# but nginx opens the files named in the package's nginx.conf the moment
# it reads its config, so its access and error logs went to
# /var/log/nginx/ inside the container — invisible to `docker logs`, which
# is the only place anybody looks. That is where the reason for every 502
# and every 403 was being written, so a reverse-proxy problem presented as
# no logs at all on either side. Point both at the container's own
# streams, which is what the rest of this image already does.
RUN sed -i -e 's#^error_log .*#error_log /dev/stderr warn;#' \
-e 's#^\(\s*\)access_log .*#\1access_log /dev/stdout main;#' \
/etc/nginx/nginx.conf
COPY docker/production/php.ini /usr/local/etc/php/conf.d/projectsend.ini
COPY docker/production/www-pool.conf /usr/local/etc/php-fpm.d/zz-www-pool.conf
COPY docker/production/nginx.conf /etc/nginx/http.d/default.conf
@@ -126,6 +139,14 @@ RUN mkdir -p storage/app/files storage/framework/cache storage/framework/session
# session and makes every encrypted column unreadable.
VOLUME ["/var/www/html/storage"]
# How the application knows it is this image and not a container somebody
# built from a checkout — the two upgrade completely differently, and it is
# the application that prints the instructions. See Installation. It has to
# be set here rather than inferred at runtime: an operator who bind-mounts
# over /var/www/html can hide any file the image ships as its evidence, and
# an environment variable survives that.
ENV PROJECTSEND_IMAGE=1
EXPOSE 80
# Laravel's health route (bootstrap/app.php: health: '/up'). Hitting it
+8 -7
View File
@@ -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.
+31 -21
View File
@@ -1,3 +1,12 @@
[![ProjectSend](https://raw.githubusercontent.com/projectsend/projectsend/main/public/apple-touch-icon.png)](https://github.com/projectsend/projectsend)
[![Release](https://img.shields.io/github/v/release/projectsend/projectsend?color=3b5bdb&label=release)](https://github.com/projectsend/projectsend/releases "what is in each release, and the downloadable zip")
[![Docker pulls](https://img.shields.io/docker/pulls/projectsend/projectsend?color=0b7285)](https://hub.docker.com/r/projectsend/projectsend/tags "every published tag")
[![Image size](https://img.shields.io/docker/image-size/projectsend/projectsend/latest?color=0b7285&label=image)](https://hub.docker.com/r/projectsend/projectsend/tags "compressed size of the latest tag")
[![GitHub stars](https://img.shields.io/github/stars/projectsend/projectsend?color=3b5bdb)](https://github.com/projectsend/projectsend "source, issues and full documentation")
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/VT9n6cyvXT "release news, and help when something is not behaving")
[![License](https://img.shields.io/badge/license-GPLv2%2B-3b5bdb)](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: counters for files, clients and groups, the clients using the most storage against their quotas, a month of uploads and downloads as a line chart, and recent activity](https://raw.githubusercontent.com/projectsend/projectsend/main/.github/screenshots/dashboard.png)
*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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+113 -5
View File
@@ -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">
+52
View File
@@ -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;
}
+65 -3
View File
@@ -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>
)}
+87 -3
View File
@@ -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">
+189 -13
View File
@@ -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.
+48 -14
View File
@@ -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">
+38 -12
View File
@@ -2,12 +2,14 @@ import { Head } from '@inertiajs/react';
import { CommentsShellDrive } from '@/components/comments/shells/comments-shell-drive';
import { DownloadAction } from '@/components/download-action';
import { PreviewAction } from '@/components/preview-action';
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
import { type VersionLinks } from '@/components/files/version-badge';
import { VersionNotice } from '@/components/files/version-notice';
import PublicLayoutDrive from '@/layouts/public-layout-drive';
import { driveFileIcon } from '@/lib/drive-file-icon';
import { formatBytes } from '@/lib/format-bytes';
import { FilePreviewDialog } from '@/components/file-preview-dialog';
import { isThumbnailable } from '@/lib/thumbnails';
import { type DownloadLimit } from '@/types/portal';
@@ -23,6 +25,12 @@ interface PublicFileShowProps {
categories: CategoryTag[];
};
thumbnail_url: string | null;
/**
* Null unless this visitor is actually offered a preview — the
* server has already weighed the setting, the file's type and its
* download limit, so a theme never re-derives any of them.
*/
preview_url: string | null;
download_url: string;
download_limit: DownloadLimit;
comments_enabled: boolean;
@@ -32,6 +40,7 @@ interface PublicFileShowProps {
export default function PublicFileShowDrive({
file,
thumbnail_url,
preview_url,
download_url,
download_limit,
comments_enabled,
@@ -45,11 +54,18 @@ export default function PublicFileShowDrive({
<div className="mx-auto flex max-w-lg flex-col items-center gap-6">
<div className="flex w-full items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-900">
{thumbnail_url && isThumbnailable(file.mime_type) ? (
<img src={thumbnail_url} alt="" className="max-h-80 w-full object-contain" />
) : (
<Icon className={`m-16 size-16 ${color}`} strokeWidth={1.5} />
)}
<FilePreviewDialog
previewUrl={preview_url}
mimeType={file.mime_type}
fileName={file.original_name}
className="flex w-full items-center justify-center"
>
{thumbnail_url && isThumbnailable(file.mime_type) ? (
<img src={thumbnail_url} alt="" className="max-h-80 w-full object-contain" />
) : (
<Icon className={`m-16 size-16 ${color}`} strokeWidth={1.5} />
)}
</FilePreviewDialog>
</div>
{file.description && <p className="text-center text-sm text-neutral-500">{file.description}</p>}
@@ -58,13 +74,23 @@ export default function PublicFileShowDrive({
<VersionNotice version={file.version} className="w-full" />
<DownloadAction
href={download_url}
limit={download_limit}
className="bg-blue-600 hover:bg-blue-700"
variant="default"
size="default"
/>
<div className="flex items-center gap-2">
<PreviewAction
previewUrl={preview_url}
mimeType={file.mime_type}
fileName={file.original_name}
className="border-blue-200 text-blue-700 hover:text-blue-800 dark:border-blue-900 dark:text-blue-400"
variant="outline"
size="default"
/>
<DownloadAction
href={download_url}
limit={download_limit}
className="bg-blue-600 hover:bg-blue-700"
variant="default"
size="default"
/>
</div>
{comments_enabled && (
<div className="w-full">
@@ -3,10 +3,12 @@ import { File as FileIcon } from 'lucide-react';
import { CommentsShellGallery } from '@/components/comments/shells/comments-shell-gallery';
import { DownloadAction } from '@/components/download-action';
import { PreviewAction } from '@/components/preview-action';
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
import { type VersionLinks } from '@/components/files/version-badge';
import { VersionNotice } from '@/components/files/version-notice';
import { formatBytes } from '@/lib/format-bytes';
import { FilePreviewDialog } from '@/components/file-preview-dialog';
import { isThumbnailable } from '@/lib/thumbnails';
import { type DownloadLimit } from '@/types/portal';
import PublicLayoutGallery from '../../../../layouts/public-layout-gallery';
@@ -23,6 +25,12 @@ interface PublicFileShowProps {
categories: CategoryTag[];
};
thumbnail_url: string | null;
/**
* Null unless this visitor is actually offered a preview — the
* server has already weighed the setting, the file's type and its
* download limit, so a theme never re-derives any of them.
*/
preview_url: string | null;
download_url: string;
download_limit: DownloadLimit;
comments_enabled: boolean;
@@ -32,6 +40,7 @@ interface PublicFileShowProps {
export default function PublicFileShowGallery({
file,
thumbnail_url,
preview_url,
download_url,
download_limit,
comments_enabled,
@@ -44,11 +53,18 @@ export default function PublicFileShowGallery({
<div className="mx-auto flex max-w-2xl flex-col items-center gap-6">
<div className="bg-muted flex w-full items-center justify-center overflow-hidden rounded-xl border shadow-lg">
{thumbnail_url && isThumbnailable(file.mime_type) ? (
<img src={thumbnail_url} alt="" className="max-h-[32rem] w-full object-contain" />
) : (
<FileIcon className="text-muted-foreground m-24 size-20" strokeWidth={1.25} />
)}
<FilePreviewDialog
previewUrl={preview_url}
mimeType={file.mime_type}
fileName={file.original_name}
className="flex w-full items-center justify-center"
>
{thumbnail_url && isThumbnailable(file.mime_type) ? (
<img src={thumbnail_url} alt="" className="max-h-[32rem] w-full object-contain" />
) : (
<FileIcon className="text-muted-foreground m-24 size-20" strokeWidth={1.25} />
)}
</FilePreviewDialog>
</div>
{file.description && <p className="text-muted-foreground text-center text-sm">{file.description}</p>}
@@ -57,7 +73,16 @@ export default function PublicFileShowGallery({
<VersionNotice version={file.version} className="w-full" />
<DownloadAction href={download_url} limit={download_limit} size="lg" />
<div className="flex items-center gap-2">
<PreviewAction
previewUrl={preview_url}
mimeType={file.mime_type}
fileName={file.original_name}
variant="outline"
size="lg"
/>
<DownloadAction href={download_url} limit={download_limit} size="lg" />
</div>
{comments_enabled && (
<div className="w-full">
@@ -19,6 +19,7 @@ interface ClientSettingsProps {
clients_can_select_group: string;
clients_membership_deny_cooldown_days: number;
default_client_storage_quota_mb: number;
clients_can_preview_files: boolean;
groups: { id: number; name: string }[];
}
@@ -29,6 +30,7 @@ export default function ClientSettings({
clients_can_select_group,
clients_membership_deny_cooldown_days,
default_client_storage_quota_mb,
clients_can_preview_files,
groups,
}: ClientSettingsProps) {
const { t } = useTranslation();
@@ -45,6 +47,7 @@ export default function ClientSettings({
clients_can_select_group: clients_can_select_group,
clients_membership_deny_cooldown_days: String(clients_membership_deny_cooldown_days),
default_client_storage_quota_mb: String(default_client_storage_quota_mb),
clients_can_preview_files: clients_can_preview_files,
});
const submit: FormEventHandler = (e) => {
@@ -172,6 +175,25 @@ export default function ClientSettings({
<InputError message={errors.default_client_storage_quota_mb} />
</div>
<div className="flex items-start gap-2">
<Checkbox
id="clients_can_preview_files"
checked={data.clients_can_preview_files}
onCheckedChange={(checked) => setData('clients_can_preview_files', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="clients_can_preview_files" className="font-normal">
{t('Clients can preview files')}
</Label>
<p className="text-muted-foreground text-sm">
{t(
'Lets clients open an image, video, audio file or PDF in the portal instead of only downloading it. Turn it off to make downloading the only way to see a file. Staff can always preview.',
)}
</p>
</div>
</div>
<InputError message={errors.clients_can_preview_files} />
<SaveButton processing={processing} recentlySuccessful={recentlySuccessful} />
</form>
</div>
@@ -16,9 +16,14 @@ import AppLayout from '@/layouts/app-layout';
interface PublicListingSettingsProps {
public_listing_enabled: boolean;
public_listing_slug: string;
public_listing_preview_enabled: boolean;
}
export default function PublicListingSettings({ public_listing_enabled, public_listing_slug }: PublicListingSettingsProps) {
export default function PublicListingSettings({
public_listing_enabled,
public_listing_slug,
public_listing_preview_enabled,
}: PublicListingSettingsProps) {
const { t } = useTranslation();
const breadcrumbs: BreadcrumbItem[] = [
@@ -29,6 +34,7 @@ export default function PublicListingSettings({ public_listing_enabled, public_l
const { data, setData, patch, errors, processing, recentlySuccessful } = useForm({
public_listing_enabled: public_listing_enabled,
public_listing_slug: public_listing_slug,
public_listing_preview_enabled: public_listing_preview_enabled,
});
const submit: FormEventHandler = (e) => {
@@ -116,6 +122,31 @@ export default function PublicListingSettings({ public_listing_enabled, public_l
</div>
)}
{/* Outside the directory switch above on purpose: a public
group's page works whether or not the directory is
enabled, so its preview has to be configurable
independently of it. */}
<div className="grid gap-2">
<div className="flex items-start gap-2">
<Checkbox
id="public_listing_preview_enabled"
checked={data.public_listing_preview_enabled}
onCheckedChange={(checked) => setData('public_listing_preview_enabled', checked === true)}
/>
<div className="grid gap-1">
<Label htmlFor="public_listing_preview_enabled" className="font-normal">
{t('Let visitors preview public files')}
</Label>
<p className="text-muted-foreground text-sm">
{t(
'Anyone with the link can open an image, video, audio file or PDF in the browser instead of downloading it. Turn it off to make downloading the only way to see a public file.',
)}
</p>
</div>
</div>
<InputError message={errors.public_listing_preview_enabled} />
</div>
<SaveButton processing={processing} recentlySuccessful={recentlySuccessful} />
</form>
</div>
+9
View File
@@ -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;
}
/**
+6
View File
@@ -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.
+66
View File
@@ -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();
+25
View File
@@ -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');
});
+132
View File
@@ -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));
});
+19 -1
View File
@@ -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));
});
-34
View File
@@ -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');
+62 -14
View File
@@ -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 () {
+1
View File
@@ -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()
+14 -5
View File
@@ -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');
});
+36
View File
@@ -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;
}