12 Commits

Author SHA1 Message Date
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 9b2aea4812 Say what the actions cast actually costs if it goes
The comment said a reader unmarshalling a map breaks on an empty array.
Checked against the reader since, and it is worse than that: the hosted
platform decodes the block into a typed struct and discards a block it
cannot read, and Go refuses a JSON list into a map outright. A [] here
loses the whole usage block -- downloads and uploads with it -- on the
day a tenant happens to have no counted activity, with nothing logging a
fault. The quietest installations would be the ones that went quiet.

Comment only. The cast was already right; what was missing was the
reason it is load-bearing, which is exactly the kind of condition this
week kept proving nobody had written down.
2026-09-01 02:05:29 -03:00
ignacionelson 6340b71dca Report recent usage and whether the scheduler is alive
projectsend:status could say what an installation holds and how many
accounts it has, but nothing about whether anybody was using it. Adds a
`usage` block -- downloads split staff/clients/anonymous, uploads, and
five allowlisted action counts -- plus `activity.last_client_login_at`,
`health.scheduler` and `health.failed_jobs_latest_at`.

The scheduler is the one worth having on its own. `health.queues`
catches a dead worker; nothing caught a dead scheduler, and its first
symptom is not a stalled feature but an expired file that is still
downloadable, because the job that was going to remove it stopped
running weeks ago. Nothing about the installation looks wrong while that
is true.

`failed_jobs_latest_at` exists because the count beside it cannot say
whether anything is wrong *now*, and reading it as though it could is a
category error rather than a threshold wanting tuning. The table is
swept daily, so the count spans a retention window -- one the
installation chooses, and one that can be set to keep-forever by
somebody who treats a failed job as evidence rather than debris. Two
identical installations therefore report different numbers, and on a
keep-forever one the count grows until any fixed threshold trips. A
timestamp is independent of how long rows are kept: 27 failures whose
newest is three weeks old is an installation that has been healthy for
three weeks and has not been swept yet.

`usage` is a rolling window with no lifetime totals, and that is a
correctness decision rather than a presentational one: activity_log is
never pruned, so a lifetime count over it gets slower every day of the
installation's life while a windowed one stays flat. The window is
emitted as `window_days` rather than left for the reader to assume.

The actions are an allowlist, not a `group by action`. This document
leaves the installation and Action gains cases most weeks, so an open
group-by would ship new action names outward with nobody having decided
they should go -- and some of them (account.erased, two_factor.reset)
are somebody's compliance event, not a business metric. It is also ~30x
cheaper: five keyed counts ride (action, created_at) while a group-by
starts from created_at and reads rows. The scheduler's failure message
and the queue exception text are omitted for the same reason; they are
the fields here that can carry a path or a stack trace, and a count with
a timestamp says "go and look", which is all a watcher is owed.

The two indexes ship as a pair and the migration explains at length why.
Measured at 2.1M rows: adding (action, created_at) alone fixes the
windowed counts and takes last_staff_login_at -- already running hourly
on every tenant -- from 0.63s to 7.7s, because the planner switches to
it, still needs actor_type, and does a scattered primary-key lookup per
row. With both, that query is answered from the index without reading a
row at all (0.0004s) and the whole new usage block costs ~70ms.

Also documents the keys as a contract, the way `capabilities` already
is. This one fails worse: a renamed capability key breaks a comparison
somebody is watching, a renamed usage key produces a chart that is
silently empty, and nobody gets paged for a flat line.
2026-08-31 18:51:47 -03:00
ignacionelson 479dc61d2d Move branding into core, and leave white-labelling behind
Logo and watermark belonged in the private package for one reason: that
is where they were written. Nothing about them needs a hosted platform,
and an installation wanting its own mark on the pages it serves is the
ordinary case rather than the exotic one. They are core's now, and every
installation has them.

Hiding "Powered by ProjectSend" did not come. That is what a hosted
customer pays for, and its gate is not a capability key but the absence
of the code: cloud-modules keeps the listener, so an installation without
that package holds the column and has nothing able to read it. Flipping
an edition variable buys nothing, which was true before and stays true.
Core renders the switch where Capability::AttributionHide is held and has
no route that can save it -- there is a test asserting exactly that, which
fails the day white-labelling quietly becomes free.

The migrations move with their original filenames on purpose. A Cloud
tenant already ran them under those names, so Laravel skips them there
and the table and its data are untouched; a fresh install or a community
one runs them from here for the first time.

What got better on the way rather than merely moving:

The watermark listeners take core's real RenderingImage and
ResolvingImageRendering instead of duck-typed `object` payloads, and the
tests construct the genuine events rather than anonymous stand-ins that
imitated their shape. The package had to do it that way -- it builds with
no host present -- so three PHPStan ignore entries existed to describe
what the type system could not see. They are gone.

ModuleBoundaryTest asserted "branding is cloud-only, and the suite runs as
community", which was never what it was testing. It now reads the
capability off the route and subtracts it, so the invariant holds for
whichever module is installed.

The 43 branding strings arrived in all sixteen locales from the package's
own catalogues rather than being retranslated, and the package's are
pruned to the one string it still uses.

A hosted plan without branding subtracts branding.customize and
attribution.hide from the instance's environment. The row is never
deleted by that: a downgrade is usually an expired card rather than a
decision, and wiping somebody's artwork over a billing event is a loss
they would find weeks later with no way to know what it used to be.
Hiding reverses; deleting does not.
2026-08-28 13:27:10 -03:00
ignacionelson afc2c74617 Say who depends on the activity log never being pruned
last_staff_login_at is a MAX() over activity_log, and the docblock
already said the log is never pruned. It did not say that anything
depends on it. Something does now: the hosted platform warns, pauses and
finally removes a free instance nobody has signed in to, counting from
this field.

So retention or pruning added to activity_log would break nothing here --
every test would pass, the field would keep answering, and old
installations would quietly start looking dormant to the process that
deletes them. That is the shape of failure worth naming in advance,
because the person adding a retention policy would have no reason to look
at this file.

Same note as the one on SeatAllowance's counting rules and on
ManagedStorageBackend::describe(): an assumption with a reader outside
this repository is a contract, and the place to record it is where
somebody would otherwise change it.
2026-08-28 12:11:29 -03:00
ignacionelson d62c62f788 Let an installation say which build it is
A version string is a decision somebody made. A commit is a fact, and the
two come apart exactly when it matters: an image built from the tag and
one built from the branch that tag sits on carry the same version and
different code. The fleet spent a day reporting 2.2.0 from images that
were not the released 2.2.0, and nothing inside any of them could have
said so -- which is why 2.2.1 was cut for a control plane rather than for
users.

So every artifact now carries config/build.php, written by
build-release.sh and never committed, and projectsend:status reports it
as `build`: the commit, the ref it describes to, the channel and the
build time.

All four are null on a source checkout, because there is no such file
there. That is the honest answer rather than a missing one -- "I was not
built" and "I will not say" are different facts, and this file's whole
null discipline exists because a reader that cannot tell them apart
eventually acts on the wrong one. An empty string is treated as no
answer for the same reason: a build step that ran and produced nothing
must not read as "answered" to anything checking presence.
2026-08-28 11:57:43 -03:00
ignacionelson 06c364d29a Report storage, health and what packages loaded in projectsend:status
Five more facts for whatever watches an installation from outside the
container, and one seam so a package can add its own.

Storage is the one that was about to be wrong. It is summed from the rows
that record it, not measured on the volume: measuring the directory was
correct until external storage went live and silently stopped being, since
an upload that resolves to a bucket leaves nothing on disk to measure. A
figure taken from the filesystem freezes while the account keeps filling,
and on a managed installation that figure is what a customer is shown and
billed against. `by_disk` splits the same sum by where the bytes went,
which is the only way to see what is still sitting locally from before a
cutover. Trashed files are excluded because they hold no bytes -- File's
deleted hook takes them.

Health is what a container cannot show from outside. A queue worker dying
is invisible to anything watching the process: it is still up, and zips
quietly stop building while mail stops going out. Same for a deploy whose
migrations failed -- the application answers every request and is a schema
behind. An unreachable queue reports null rather than zero, because an
unreachable Redis is not an empty queue and reading the second as the
first is how a dead worker looks healthy.

The two-factor enforcement setting is echoed back the way EnforceTwoFactor
reads it, fallback included: reporting a stricter rule than the middleware
actually applies would be worse than reporting none.

And ResolvingInstallationStatus, so a package can report what core cannot
know. The managed storage backend and the version of the package providing
it live in cloud-modules, which this repository must not reference, and a
platform that writes eight environment variables only ever knows what it
asked for. Those came apart once: a bucket provisioned, a token minted,
every variable correct, and an image whose copy of the package predated
the module that reads them. Files went to local disk with the
configuration sitting perfectly right beside them.

Two shapes are cast to objects deliberately. An empty PHP array encodes as
[], so an installation with no packages -- or holding no files -- would
answer a map-shaped field with a list, and a reader unmarshalling it
breaks on the day it happens to be empty rather than the day it is
written. There is a test for each.

Requested by the ProjectSend Cloud control plane, whose storage figure
stops growing the moment a tenant's uploads start reaching the bucket.
2026-08-28 01:32:25 -03:00
ignacionelson 73d93495c9 Report the last staff sign-in in projectsend:status
A platform can see that an installation is running. It cannot see
whether anybody is still using it, and the difference is what separates
a customer from an abandoned free instance holding a database.

So the status probe gains one field:

    "activity": { "last_staff_login_at": "2026-08-24T21:13:32+00:00" }

Null means no staff account has ever signed in, and the key is emitted
either way. That is the whole care in this change: "they said never" and
"we got no answer" have to stay distinguishable, because collapsing them
is how a broken probe reads as a dormant fleet.

Only interactive sign-ins count. Laravel's Login event does not fire for
token authentication, so an integration polling every hour cannot make
an empty installation look busy -- which matters when the reading is
used to decide something.

Derived from the activity log rather than denormalised onto users. A
column would cost a migration, a listener change and a backfill to save
one indexed MAX() over a table with a handful of rows on exactly the
installations anybody asks this about. Nothing prunes the log, and
erasure anonymises entries rather than removing them -- actor_type
survives on purpose -- so the answer does not change when the person who
gave it is forgotten.

Requested by the ProjectSend Cloud control plane, which has no other way
to learn the date. Recorded in docs/api-todo.md as deliberately a
command rather than an endpoint, for the reason the command exists at
all: it observes, it does not accept instructions.
2026-08-27 22:58:47 -03:00
ignacionelson 787e9ec189 Report version, edition, capabilities and seat usage as one probe
Asked for by the platform side, and the reason is better than
convenience. Their reconciler's rule is that it observes an end state and
never sends an instruction. `docker exec … php -r '…'` to reach a public
method is an instruction with the caller's argv in it, however harmless
the argv, and it would have been the first crack in that rule. A named
command is an observation, the same kind of thing as reading a directory
size.

`--json` for a machine, plain lines for a person. Nothing here is a
secret or a credential: every field is already visible to any signed-in
administrator, which is what makes it safe to read from outside the
container.

The counts come from SeatAllowance — the code that refuses the account
past the limit — rather than from a second query that agrees with it
today. Two counts that merely agree diverge eventually, over an inactive
account or a soft-deleted one, and the divergence reads as a billing
fault rather than a counting one.

Unlimited is emitted as null, with a test saying so, because the failure
if a reader takes it for zero is a customer on the most expensive plan
whose instance refuses to create a single client. The platform side
independently landed the same care on the emitting end, omitting the
variable rather than sending it empty.

It also answers the question that started all of this. Diagnosing why a
tenant ignored its bucket meant reaching into a container and calling
app() by hand; `projectsend:status` now says which capabilities the
edition grants, which is where that hunt began.
2026-08-27 02:40:28 -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 1b6513f0fb Stop container detection from taking the dashboard down on shared hosting
Deciding which update instructions to print starts with asking whether we
are running in a container, and that question is asked by looking for the
file a container runtime leaves in the root of the filesystem. Shared
hosting confines PHP to the webspace with open_basedir, where looking
outside it is a warning rather than a false — and the framework's error
handler turns warnings into exceptions, so the probe threw instead of
answering. The dashboard is the one page that asks, so it returned a 500
while everything else worked (#1663).

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

The dashboard was only the first symptom. updateNotice() reaches the same
call on every Inertia response once a newer release exists, and
RunningCodeState reaches it whenever the applied and running versions
disagree — so the next release, or the host's next update attempt, would
have taken every page rather than one.
2026-08-21 12:03:23 -03:00
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