25 Commits

Author SHA1 Message Date
ignacionelson b8050b36ca Release 2.4.1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 13:59:02 -03:00
ignacionelson 6ad26bb61e Hold an upload to the size it said it was sending
Reported by @ry2811 as GHSA-6jh6-gvj5-pv8v.

A resumable upload declares its size, and that declaration is what store()
weighs against the maximum file size and the client's storage quota. Only
the assembled file was ever held to it. The parts in between were bounded
one request at a time and never added up, so a client could declare one
byte and then stream parts: ten thousand part numbers at twice a 20 MB
part is about 400 GB, per session, and the number of sessions was not
bounded either. None of it counted against anything, because nothing
becomes a File row until the upload completes and ClientStorageUsage sums
File rows. A client with a 1 MB quota could fill the volume and repeat.

putPart()'s own comment described this defect and treated the per-part cap
as the answer to it: "without a cap here the exposure is a day's worth of
disk". A cap on one request bounds one request. The exposure was a day's
worth of disk multiplied by however many requests somebody cared to make.

Three limits, and each one exists because the other two do not cover it.

A session may not stage more than it declared. The room for a part is
claimed before the body is read — a body's length is not known until it
has arrived, and by then it is on the disk being protected — and the write
is then capped at exactly what was claimed, so an over-long body is cut
off mid-stream as it always was, against a smaller number. The claim is a
read and a conditional update under a per-session lock, the same shape
complete() already uses: the protocol sends parts in parallel and how many
is the client's choice, so an unlocked read lets every part in flight
claim the same room, while an atomic claim alone refuses the honest
parallel upload instead. Whatever the part really weighs is settled back
afterwards, in a finally, or a client's own retries would exhaust a
session with room to spare.

Open sessions count against the quota at the size they declared. A quota
measured against finished files alone is spent twice by opening sessions
one after another — each is told there is room, because the ones before it
have not finished. The cost is that an abandoned transfer holds its share
until it is cancelled or swept, so the sweeper now runs hourly rather than
daily: that gap is now somebody unable to upload, which it was not before.

And a cap on open sessions, because for anyone with no quota to spend —
staff, and clients on an installation that sets none — the session count
is the only thing between a declared size and any multiple of it.

Four tests fail on the unfixed code, and three existing ones had to change:
they declared a tiny size and sent a large part deliberately, to reach the
re-checks at complete(). That route is now closed at putPart(), so they
reach those re-checks the way a real install would instead — the file-size
limit or the quota moving while a long transfer is running, which is the
reason complete() re-asks rather than trusting what store() decided.

The staged-byte total is BIGINT UNSIGNED, and the suite runs SQLite, which
has no unsigned integers. The first version of the bounds read
`staged_bytes + :delta BETWEEN 0 AND size` and raised SQLSTATE 22003 on
MySQL for any refund — in the comparison, so the bound written to prevent
the underflow was the statement that underflowed. Every SQLite test passed
on it. Both bounds are now arranged so the column is never inside a
subtraction, and UploadSessionStagedBytesMysqlTest skips loudly unless the
connection is MySQL. Verified against 8.4, as was the report itself: three
sessions declaring one byte each put 6 MB on the volume of a client with a
1 MB quota before, and nothing at all after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CNFU55Tkq6MuEQ73nbbBRx
2026-09-11 12:05:41 -03:00
ignacionelson 62c763d04e Put a floor under a client quota nobody set
Setting::DefaultClientStorageQuotaMb defaults to 0, and 0 means
unlimited. That is the right default for somebody setting up their own
installation and the wrong one for an installation a platform operates
on other people's behalf: an account that arrived without an explicit
quota has no ceiling at all, and it does not have to be an account the
platform created.

So a platform may set a floor in the environment
(PROJECTSEND_PLATFORM_DEFAULT_CLIENT_QUOTA_MB), exactly as it sets the
seat caps, and for the same reason those are not settings: it is the
shape of what was sold rather than a preference the installation's
administrator is expressing. It applies only where the setting says
nothing, so an administrator who chose a number keeps it, and an install
with no platform behind it is unaffected.

ClientStorageUsage::defaultQuotaMb() is where the three sources resolve,
and every screen that presents the answer now reads it there:

  - The client create and edit screens. The edit screen mirrors that
    resolution client-side to draw the usage bar, so handed the raw
    setting on a floored installation it computed an effective quota of
    zero, printed "unlimited" and hid the bar entirely -- for a client
    whose next upload was about to be rejected for exceeding a limit the
    screen said did not exist.

  - projectsend:status, which gains clients_can_register and
    default_client_storage_quota_mb. Both defaults are the permissive
    ones, both are invisible from outside, and a document reporting the
    setting while uploads obeyed the floor would say the ceiling was
    missing on an installation that has one.

The Client settings form deliberately still reads the raw setting: that
field is read and written back on save, so prefilling it with the floor
would write the platform's number into the setting as the
administrator's own choice, where it would outlive the floor.
2026-09-11 00:41:31 -03:00
ignacionelson e187513cdd Stop a mistyped CAPTCHA flag from switching the CAPTCHA off
`env()` recognises the words "true" and "false" and returns everything
else as the string it was — and every non-empty string is truthy in PHP.
So `(bool) env('PROJECTSEND_CAPTCHA_DISABLED')` read all of these as "yes,
disabled":

    PROJECTSEND_CAPTCHA_DISABLED=no
    PROJECTSEND_CAPTCHA_DISABLED=off
    PROJECTSEND_CAPTCHA_DISABLED=fasle

An operator who meant to say no took the bot protection off their login
and registration forms and had nothing to tell them so — the setting
screen still shows the CAPTCHA configured, because this is the escape
hatch that runs ahead of it.

For most settings the cast is a shrug: somebody notices the feature is on
and fixes the line. It stops being a shrug when the wrong answer is the
unsafe one, and this is one of those. EnvFlag lists what counts as yes —
`true` and `1`, either case, either type — and reads everything else,
recognised or not, as no. A value typed as `disabled` turns nothing off:
a configuration mistake to be found rather than guessed at.

Found while fixing the same bug in a new cloud-modules flag, where the
unsafe direction was publishing a customer's files rather than dropping a
CAPTCHA. Two of the four remaining `(bool) env()` casts are left alone on
purpose: a wrong S3 path-style value breaks storage loudly, and the
migration tool's direct mode defaults to true anyway, so neither fails
into an unsafe state.
2026-09-08 17:05:30 -03:00
ignacionelson 763e7b0e2e Render one image once, however many requests ask at the same time
Renditions are generated on demand and cached by existence, and nothing
between the callers stopped two requests decoding the same image at once.
The atomic rename settled which file survived; it never stopped both from
doing the work. So N concurrent requests for one cold rendition were N
full-size decodes, each holding four bytes per source pixel — up to 160 MB
at the 40-megapixel ceiling.

That is not an attack. A public listing emits a thumbnail URL per file, a
browser opens six or more connections at once, and the first visit to a
gallery of ordinary camera images was six simultaneous decodes on a
container sized for one. PublicGroupsController reaches the generator with
no account at all, so nothing about it required a customer to be signed
in, and the 240/min throttle bounds rate rather than concurrency.

Worse than a crash, it did not resolve itself: a render killed mid-flight
renames nothing, so the cache warmed only by whatever finished before the
kill and the page died again on the next visit.

A lock keyed on the destination path — which already encodes the file, the
audience and the rendition, so two requests collide exactly when they
would have written the same path. The waiter re-reads after acquiring,
which is what turns a wait into a cache hit rather than a second decode of
the same image.

Waiting rather than refusing, because the arithmetic says so: a waiting
request holds an idle worker at about 35 MB, a rendering one holds that
plus the whole source bitmap. Six waiters cost what one renderer costs.

On timeout it refuses instead of rendering anyway. Falling through would
reinstate the pile-on at the moment the system is already struggling, and
one failed thumbnail is a better outcome than a container that dies and
takes the warm cache with it.

The wait is configurable because the right number is a property of the
machine — a small VPS reading a large source off a slow disk wants longer
— and clamped to at least a second, since a stray empty variable would
otherwise make every concurrent request fail instantly, which is the
opposite of the point.

Eight tests. Two go red without the lock, and the clamp is asserted on the
resolved value rather than the clock, because block() measures in whole
seconds and a timing assertion there would be flaky rather than wrong.

Found by the session sizing free-tier containers, from the outside.
2026-09-08 15:29:20 -03:00
ignacionelson fba5f30436 Release 2.4.0 2026-09-08 09:33:14 -03:00
ignacionelson 96107fdcd5 Release 2.3.0 2026-09-01 01:18:18 -03:00
ignacionelson d6fd5a917d Send downloads the way the web server in front of us understands
Uploads live outside the web root, so PHP authorizes every download and
then hands the file to the web server with a header naming it. Four
routes decided that for themselves and all four hard-coded nginx's
spelling. On Apache or LiteSpeed nothing acts on the header, so the
empty body PHP sent goes to the visitor: files upload fine, thumbnails
are broken images, and downloads arrive as 0 bytes, with every other
page working. Reported as #1765 from an Apache 2.4 install, and before
that as #1266, #1215, #870 and #1271.

It is also a regression from v1, which had a download_method setting --
php, apache_xsendfile, litespeed, nginx_xaccel -- defaulting to php. v1
therefore worked on any server out of the box and v2 did not, and a v1
Apache user migrating lost every download with nothing to tell them why.

So the four sites now go through one FileDelivery, and it picks:

  auto (default)  nginx when SERVER_SOFTWARE says nginx, else php
  nginx           X-Accel-Redirect, a URL path via the internal location
  xsendfile       X-Sendfile, an absolute path (Apache mod_xsendfile,
                  LiteSpeed)
  php             BinaryFileResponse

Defaulting to auto rather than nginx is the point of the change: a
default that assumes nginx leaves an Apache install exactly as broken as
it is today until somebody reads INSTALL.md. Slow beats empty.

Auto never picks xsendfile, even where the module is loaded.
mod_xsendfile also needs XSendFilePath to allow the storage directory,
which cannot be seen from here, and choosing it on the strength of the
module being present would trade a silent failure an administrator can
diagnose from the dashboard for one nobody can.

BinaryFileResponse rather than a readfile loop because it answers Range
requests. nginx does that itself on the fast path, so hand-rolling it
would have broken seeking through a video on exactly the installations
this fallback exists for. Verified end to end: 206 with the right
Content-Range through the live stack.

Two guards. Every method checks the path cannot climb out of the storage
area -- nginx resolves `..` in the URL it is handed as happily as PHP
would -- and the two methods that hand over a filesystem path resolve it
and prove it lands inside the root. Callers pass paths from rows they
just authorized, so this is a backstop; it is here because the cost of
being wrong once is handing over any file the web server can read.

The dashboard's System panel names the method, with a warning icon and a
dialog when PHP is doing the sending: what is happening, what it costs
(one worker held for the whole of each download, so a few large
simultaneous ones can occupy every worker while the processor sits
idle), why it is set that way, and the three ways out. Written to be
accurate rather than reassuring -- nothing is broken, it does not scale
-- and the notice stays even when php was chosen deliberately, because
the trade-off is the same either way. /system/settings/downloads repeats
it, which is where somebody coming from v1 goes looking for the
dropdown.

An environment variable rather than a stored setting: it describes the
server this installation runs on, not a preference, and a value in the
database travels to a different server in a restore and is wrong there.
Read only in config/projectsend.php, so config:cache cannot blank it.

The suite pins itself to nginx. Left at auto it would detect no server
at all, fall back to php, and quietly retire the coverage of the
mechanism most installations actually use.
2026-08-31 22:31:27 -03:00
ignacionelson b7a94d4479 Merge pull request #1742 from denkfabrik-li/fix/file-permissions-test-reads-config
FILES_WEB_SERVER_READABLE exists so a web server running as a different user can traverse the directories a download lives in. It asked for 0755 from a key that is never consulted: FilesystemManager::createLocalDriver() passes directory_visibility ?? visibility ?? private as the default visibility for directories, and this disk sets visibility to public two lines above with no directory_visibility, so Flysystem reads dir.public and never looks at dir.private. The mode came out 0755 anyway, because 0755 is Flysystem's default for a public directory -- the right answer from the wrong place, which is the kind that stops being right quietly. Adding a directory_visibility to this disk, an ordinary hardening move, or a change to that Flysystem default would have been enough to break the flag silently on exactly the hosts that need it.

Both directory keys are now named, so the intent survives whichever branch Flysystem takes. Nothing widens: the flag-off path is still literally the old configuration, spread rather than ternary, and under the flag 0755 was already the effective mode.

And the test could not have caught it, because it was not testing this configuration: filesDiskWith() restated the shipped branch inline, verbatim down to the 0755, so it kept passing against its own copy however the real one changed. It now requires config/filesystems.php and replaces only the root. Two housekeeping fixes ride along: the scratch root is per parallel worker, the way Tests\TestCase already does it for upload parts, because eight workers sharing one real directory means one worker's afterEach deletes another's tree mid-test; and the tree is cleared before each test as well as after, so a killed run does not poison the next one.

Verified before merging: 3 passed on the trial-merge, and the mutation counter-check was run here rather than taken from the PR. With the shipped dir.public changed to 0750, this branch's test goes 1 failed / 2 passed and main's version of the same file goes 3 passed -- the old one genuinely could not see a change to the shipped configuration.

Reported and fixed by @denkfabrik-li.
2026-08-28 17:34:33 -03:00
ignacionelson 530f30606d Let a plan take a capability away, and split branding from white-labelling
Groundwork for moving Branding out of the private package. Two changes,
both about who decides what an installation may do.

An edition grants capabilities; an operator may now take some away, via
PROJECTSEND_CAPABILITIES_DISABLED. Subtractive only, and that asymmetry is
the whole design: a variable that could *add* would put the hosted
edition's proprietary screens one line of .env away on every self-hosted
install, which is not a gate at all. So the list is intersected with what
the edition already allows and can only make the answer smaller.

This is not the plan tier core has always refused to invent. There are
still no billing tiers here to key off -- the objection config/api.php
makes about rate limits stands. It is the operator stating a fact about
this installation, exactly as PROJECTSEND_PLATFORM_MAX_STAFF_USERS does
for seats: the platform knows what it sold, the installation is told and
enforces. Unknown keys are ignored rather than fatal, because the variable
outlives both the plan that wrote it and the release that named the key,
and refusing to boot over a stale one would be an outage on upgrade day.

The registry takes the list as a constructor argument rather than reading
config itself, which keeps it a value object testable without an
application -- the failure that surfaced it was a unit test with no
container.

And branding.customize is now both editions, with the white-label half
split into attribution.hide, which stays Cloud-only. Dressing an
installation in its own logo is not a hosted concern; taking ProjectSend's
name off somebody's public pages is what a hosted customer pays for. The
gate on the second is not the key but that the only code able to answer
"hide it" ships in the private package, so flipping an edition variable
buys nothing.

EnsureCapabilityMiddlewareTest had to pick a new Cloud-only example for
the second time -- branding after users.manage. It now uses
storage.managed, and records what to ask if it ever needs a third.

The code move itself is the next commit; nothing user-visible changes yet,
because the screens still live in cloud-modules.
2026-08-28 13:11:57 -03:00
ignacionelson 2029309126 Release 2.2.1 2026-08-28 01:52:41 -03:00
denkfabrik-li b838036a9a Set the directory permission Flysystem actually reads
FILES_WEB_SERVER_READABLE asks for 0755 on the directories a download has
to be traversed through, and asks for it from a key that is never
consulted.

FilesystemManager::createLocalDriver passes
`directory_visibility ?? visibility ?? private` to
PortableVisibilityConverter::fromArray() as the default visibility for
directories. This disk sets `visibility` to public two lines above, and no
`directory_visibility`, so directories are public and the converter reads
`dir.public`. The configuration names only `dir.private`.

The mode is 0755 regardless, because 0755 is Flysystem's default for a
public directory -- the right answer from the wrong place. Adding
`directory_visibility` to this disk, or a change to that default, is all
it would take for the flag to stop doing what it says. Measured on main,
with the flag on:

  dir.private 0755 → 0750   directory stays 0755   (nothing reads it)
  dir.public  0755 → 0750   directory becomes 0750 (this is the key)

Both are named now, so the intent survives either way round.

FilePermissionsTest could not have caught this, because it was not testing
this configuration. filesDiskWith() restated the shipped branch inline,
verbatim down to the 0755, so it went on passing against its own copy
however the real one changed. It now requires config/filesystems.php and
replaces only the root, which is what makes the mutation above visible to
it.

Two more things in the same helper, both about the suite rather than the
subject: the scratch root is per worker now (Tests\TestCase does the same
for upload parts, and eight workers sharing one directory means one
worker's afterEach deletes another's tree mid-test), and it is cleared
before each test as well as after, so a killed run does not poison the
next one.
2026-08-28 06:40:54 +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 463e86f82b Refuse an account past the seat count an operator sold
Opening user management on cloud (623ad68) left a managed tenant able to
create staff accounts without limit. This is the other half, and the two
belong in the same release.

max_clients and max_staff_users are numbers the platform sells and does
not enforce — grep finds them only being passed to screens. The
application is the only process that can count against them, so it
accepts the number from the environment and refuses to exceed it. That is
not the same as inventing a plan tier, which is what config/api.php
declines to do when it will not key a rate limit off billing: nothing
here knows what a plan is.

## One definition

staffUsed() and clientUsed() are public and are what the guards read. A
control plane showing "2 of 3 used" from its own query, beside an
application refusing the fourth from a different one, disagrees
eventually — over an inactive account, or a deleted one — and the
disagreement reads as a billing fault rather than a counting one.

## What counts, and the consequences somebody has to explain

An inactive staff account occupies its seat. Excluding it would make
deactivation a way around the cap rather than a way to revoke access,
since reactivating is one click. The cost is an awkward incentive —
deactivating is the safe removal and keeps paying, deleting frees the
seat and asks what happens to the files — and it is better explained than
hidden.

A client awaiting approval does not. Self-registration is open to
strangers, and counting a pending request would let anybody exhaust a
paid limit from the outside, turning a pricing tier into an availability
control. The seat is spent at approval, which is where the guard sits.

A soft-deleted account frees its seat, though not its address —
AvailableEmailRule holds that until erasure. So a seat can be free while
re-adding the same person is still refused, which is the address rule
rather than this one.

## Eight doors, eight tests

There is no single User::create() to guard. StaffAccounts::create()
covers both staff controllers, but a promotion takes a staff seat without
creating anything, a demotion takes a client seat, ClientProvisioning
serves registration and LDAP and social sign-in alike, and approval turns
an uncounted request into a counted client.

A cap is only a cap if every door asks, so there is a test per door and
each was verified to fail without its guard — eight red, with the two
"must not change" cases green either way. DownloadAllowance's shape for
DownloadAllowance's reason: the failure mode is one of them quietly not
asking, invisible from everywhere except the door that forgot.

projectsend:admin is deliberately uncapped and has a test saying so. It
is the recovery path, and anyone who can run it can also edit the
environment the cap comes from.
2026-08-27 02:31:12 -03:00
ignacionelson 4c5c956a26 Release 2.2.0 2026-08-27 00:54:55 -03:00
ignacionelson 41b4e477b5 Give each parallel test worker its own directory for upload parts
A full parallel run failed once and passed on retry while I was doing the
#1703 follow-up. A flake is worse than a steady failure: it trains you to
re-run rather than look, and it quietly weakens every green run reported
beside it.

Upload parts are real files under storage_path('app/uploads-tmp/{session_id}'),
not a faked disk. Every parallel worker gets its own database, so session
ids restart at 1 in each of them, and two workers writing parts land in
the same directory. On top of that ChunkedUploadsTest's afterEach deleted
the whole tree rather than its own share, for everybody. Six test files
write parts, so this was reachable without anything I added.

The same collision exists inside one worker: RefreshDatabase rolls back,
so ids restart at 1 for every test, and a run that died before its
cleanup leaves parts sitting under the id the next test is about to
claim.

LocalPartStore now reads its root from config, defaulting to exactly
where it always was -- an installation with UPLOAD_PARTS_PATH unset
behaves identically. Tests\TestCase points it at a per-worker directory
and empties that directory per test, which closes the cross-worker, the
cross-run and the intra-worker versions together. ChunkedUploadsTest's
cleanup and its two directory assertions read the configured root rather
than the hardcoded path, so they can no longer reach into a neighbour.

Verified with eight consecutive parallel runs, green, and by watching the
per-worker directories appear separately (w1, w2, w4 … w14) rather than
one shared tree. The isolation itself cannot be asserted from inside a
single test; what a test can pin is the mechanism it rests on, so one
does: parts go where the configured root says.
2026-08-26 18:00:22 -03:00
denkfabrik-li 4eb8cf915a Add Google / Gmail as the second OAuth mail provider
Same delegated shape as the Microsoft 365 provider, through the same
broker interface: the admin registers an OAuth client in Google Cloud
Console, connects the Google account the installation should send as,
and outgoing email goes through the Gmail API's messages.send as that
account.

The shared authorization-code machinery (exchange, refresh, token
storage, id_token account detection, RFC 6749 failure telling a dead
grant from a transient one) moves into an abstract OAuthCodeFlowBroker;
the two vendor brokers keep only their endpoints, scopes and consent
URL parameters. Google's quirks live where they belong: offline access
with a forced consent screen (the only way Google issues a refresh
token), and a refresh response that never re-sends one — the store
keeps what it has.

The settings screen needed no changes: the dropdown, the credential
form and the connect flow all derive from the provider enum.
2026-08-23 22:46:24 +02:00
denkfabrik-li 933eaa2ba4 Send mail through Microsoft Graph as an admin-connected mailbox
Adds "Microsoft 365 (OAuth)" to the Email settings provider dropdown.
Selecting it swaps the SMTP form for an app registration (client id,
secret, optional tenant) and a "Connect mailbox" flow: the admin signs
into the mailbox the installation should send as, and outgoing email
goes through Graph sendMail as that mailbox — no password, no app
password, no SMTP AUTH, which Microsoft is winding down.

Delegated flow on purpose: it needs no admin consent and works for
work/school and personal accounts alike. Its one weakness — a grant
can die silently behind a password reset or a Conditional Access
change — is answered by a daily scheduled refresh that keeps the
token alive and, on a dead grant, warns the settings admins once
in-app and on the settings page instead of letting mail stop quietly.

Tokens and the client secret live encrypted in their own row and are
read fresh at send time, never through the boot-config cache. The
stored SMTP transport survives a provider switch untouched.
2026-08-23 22:46:24 +02: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 e9dabc39e3 Release 2.1.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:42:37 -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
ignacionelson a8a7f3f340 Send each edition to its own front door, and stop asking payers for money
Two things on the About screen were written when there was only one
edition. "Website" pointed at projectsend.org for everybody, and the
donation link was offered to hosted customers who are already paying for
this — on the same screen that thanks them for choosing it.

projectsend.org is the way in for the software you run yourself and
projectsend.cloud is the way in for the hosted service, so `links.website`
now resolves to whichever one the reader is actually using. That reaches
further than About by design: the "Powered by ProjectSend" line at the
foot of every outgoing email and on every client-facing page is where a
recipient meets this product for the first time, and sending a hosted
customer's recipients to self-hosting instructions is the wrong door.

The donation link is *omitted* rather than hidden by the page, so a
surface added later cannot ask a paying customer for money by forgetting
to check. Its TypeScript type is optional now, which makes the compiler
enforce the same thing.

Also fixed on the way past: the settings footer hardcoded the text
"projectsend.org" next to that link, so on the hosted service it named a
site it did not link to. It reads the host off the resolved URL now.

Verified in a browser against both editions, not only in tests. Cloud:
projectsend.cloud, no donation link, on both screens. Community:
projectsend.org and Open Collective, exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:55:41 -03:00
ignacionelson 6ddfc1aa5d Greet the administrator once, on the first visit after an update
An update finished and nothing said so. The dashboard looked identical to
yesterday's, and whatever the release brought was in a file nobody opens.

The first time the installation's administrator opens ProjectSend after
an update, they now land on a page that names the version they are on,
invites them to the Discord — the same invitation update.sh prints, made
again where they are actually looking — and then lays out what the
release brought.

The notes come from CHANGELOG.md inside the release, not from GitHub: the
one moment this page exists for is the moment after an update, possibly
on a server with no outbound access, describing code already on disk.
Parsed rather than rendered, so nothing in it can become HTML.

Once, and to one person. The update happened to the installation, so
greeting five staff members — each having to dismiss a page they did not
ask for — would turn a pleasant moment into a support question. It goes
to the oldest active administrator, which on any installation that went
through setup is whoever set it up. No owner flag was invented for this:
administrators are equal in authority, and changing that for a greeting
is not a trade worth making.

Only forwards, and only for a real update. A fresh install has nothing to
catch up on, a container reboot has not updated anything, and somebody
restoring an older release is dealing with a problem rather than
celebrating. Managed installations never see it at all — nobody signed in
there performed the update it thanks them for, which is the same gate the
System card and About's environment block already carry.

The redirect is attached to the dashboard alone, not the web group: it
catches a login and the sidebar logo both, without ever interrupting a
download to congratulate somebody. Reading the page clears the marker,
but the address keeps working — closing it by accident should not be
unrecoverable — and About now links to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:22:17 -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