mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-12 06:48:55 +00:00
v2.4.1
20 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
227a08dfce |
Merge pull request #1760 from denkfabrik-li/fix/image-defaults-to-production
Have the production image default to production |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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:
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |