20 Commits

Author SHA1 Message Date
ignacionelson 0671848bfa Read settings written before the columns they name existed
Reported by @apps3000 in #1770. Upgrading a container from 2.0 or 2.1
with external storage configured restart-loops, and says the database is
unreachable while the database is fine.

A row hydrated from the database does not get the model's column
defaults — only a new model does. So a row written before
external_storage_settings.provider existed reads that column as null,
and the enum match in isConfigured() throws UnhandledMatchError.

That would be a small bug anywhere else. It is not here, because
PlatformServiceProvider::boot() reads these settings on every process
boot, and boot happens before `artisan migrate` runs. During an upgrade
the code is new and the schema is still old, so every artisan command in
that window dies — including `projectsend:update`, the one that would
have added the column. Reordering the entrypoint or using a lighter
readiness probe does not help for that reason; the crash is in the
bootstrap, not in the probe.

current() now applies the model's declared defaults to any column the
hydrated row does not have. That closes the window for every column with
a default rather than for the one where it was found, and goes inert the
moment the schema is current. The match in isConfigured() is left total
on purpose: a default arm would swallow a real unhandled case, and the
invariant it needs now holds at the one place the row is read.

The probe's message is the other half. It boots the whole application,
so it fails both when the database is absent and when the application
cannot start, and it reported the second as the first — sending an
operator off checking credentials that were never wrong. It now prints
the error it actually hit and says which of the two it looks like.

Verified end to end against a 2.1-shaped database: `artisan migrate`
dies with UnhandledMatchError before the change and completes after it,
leaving the row reading as S3 with its bucket intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QmyH342d8MuW3pDuE9mbtS
2026-09-10 17:23:26 -03:00
ignacionelson 227a08dfce Merge pull request #1760 from denkfabrik-li/fix/image-defaults-to-production
Have the production image default to production
2026-08-29 02:32:21 -03:00
denkfabrik-li 74077993de Have the production image default to production
The entrypoint seeds the .env on the storage volume when there is none:

    if [ ! -f storage/.env ]; then
        cp .env.example storage/.env

.env.example is the development template. It carries APP_ENV=local and
APP_DEBUG=true, and the Dockerfile set no defaults of its own -- its only
ENV was PROJECTSEND_IMAGE=1.

Real environment variables win over that file, so compose.example.yaml
(APP_ENV: production, APP_DEBUG: "false") was never affected, and neither
was anybody following the documentation. Everybody else was: `docker run`
with nothing but a database address, the Portainer / unRAID / TrueNAS
templates people actually use, a Kubernetes manifest naming only
DB/Redis/APP_URL. All of them booted a debug build and nothing said so.

Two things follow, and neither is visible from inside the application:

1. **Every 500 hands its stack trace to whoever caused it**, signed in or
   not -- Laravel's exception page, with the file, the line and the
   surrounding source. docker/production/php.ini sets display_errors=Off
   and that does not help, because Laravel renders the page itself rather
   than letting PHP print it.

2. **"Reject known-breached passwords" never ran.** PasswordPolicy::rule()
   appends ->uncompromised() only when app()->isProduction(). On
   APP_ENV=local an administrator could switch the setting on, watch
   descriptor() advertise it on every password form and the security
   settings screen report it as active, and have it do nothing.

The image now states its own environment.

Set in the Dockerfile rather than in the seeded .env on purpose: the copy
only happens when no .env exists, so seeding would fix a fresh install and
leave every installation already running on a stale one exactly as it is.
As an ENV it takes effect on the next pull.

What it does not outrank: Laravel builds its env repository immutable
(Illuminate\Support\Env), so a real environment variable beats the .env
file. `docker run -e`, compose `environment:` and Kubernetes `env:` all
set real environment variables, so an operator who asks for something
explicitly still gets it -- verified against this image, where a .env
saying local/true is overridden to production/false by the variables.

The trade-off, stated because it is a behaviour change: editing APP_ENV or
APP_DEBUG inside storage/.env no longer has any effect, since these are
real environment variables and that file is not. Turning debug on
deliberately is `-e APP_DEBUG=true`, which still works. That is written
into the Dockerfile comment so the next person finds it there.

Not changed: PasswordPolicy's isProduction() test itself. Tying an
administrator's setting to the environment rather than to the setting is
arguably wrong on its own, but it is a separate question with its own
blast radius, and this change makes the shipped image behave the way that
code already assumes.

No test: the environment an image ships is not observable from the suite.
`docker build --check` reports no warnings on the edited file.
2026-08-29 00:07:12 +02:00
denkfabrik-li da7eb6f67d Publish the quickstart on loopback, since it trusts any proxy
compose.example.yaml does two things that are each fine alone and unsafe
together:

    ports:
      - "8080:80"            # Docker binds 0.0.0.0 unless told otherwise
    environment:
      TRUSTED_PROXIES: "*"   # believe the X-Forwarded-For of whoever connects

Behind a proxy that appends the header, "*" is correct and harmless --
Symfony strips the peer and takes the real client the proxy appended. The
example never gets there. It publishes the container on every interface,
so a visitor can reach port 8080 themselves, and then *they* are the peer
the application has been told to trust. `X-Forwarded-For: 203.0.113.9`
makes request()->ip() return exactly that.

What that costs, all of it on the signed-out surface:

  - the login lockout, keyed on `email|ip` in LoginRequest::throttleKey()
  - throttle:6,1 on register, password-email, password-reset, two-factor
  - throttle:30,1 on share-link, public-browse, public-comment
  - the download log, the activity log, and the `ip_address` recorded on
    guest comments -- which FileComments::post calls "the one handle that
    makes spam actionable"

Rotate the header and every one of them counts a different attacker.

The project's own test states the primitive: TrustedProxiesTest sets
trustedproxy.proxies = '*', sends X-Forwarded-For from a *direct* client,
and asserts the address is taken.

The documentation has always qualified "*" correctly -- .env.example says
it is "only safe when nothing but the proxy can reach the app", and
dockerhub-overview.md repeats it. The example file is what did not meet
its own precondition, and it is the file the Docker Hub description tells
a first-time reader to copy.

Publishing on 127.0.0.1 restores the precondition: a proxy on the host, or
in this compose file, still reaches it; nothing off the machine does. This
repository's own compose.yaml already publishes Adminer that way, for the
same reason.

The two settings are now documented as a pair in all three places that
carry them, including what to do when the proxy is on another host: bind
to the interface it arrives from and name that address in TRUSTED_PROXIES
instead of "*".

DOCKER.md's health-check command changes with it -- it told the reader to
curl <host-ip>:8080 from the same machine, which the new binding does not
answer. It now says 127.0.0.1:8080.

No test: this is packaging and prose. `docker compose config` parses the
edited file.
2026-08-29 00:05:03 +02:00
ignacionelson ac691387e8 Seed two-factor enforcement at provision, before the first account exists
The last of the three. Enforcement is a database setting defaulting to
'none', and on a managed installation the only writers are whoever
administers it and the boot that creates them — so a policy meant to be
on from the start had nowhere to be written. A control plane calling in
afterwards leaves a window between the first account existing and the
policy covering it, and the first account is the one with every
permission.

The entrypoint already seeds an account from the environment. This seeds
the policy one line above it, so the administrator is born under the rule
rather than ahead of it. There is a test for exactly that ordering,
because the ordering is the whole point.

Seeded, never overridden. A value that won on every boot would take the
setting away from the person it belongs to — somebody who tightened it
would find it loosened again by a restart. So it writes only when nothing
has ever been stored, the same shape as `projectsend:admin --if-none`.

Two things that would have been easy to get wrong, both pinned:

'none' is the enum's own default, so Settings::get() cannot tell "stored
as none" from "never stored". Asking the accessor would have overwritten
an administrator who deliberately chose it. The command asks the table.

And it reads config rather than env() directly. `config:cache` stops .env
being read at all, which is how TRUSTED_PROXIES came to have no effect on
any web request while looking correct in the file.

Deliberately not a general PROJECTSEND_SETTING_<KEY> mechanism. Every
setting reachable from outside is one whose value depends on where you
look, and the blast radius of getting that wrong is the settings table.
One named key per setting that needs it.

The three new variables are documented in config/projectsend.php and not
in .env.example or the Docker Hub overview. Those two are written for
somebody running one installation for themselves, and a seat cap is not
a thing they have — FILES_WEB_SERVER_READABLE is in .env.example because
a self-hoster on cPanel genuinely meets that problem.
2026-08-27 02:38:39 -03:00
ignacionelson ffbde4bea2 Bring the Docker Hub overview in line with 2.2.0
Three things went stale, all of them describing the image rather than
selling it, which is the half of that page people act on.

The tag table's worked example was 2.1.0/2.1. The "what is in the image"
paragraph said one queue worker; there are two now, and the second is
there so that building a large zip cannot hold up every notification
email behind it — worth a sentence, since somebody counting processes in
`docker top` would otherwise wonder. And the storage line said local disk
or S3, which stopped being the whole list when Google Cloud Storage
arrived.

FILES_WEB_SERVER_READABLE is new in 2.2.0 and deliberately not in the
environment table. It exists for hosts where nginx and PHP run as
different users, which cPanel and Plesk do; in this image they are the
same user in the same container, so listing it would invite people to set
something that buys them nothing.
2026-08-27 01:31:16 -03:00
ignacionelson 92a132d74f Give zip builds their own queue, so one archive cannot hold up the mail
The last piece of the #1687 follow-up. BuildZipDownloadJob allows itself
an hour, every shipped topology runs exactly one worker, and everything
shares the default queue -- so one large archive delayed every
notification email queued behind it. The size cap and the
one-build-per-person rule bounded that in July; they did not remove it.

onQueue('zips') in the constructor rather than at the dispatch site, so a
second caller cannot forget it. Both images grow a worker for it:
compose.yaml gains worker-zips, supervisord gains [program:queue-zips],
and the existing worker in each narrows to --queue=default. --tries=1
there matches the job, which records its own failure rather than being
retried.

The part that needs care is the manual install. A worker whose command
still says plain `queue:work` consumes `default` only, so it would send
email happily and never finish a single zip, with nothing in any log
saying why. INSTALL.md's unit now reads --queue=default,zips -- one
worker watching both, which is right for most installations -- and says
what happens if you leave it off, with the two-worker split offered for
anyone who would rather keep the two kinds of work apart. CHANGELOG
carries it as an upgrade note, since it is something to do rather than
something that was done.

Verified in the dev stack rather than only in a test: dispatched a build
and watched worker-zips take it while the default worker stayed idle.
2026-08-26 18:06:40 -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
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
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
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
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
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
ignacionelson be44b4b6aa Track the Docker Hub description, and send migrators to the guide
This file is what Docker Hub shows above the pull command, and it has been
sitting untracked since it was written — nothing to review it against,
nothing to stop it drifting from the compose file and the entrypoint it
describes, and nothing in the repository if the machine holding it died.

Verified against what it claims rather than read for tone. The compose
block has the same services, images and environment variables as
compose.example.yaml, with nothing extra in either. supervisord really does
run nginx, php-fpm, the queue worker and the scheduler. APP_KEY really is
generated once and kept on the storage volume with .env symlinked to it.
The container really does wait for MySQL and then run
`php artisan projectsend:update`. `projectsend:admin --if-none` is what
makes "ignored once any user exists" true. nginx really does serve
protected downloads with X-Accel-Redirect, which is the reason the image
carries a web server at all. MySQL 8.0-or-newer matches INSTALL.md word for
word, and every external link resolves.

The one thing that was wrong for its audience: the Legacy section pointed
at the migration tool's repository, and everybody reading this page is on
Docker — where the tool cannot be installed the way its README implies,
because the image ships the application already built and carries no
Composer. It now points at the guide, and names the section written for
this image today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:26:42 -03:00
ignacionelson 9192779ee4 Close four small gaps before the release goes out
Four unrelated one-liners, each already written down and none of them
worth a branch of its own.

The lock was still pinned to the community package's previous commit,
which is the one before it started shipping its own sixteen catalogues.
The mechanism that carries a package's translations to the browser landed
here last week; without this bump the release would have shipped that
mechanism with nothing to carry, and the Custom Assets screen would have
stayed half-English in every language.

The stock `local` disk had `serve` left on. Nothing in this application
writes to it, so the framework's /storage route was a door with nothing
behind it — but it was still a door, and closing it costs one word.

nginx evaluated `\.php$` before `/protected-files/`, so a protected path
ending in .php would have reached the PHP handler instead of streaming
under the sandbox headers that block sets. Not reachable on a default
install — the upload allowlist refuses php and X-Accel paths are UUIDs —
but the guarantee read stronger than it was. `^~` makes it true.

And `.release-build` is now ignored by eslint, so linting after building a
zip stops walking the vendored minified JS inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:28:16 -03:00
Ignacio Nelson f446398dfd Say which step is missing instead of failing blankly (#1633)
Somebody followed the README's Docker quickstart, which starts the
development stack, and got three failures in a row with nothing to search
for (#1627): the worker died once a second on a missing autoloader, the
site answered a bare 500, and once dependencies were installed by hand the
setup screen threw ViteManifestNotFoundException.

None of that is wrong behaviour for a clone — vendor/ and public/build/
are deliberately not in git — but every one of those failures kept its
cause to itself.

The preflight guard exists to turn "this was never set up" into a
sentence, and it runs before the autoloader precisely so it can. It now
answers two more questions: dependencies not installed, and frontend not
built. The dependency check goes first, before the .env one, because the
fix that branch prints — php artisan key:generate — cannot itself run
without the autoloader, so reporting the key first hands somebody a second
and more confusing error. A running vite dev server counts as built:
public/hot means the assets come from there, and blocking a developer
mid-session would be worse than the exception this replaces.

The worker and scheduler exec straight into artisan, so before composer
install they died instantly and restarted forever, filling the log that
had to be read to fix it. They now print what is missing and exit slowly,
and recover on their own once it is there. The scheduler gains the restart
policy the worker already had — without one it exits during that window
and stays exited, and scheduled work then silently never happens.

Rehearsed on a genuine clone of the public repository, following the
reporter's exact path: worker prints instructions instead of fatals (2
restarts in 30s, not 30), the browser gets "ProjectSend is not installed
yet" naming composer install, then "not configured yet", then "not built
yet" naming npm run build, then the setup screen.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 02:21:29 -03:00
Ignacio Nelson ed0d36de25 Reduce a manual update to one command that asks first (#1628)
Updating a server install cost nine artisan invocations plus a PHP-FPM
reload, written out in three places that had already drifted apart. One
of those steps is silently fatal to skip: with opcache.validate_timestamps
off — what production guides recommend and what our own image ships — the
database moves to the new version while every visitor keeps being served
the old code, and artisan reports the new version throughout.

`sudo ./update.sh` is now the whole procedure. It asks whether to check
GitHub, asks whether to download the release and verifies the checksum
published beside it, and asks whether there is a backup — offering to dump
the database when the answer is no. Then it takes the site down, replaces
the files, runs the update, reloads PHP-FPM, restarts the worker and
brings the site back. The application still has no self-updater: nothing
is fetched or applied unless somebody runs this and answers yes.

Underneath it is `php artisan projectsend:update`, which is everything an
update does that needs no root — and now the only definition of it. Both
container entrypoints call it instead of carrying their own copy of the
sequence, so the two paths cannot drift again.

Three findings worth keeping in the record, all from rehearsing rather
than reasoning:

  - queue:restart has to come last. It writes its signal into the cache,
    so clearing the cache afterwards deletes it and the worker runs old
    code forever.
  - optimize:clear is not safe to recommend. It runs cache:clear, which
    on Redis is FLUSHDB — harmless on the default two-database layout,
    but on a single-database Redis it takes the sessions and the queue
    with it. The compiled caches are cleared individually instead.
  - update.sh overwrites itself mid-run, because the zip contains it and
    bash reads its own script lazily by byte offset. It re-execs from a
    temporary copy before touching anything.

And when the reload is skipped anyway, the application now says so:
projectsend:update records the version it applied, and any staff page
compares that with what the running process actually compiled. The same
check catches the mirror image — new files in place, update never run.

Rehearsed end to end against real installs: a container upgrade (69 to 73
migrations, key and data intact, healthy), a scripted update on a real
nginx + php-fpm install with OPcache pinned (web process moved 2.1.0 to
2.1.1), the skipped-reload case (banner appears naming both versions, and
clears on reload), the refusals (downgrade, non-release zip, truncated
zip, URL passed to --zip, non-root), a database taken down mid-update
(site comes back out of maintenance mode by itself), and a real download
of the published 2.0.0 zip with its checksum verified.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:29:20 -03:00
Ignacio Nelson 4f10401c36 Run nginx as the user php-fpm writes as (#1622)
The image creates a fixed-uid www-data for php-fpm but left nginx on the
alpine package's own `user nginx;` (uid 100), while nginx.conf's header
claimed the two already matched. They did not, and the claim is why
nobody looked.

It matters because php-fpm hands nginx files to serve: protected
downloads and thumbnails go out by X-Accel-Redirect. Directories the
application creates on demand come out at Flysystem's private mode, 0700,
owned by www-data — and traversing one of those means being its owner,
since there are no group or other bits to fall back on. So the first
thumbnail an installation ever renders answers 403, from nginx, with
nothing in the application's log to show for it (#1614).

Pointing the directive at www-data is half the fix. /var/lib/nginx and
its tmp/ arrive from the package owned by the old user, and nginx
recreates only the leaf temp directories at boot — as the new user, so
they look correct while their parent stays untraversable. Every request
nginx buffers to disk then fails with a bare 500 that never reaches
php-fpm, which is every upload chunk, which is every upload (#1618).
Fixing one without the other trades a broken thumbnail for a broken
upload, so both land together.

Verified by building the image three ways and driving a real upload and
thumbnail through each: unchanged, the part PUT succeeds and the
thumbnail 403s; with the user directive alone, the thumbnail works and
the part PUT is a bare nginx 500 with "open()
/var/lib/nginx/tmp/client_body/0000000001 failed (13: Permission
denied)"; with both, the chunked upload completes, the thumbnail renders,
and the file downloads back byte for byte.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:55:06 -03:00
Ignacio Nelson d53bb9a2f7 Own the application directory in the official image (#1620)
The base php:*-fpm image creates /var/www/html owned by its own www-data
(uid 82) and mode 1777, so that an image can run as an arbitrary user.
This image replaces www-data with a fixed uid 1000 and copies the
release in with COPY --chown — which re-owns what it copies into the
directory, never the directory itself. It was left world-writable,
sticky, and owned by a uid the container no longer has.

fs.protected_symlinks — on by default on Ubuntu, Debian and most current
distributions — then refuses to let a non-root process follow a symlink
in such a directory, and .env is exactly that: the entrypoint keeps it
on the storage volume so a generated APP_KEY survives container
replacement, and links it into place. So every request 503'd with
"ProjectSend is not configured yet" while `docker exec ... cat .env`,
run as root, printed the file back perfectly (#1615).

Three changes, each independently sufficient for the reported case, and
deliberately so — this failure is silent and its symptom points away
from its cause:

  - the image owns /var/www/html as the runtime user, at mode 755;
  - the entrypoint owns the symlink it creates, so it stays followable
    even if that directory's mode ever drifts back;
  - preflight distinguishes "no .env" from ".env is there and cannot be
    read", instead of reporting the second as the first and sending the
    operator off to create a file they already have.

Verified by building the production image before and after: every
request 503s beforehand, with /var/www/html at uid 82 mode 1777 and
www-data denied on the symlink while root reads it; afterwards /up
answers 200, the container reports healthy, and / redirects to /setup.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:20:57 -03:00
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00