Commit Graph

496 Commits

Author SHA1 Message Date
ignacionelson ca85c8a7e3 Translate the storage rows and the password dialog's rate-limit message
Eight strings, in all sixteen locales: the seven the System card gained
in #1794 (where files are stored, what is free, the temporary upload
space and why it exists), and the password dialog's "Too many attempts",
which until now was only in Spanish.
2026-09-21 22:47:50 -03:00
ignacionelson 42721b1bab Merge pull request #1794 from JensS/fix/storage-capacity-display
Show object storage and temporary upload capacity separately
2026-09-21 21:49:24 -03:00
ignacionelson ac5803b773 Merge pull request #1792 from JensS/fix/concurrent-upload-reservations
Fix premature 413 errors for concurrent upload chunks
2026-09-21 18:37:45 -03:00
ignacionelson 86edbc640d Remove the duplicate custom-pr-sign-comment that broke the CLA workflow
c9a4b552 added custom-pr-sign-comment to close a hole that was not
there: the key was already set further down the same block, with the
same value, and has been since 2.0.0. The action was already comparing
the whole comment, so a comment wrapping the phrase in other text was
never recorded as a signature, and loosening the job filter in
0a7330d5 opened nothing. The second copy made the file invalid, and
GitHub stopped running the CLA check at all.

This removes the copy and says at the original why it is set, since it
repeats the action's default phrase and looks removable.
2026-09-21 18:26:43 -03:00
ignacionelson c9a4b5520d Only record a CLA signature when the comment is the phrase
The action, left to its default, searches a comment for the signing
phrase, so a single line such as "I LIE, I have read the CLA Document
and I hereby sign the CLA. I do not sign it" was recorded as a
signature. The exact match in the job filter used to block that, but
only by accident, and it also blocked @JensS's real signature, which
had line breaks after it. Loosening that filter in 0a7330d5 let both
through.

custom-pr-sign-comment makes the action compare the whole comment,
trimmed and lowercased, against the phrase. Trailing line breaks still
sign; anything else around the phrase does not. The job filter stays
loose, since it only decides whether a runner starts.
2026-09-21 18:25:14 -03:00
ignacionelson 0a7330d5cd Accept a CLA signature that has line breaks after it
The CLA job only ran for a comment exactly equal to the signing phrase.
@JensS signed on #1792 with the phrase followed by line breaks
("...sign the CLA\r\n\r\n\n"), the comparison failed, the job was
skipped, and the signature was never recorded, so all three of his pull
requests still show the CLA as unsigned.

The action itself matches the phrase loosely. The filter in front of it
now does too: contains() for the signature, startsWith() for recheck,
both case-insensitive. It still keeps the job off ordinary comments,
which is what it is there for.
2026-09-21 18:21:04 -03:00
ignacionelson 3a3fd5358d Let directory accounts confirm their password
The confirm-password screen hid its field from every account that was
not Local, and told it to set a password instead. That is right for an
account a provider created, which has no password anybody has seen. It
is wrong for a directory account: its password is the directory's,
PasswordVerification accepts it, and /settings/password refuses to let
it set another. So an LDAP account could not get past the confirmation
at all, and everything behind it, turning on two-factor included, was
out of reach. The new dialog copied the same question.

Both now ask whether the account came from a provider, and the prop is
called has_password, which is what it means. The dialog also clears
the typed password when it closes or once it has been used, instead of
keeping it in component state.
2026-09-21 18:13:24 -03:00
ignacionelson a45eae315c Ask for the password over the page instead of throwing the form away
password.confirm redirected every write to the confirm-password screen.
A redirect cannot carry a POST body, and Redirector::guest() only
remembers the exact URL of a GET, so after confirming, the user landed
back on an empty form and the action never ran. On the API token forms
that meant typing the name, the scopes and the expiry again.

An Inertia request now gets a 423 marked X-Password-Confirmation. A
dialog mounted around every page catches it, asks for the password over
the current page, and sends the refused request again with the same data
and callbacks, so the form finishes as if nothing happened. The check
itself is still the framework's. Plain form posts and JSON clients are
answered as before, and accounts with no local password are offered a
way to set one, as the confirm screen does.
2026-09-21 18:05:54 -03:00
Jens 5902e7ab32 Report actual file storage and temporary upload capacity separately 2026-09-20 17:58:08 +02:00
Jens d3f213da16 Fix premature 413 responses for concurrent upload chunks 2026-09-20 16:46:00 +02:00
ignacionelson 60171799e7 Keep "No folder" inside the client's own folder, not the library root
Reported by binghuo. With per-client folders switched on, a client editing
one of their own files could choose "No folder" and the file left their
home for the root of the library — beside the staff folders, where the
administrator's own things are. The feature exists precisely to stop that
mess, and the editor was the one door still open to it.

Uploading resolves an absent folder to the client's home, and so does
creating a folder without naming a parent. The portal's file editor did
not, so the rule held on two paths out of three.

Now it holds on all three: update() resolves a null folder to the home
where the installation gives them one, and the editor stops offering "No
folder" at all in that case — there is no such place for this client — and
preselects their home for a file that has none.

An installation with the setting off is unchanged: no folder still means
no folder, because there every client's file sits at the root.
2026-09-20 10:44:25 -03:00
ignacionelson 849ec5f3e8 Release 2.5.0 v2.5.0 2026-09-18 16:37:32 -03:00
ignacionelson 4905be8e32 Give the bucket folder only to the driver that reads it
Reported by @veenone (#1788). Setting "folder inside the bucket" on S3 or
an S3-compatible backend made every page that touches storage answer 500,
including the orphan-files screen. Reproduced against MinIO: the disk
resolved into

  Class "League\Flysystem\PathPrefixing\PathPrefixedAdapter" not found

One folder on the screen, two names underneath: Laravel's own drivers
read `root`, and this application's GCS driver reads `prefix` and builds
its adapter with it. Setting both looked like a harmless way to serve
both, and was not — FilesystemManager wraps any disk carrying a non-empty
`prefix` in that adapter, which lives in an optional package nobody
installs. So the key meant for GCS broke S3, and only once somebody set a
folder.

`prefix` now goes to the GCS driver alone, and both keys are written on
every apply rather than only when there is a folder — a process that
switched provider or cleared the field kept a stale one otherwise.

Verified against MinIO with a folder set: the orphans screen loads, and an
upload lands inside the folder rather than at the root of the bucket.
2026-09-18 15:08:42 -03:00
ignacionelson cf1cd3ab9a Say on the Microsoft screen why accounts still wait for approval
Reported by Ricardo Cazati, who ticked "create an account on first
sign-in" and "approve those accounts automatically", named his domain,
and then found every colleague's account sitting in the approval queue
with nothing anywhere saying why.

The rule is right and stays: an address the provider has not confirmed
goes to the queue whatever the box says, because inside one Entra tenant
a colleague can present somebody else's address. What was missing is that
Microsoft confirms nothing until `xms_edov` is added to the app
registration — an administrator configuring their own company directory
reads "verified" as already true of it.

The tenant-ID field mentioned that claim, but only for the half about
never attaching to an existing account. The other half — that this is
also why auto-approval does nothing — now sits under the auto-approve
checkbox itself, where the promise is made, and only when the three
settings that produce the surprise are all on.
2026-09-18 13:49:50 -03:00
ignacionelson 51035994ae Give a client the public link the switch already promised
Reported by Ricardo Cazati. A client with every file permission could
mark their own file public — and was then shown nothing. The screen said
"anyone with the link will be able to open and download it" while the
only route that makes a link was staff-only, so the link existed for
nobody. Version 1 could do this.

Making one now asks `upload_public`, the same key that lets them mark the
file public, and `update` on the file, which for a client means one they
uploaded and nothing else. Staff are not asked for the key, as they never
have been: `update` is their boundary and asking now would be a new
refusal on every installation that upgrades.

Revoking deliberately does not ask for the publishing key. It takes
access away, and somebody whose permission to publish was withdrawn must
still be able to undo what they published.

The portal's file editor grows the section the staff screen has, minus
what a client has no business setting: the link, a copy button, and
revoke.
2026-09-18 13:37:25 -03:00
ignacionelson ff9ad10742 Let an account that signs in through a provider set a password
Reported by Ricardo Cazati, who had to turn compulsory two-factor off to
get his colleagues working.

An account provisioned by a provider carries a generated password nobody
has ever seen. The password screen asked for the current one before it
would set a new one, so those accounts could never have a password of
their own — and enrolling in two-factor is behind a password
confirmation, so they could not enrol either. With
`TwoFactorEnforcement` set, the enforcement middleware sent them to
enrol, enrolling sent them to confirm a password they do not have, and
every other screen — including the one that would have given them one —
redirected back. No way in and no way out.

- The password screen asks for the current one only where there is one,
  and says "Set a password" otherwise. Setting it moves the account to
  `local`, the line NewPasswordController already writes when such an
  account resets its password: the hash is now what signs it in, and the
  settings screens read that off this column.
- An LDAP account is refused outright rather than handed a password that
  signs nothing in — its password lives in the directory.
- The enforcement middleware lets the password screen through, the way it
  already lets the confirm-password screen through, so the loop has an
  exit.
- The confirm-password screen offers to set one instead of asking for a
  password that does not exist.
2026-09-18 13:16:20 -03:00
ignacionelson 305c79bc96 Keep an invitation inside the sender's own client scope
Reported by @hackchang (GHSA-c6h9-hcm7-j3x9). GHSA-r3hg-3fxw-rcmr scoped
the group controllers; invitations were written afterwards and were not,
so the same reach was open through a different door.

A client-scoped staff member with `create_clients` could read every
group's id and name off the invitation form, name any of them on an
invitation, and have the invited client added to it at redemption — a
group whose files they cannot see and whose members are not theirs. The
ordinary way to do that, adding a member to a group, refuses on
StaffLibraryScope::allowsGroupMembership(); the invitation path never
asked.

Three places, because the hole had three halves:

- the form lists `$this->scope->groups($viewer)`, as GroupsController
  already does;
- the request is validated against those groups rather than every group
  there is, since a request need not come from the form;
- redemption asks allowsGroupMembership() of the invitation's sender
  before writing the membership.

The last one is the one that matters. An invitation is a grant that lands
days later, when the sender is not present to be checked, and the ones
written before today are still outstanding. A refused membership is
dropped and logged rather than failing the redemption: the account is
what the person holding the link came for, and it is theirs either way.
An invitation whose sender has since been deleted keeps its group — there
is no longer a reach to exceed.
2026-09-18 03:35:14 -03:00
ignacionelson 521927a3bb Note the public unscanned notice in 2.5.0, and date it today 2026-09-18 02:09:11 -03:00
Ignacio Nelson 8fdc8b0102 Merge pull request #1787 from projectsend/public-unscanned-notice
Tell whoever opens a public link that nothing checked the file
2026-09-18 02:08:57 -03:00
ignacionelson 7ce1e3487f Tell whoever opens a public link that nothing checked the file
An installation that scans can still let files through: too large for the
scanner, an archive it could not open, or an upload that arrived while the
scanner was down. Both policies default to letting those through, and the
count of them is on the settings screen and the dashboard.

Everyone could see that except the one person it matters to. The uploader
sees the state on their own file and staff see it in the library; whoever
follows a public link saw the page a file that passed gets, having neither
chosen the policy nor any way to see the setting. On the hosted free plan,
where every upload is published behind a link, that is the whole audience.

The share page and the public file page in all four themes now carry one
line: "This file was not checked for viruses." Said plainly and without
alarm — nothing is known to be wrong with the file; what is known is that
nothing looked.

Only where this installation scans, and only for the three reasons that
mean a scanner let something past. A file from before scanning was
switched on says nothing: on an installation that has only just switched
it on that is every file, and saying it about all of them says it about
none of them.
2026-09-18 01:58:36 -03:00
ignacionelson 419ecea3b0 Add the scanner's encrypted-archive check and file expiry dates to 2.5.0 2026-09-17 13:17:31 -03:00
ignacionelson 7762755a2d Write the 2.5.0 changelog entry
Seven features since 2.4.1, so the middle number moves: virus scanning,
client invitations, client account expiry, start pages, per-client folders,
the library filters and the missing-from-storage report. Three fixes to
behaviour that shipped in 2.4.1, plus a dependency advisory.

One line per item, which is the 2.4.1 shape rather than the paragraphs
older entries carry. Virus scanning is six lines rather than one long one --
splitting is how a big feature fits the format.

No "Important -- do these yourself" section, and that was checked rather
than assumed: virus scanning and per-client folders both default to off,
the three new scheduled commands run on their own, and the six migrations
need nothing from anybody. Upgrading leaves nothing working differently
from how you expect.

The date is today's. Whoever cuts the release restamps it if it lands on
another day.
2026-09-17 13:12:58 -03:00
Ignacio Nelson 62c2ddfdd3 Merge pull request #1785 from projectsend/shared-file-lifetime
Show clients when a file expires, and let a package speak on the upload page
2026-09-17 13:07:36 -03:00
ignacionelson 99c8c469c5 Show clients when a file expires, and let a package speak on the upload page
Client file rows in My files now carry expires_at, and all four portal
themes show "Available until <date>" beside the size, date and download
count. Before, the date was only on the file's edit screen, so a file
about to disappear gave no sign of it where the client looks for it.

The portal upload page dispatches ResolvingUploadNotice, handing the
uploader to listeners, and shows any lines they add above the uploader.
The upload page is one page for every theme, so this is the only place a
rule about uploads can be read before one happens; the announcement band
is drawn by one theme's shell and would miss the other three. With
nothing listening it shows nothing. First caller: cloud-modules, telling
free-plan customers how long uploads are kept.
2026-09-17 12:18:03 -03:00
ignacionelson 963e36397d Keep test tooling out of a production install in the migration guide
composer require and composer remove both update the rest of the
dependencies, and without --update-no-dev they install ProjectSend's
development and test tools too. The Docker section already said so; the
release-zip install and the removal step did not. The removal step also
now says how to run it on the Docker image.
2026-09-17 11:43:01 -03:00
ignacionelson ba99674cc7 Count files going out unscanned, not log entries about them
The dashboard warned that 62 files had been let through unscanned in the
last day. They were 62 activity log entries for 16 files, and none of
those files could be downloaded: 50 entries were for files now missing
from storage, and 12 for files since deleted. The count read the log, so
it counted a file once per attempt and kept counting it after it was
deleted, went missing or was scanned clean.

It now counts files in their current state: not scanned, let through
while the scanner was down or because it could not open them, with that
verdict in the last day. The settings screen already counted this way
without the time limit; the rule is one File scope used by the
dashboard, the settings screen and projectsend:status. The status key
keeps its name and meaning, and is now accurate.
2026-09-17 11:09:40 -03:00
ignacionelson 783536be40 Show a passing scanner test in green
A failed test was red and a passing one was a plain box, so the answer
somebody pressed the button for was the one that did not stand out.
2026-09-17 11:04:23 -03:00
ignacionelson 3b5352d886 Name EICAR in the scanner test, and say it is harmless
"Detected the test file" left it open what the file was, which reads
like ProjectSend carrying something malicious. The Test button's
description and both of its results now name EICAR and say it is a
harmless file made only for testing, as do DOCKER.md and INSTALL.md.
2026-09-17 11:01:18 -03:00
Ignacio Nelson 6e5edfa7ad Merge pull request #1784 from projectsend/virus-scanning-fixes
Close the gaps an end-to-end and security pass found in virus scanning
2026-09-17 03:08:51 -03:00
ignacionelson c15c9c48f8 Close the gaps an end-to-end and security pass found in virus scanning
Run against the dev stack with real ClamAV and queue workers, and a code
review looking for ways around the scanner.

Quarantine now stays quarantined until somebody releases the file. A
rescan only touches files people can download, and changes nothing when
the scanner cannot answer or scanning is off. Before, an old infected file
rescanned while clamd restarted went through the "allow" policy and became
downloadable. The daily missing-files check leaves quarantined files alone,
so a storage outage no longer brings one back as a fresh upload.

A file longer than clamd's StreamMaxLength is "too large" again. clamd
answers and hangs up; the next write raised a warning that became an
exception before the answer was read, so the file was recorded as
"scanner down" and retried past the unscannable policy.

The production compose example gives clamd the settings it needs. On its
own defaults an encrypted zip comes back clean. The Test button now sends a
password-protected zip and fails when it is called clean, and says when an
address answers but is not ClamAV.

Saving the settings restarts the queue workers, which kept the old values
in memory. New scan runs --all, as its name says, and is refused while
scans are queued. A retry scheduled for later no longer counts as a scan
in progress.

Also: quarantine respects client scope for listing, release and
notifications; a zip built before a file was quarantined is refused;
public comments and version links skip unavailable files; a client no
longer sees their own quarantined or missing upload; a file whose bytes
return is scanned at once; clamd listens on IPv6 too, so its container
health check passes.
2026-09-17 02:48:03 -03:00
ignacionelson 5e6b792104 Translate the 155 strings that have accumulated since 2.4.1
Virus scanning and the missing-from-storage report account for most of
them; the rest are the library filters and the per-client folder setting.
Every locale was at 0 missing before this and is at 0 missing again.

Notes for whoever reads this next:

- The Slavic locales keep the `Label: :count` shape rather than `:count
  plików`, because Polish, Czech and Russian inflect the noun according to
  the number in front of it and no single form is right for every value.
  That is the existing convention in those files, not a new one.
- "Scanner" is now counted as untranslated in German and Dutch. It is the
  correct word in both, the same way API and OK are elsewhere.
- Russian stays on the formal вы/ваш the rest of that catalogue uses --
  152 entries to 0 before this, and unchanged after.

Checked in a browser with the locale switched, not only in the file: the
client settings screen reads correctly in Spanish and the long
folder-setting description fits its column. A catalogue that parses is not
evidence that a sentence reads well.
2026-09-17 01:07:47 -03:00
ignacionelson 616a355d54 Give each client a folder of their own, standing in for the root
A client who may create folders creates them at the top of the library,
beside the ones staff made, and their uploads land at the root too. An
administrator opening /files gets one flat pile with nothing saying which
parts belong to whom.

With the new "Give each client a folder of their own" setting, every new
client gets a folder named after them and it acts as their root: what they
upload and any folder they create goes inside it. /files becomes a list of
clients rather than a pile.

The sentence this feature has to keep true: **the home is a default
location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a
folder staff shared with a client still reaches them and sits beside their
own. Making the home a jail would have silently revoked every share that
already exists -- a data-access change wearing the clothes of a tidying-up
feature. There is a test named after that rule.

What the client sees is the *inside* of their folder, not a folder wearing
their own name, which is not information to them. The breadcrumb is trimmed
of it for the same reason: "Invoices", not "Acme Ltd / Invoices".

Some decisions worth naming:

- **A column, not a convention.** `folders.home_for_user_id`, unique.
  Matching on the name breaks the moment two clients share one, and
  `created_by` plus a null parent catches every root folder a client ever
  made themselves. The question is asked on each upload and each portal
  listing and the answer has to be exact.
- **created_by is the client**, because that is how scopeVisibleToClient
  already grants somebody their own folder -- no assignment row to keep in
  step with it. That is also why this writes the row rather than calling
  FolderService::create(), which takes created_by from auth()->id().
- **On model events**, not in the services that make and rename clients.
  There are nine of those (ClientAccounts, ClientProvisioning, the profile
  screen, two update endpoints, AccountConversion, invitations, LDAP,
  social) and a rule repeated in nine places is missing from the tenth.
- **Turning the setting on creates nothing.** Existing clients get a folder
  when an administrator presses a button that says how many are waiting,
  and it reports created/total/already-had afterwards. Somebody should be
  able to switch this on, look, and switch it off without having
  reorganised a library. It moves no files either.
- **Nobody deletes a home from a folder screen**, staff included, and the
  client cannot rename theirs -- they own it, so ownership alone would have
  let them, and its name follows the account anyway.
- **The name always follows the client**, over a hand-typed one. A folder
  still called "Acme Ltd" under an account now called something else
  misleads the administrator the feature exists for.

Verified in a real browser as well as in tests: the screen mounts, the
panel reads "24 of your existing clients have no folder yet", and pressing
the button answers "24 of 24 clients got a folder. 0 already had one."
2026-09-17 00:22:29 -03:00
Ignacio Nelson 044afe5fcb Merge pull request #1783 from projectsend/virus-scanning
Scan uploaded files for viruses
2026-09-17 00:01:47 -03:00
ignacionelson a255a883a8 Merge remote-tracking branch 'origin/main' into virus-scanning
# Conflicts:
#	tests/Feature/Platform/SchedulerMonitoringTest.php
2026-09-16 23:57:19 -03:00
ignacionelson 5f7e3089eb Stop offering a file nobody can have
A quarantined file was listed in the library with every button a working
file has, and Download answered with an error page. Three changes, all
the same idea: do not offer what cannot be done.

The library no longer lists a file that is quarantined or missing from
storage. Those two live on the screens that exist to act on them —
Quarantine, and Files missing from storage — and both now link each row
to the file itself, which is where somebody deciding needs to look.

That page says why, at the top, in the colour the state deserves: red
for a threat, amber for bytes that are gone. And it stops offering the
download and the preview, because a button that answers 423 is not an
affordance.

A file still being checked stays in the library. It is about to be
usable, and its uploader should be able to see where it went.
2026-09-16 23:51:33 -03:00
ignacionelson 7119435c3c Make "New scan" mean a new scan, and colour a result by what it is
The button was disabled on a library that had already been scanned
once, which is most of the time and exactly when somebody would press
it — after updating definitions, say. It now re-checks everything
rather than only what was never looked at, which is what its name says.
A rescan keeps each file available until its new verdict arrives, so a
full pass takes nothing offline. The two states it skips are a file
already waiting for its first verdict and one whose bytes are gone.

It is disabled for two honest reasons now — scanning is off, or a scan
is already running — and says which.

Results are green, amber and red: checked and fine, checked and could
not be read, checked and something was found. The badge gained a
warning variant to say the middle one, matching the amber the warning
alert already uses; before this a missing file wore the same red as a
virus.

A file found missing is also stamped with the time it was checked, so
it appears in the Activity list. It is a verdict like any other, and
without the stamp it was decided somewhere nobody could see.
2026-09-16 23:20:34 -03:00
ignacionelson 25b92c086b Refuse a scanner address that only looks like one
tcp://clamav:3310djlkasjdlk connected happily. PHP reads a port the way
atoi does — the digits at the front, the rest ignored — so an address
with a typo on the end was saved, tested, and reported as working, while
tcp://clamav:33101 went somewhere else and failed. The feedback an
operator got had nothing to do with the mistake they made.

ScannerAddress says what an address is: tcp:// with a host and a port of
1 to 65535 and nothing after it, or unix:// with an absolute path. It is
asked in all three places an address arrives — saving, testing, and
connecting. The third matters because a managed address comes from the
environment and never passes the screen.

The answer names the problem rather than reporting "no answer", which
would be true of any unreachable scanner and would send somebody to look
at their network for a typo.

Checked in a browser with both addresses from the report: each is now
refused on Test and on Save, with the same sentence, and
tcp://clamav:3310 still comes back "Working. ClamAV 1.5.4 detected the
test file".
2026-09-16 23:09:24 -03:00
ignacionelson afb4c2c6d4 Test the address on screen, and count the quarantine in the sidebar
The Test button asked the scanner on file, which makes it useless at
the moment it is most needed: the first attempt, before anything has
been saved. It now tries what is typed, falling back to the stored
address when the field is empty so the button still answers on a screen
nobody has touched. Nothing is written either way — testing is not
saving.

The address travels as a request field and is applied to the request's
own ScanningConfig, which is scoped so the screen and the scanner it
resolves share one. A preview address beats even a managed one, and is
set in exactly one place.

Quarantine now carries a count in the sidebar, like Comments — in amber
rather than the usual colour, because the others count work waiting and
this one counts something that went wrong. Shown only to whoever holds
the permission to act on it.

Both checked in a browser: an address typed and not saved came back
"No answer from tcp://escrito-a-mano.invalid:3310", the stored one
untouched, and the badge renders amber with the real count.
2026-09-16 22:57:58 -03:00
ignacionelson 0b36cf2c38 Put the connection test under the address it tests
It sat above the form, which read as a box about the screen rather than
about the field. It belongs under "Scanner address" and above Save,
where it is plainly the answer to "is this address right?".

The box is now shown only where there is something to test — an
installation that connects its own scanner. On a hosted one the endpoint
answers 403, and a button that leads to a refusal is worse than no
button. That is a separate prop from `managed`, which covers two
different reasons the address is not editable: a scanner named in the
environment still has a connection worth testing.
2026-09-16 22:54:24 -03:00
ignacionelson d445b01dd4 Make starting a scan a button in the header, and drop the box it lived in
"New scan" sits beside Quarantine as the screen's one action, in the
primary colour, on every tab. The "Files already here" box it replaces
is gone: it held an action under a Save button, a counter that the
Activity tab now shows live, and a field that belongs with the other
settings, which is where it is now.

The button says why when it cannot be pressed — scanning is off, every
file has already been checked, or a scan is already running — rather
than sitting grey with no explanation. It refuses a second scan while
one is working through the queue, which is a thing somebody would
otherwise do by clicking twice.

Starting one lands on the Activity tab. A button whose screen looks
unchanged afterwards reads as a button that did nothing, and this one
has somewhere worth looking.

Checked against a real ClamAV: 42 files queued, 31 came back clean, the
rest were the ones whose bytes are missing. The button then disabled
itself, because there was nothing left to scan.
2026-09-16 22:52:08 -03:00
ignacionelson f937b4398d Tell a missing file apart from a missing scanner, and do something about it
A row whose bytes are gone was recorded as "the scanner could not be
reached". Wrong on screen, and wrong underneath: that is the one reason
the hourly sweep re-queues, so every orphaned row would have been
rescanned hourly forever.

It is its own state now, `missing`, and withheld rather than offered:
a client who sees a file listed and gets an error on the download is
worse off than one who never saw it. Staff still see it, marked, which
is the point — somebody has to decide what to do about it. The refusal
says what it is ("no longer on the server") instead of sending somebody
looking for a permission that would let them through.

A daily `projectsend:check-missing-files` finds them, whether or not
this installation scans for viruses: it is not a virus question, and an
installation with no scanner has exactly the same problem. It compares
one disk listing against the rows rather than asking "does this exist?"
per file, which on object storage would be a request per file per day.
Files that come back — a remount, a restored backup — are picked up on
the next run and re-checked rather than left for dead.

They are listed beside the orphans, which is the same fault seen from
the other end: bytes with no row, rows with no bytes. The tab carries
the count, each row says where the file should be, and removing one
takes the record with it through the deletion that already exists.

The dashboard says how many there are, and so does
`projectsend:status`, because a fleet-wide jump in this is a storage
fault nothing else in that document would show.
2026-09-16 22:46:21 -03:00
ignacionelson dc0937fda1 Add an Activity tab that shows a scan as it happens
A backfill runs for minutes or hours inside a queue worker, where none
of it is visible. The third tab polls every four seconds and says what
is happening: whether anything is running, how many uploads are held,
how deep the queue is, how many files were checked in the last hour,
and the last twenty verdicts with what each one was. When nothing is
running, that same list is the record of the last run, which is what
somebody opening the tab after the fact came for.

Two things the live screen found that the tests had not:

**A backfill read as "nothing is being scanned."** Re-scanning a file
that already went out unchecked deliberately leaves it available, so it
is never "pending" — and the screen counted only pending files. It
counts the scans queue too, and the two are shown separately, because
"an upload nobody can download yet" and "work the scanner has not
reached" are different facts.

**A file whose bytes are missing was recorded as "the scanner could not
be reached."** Wrong on screen, and worse than wrong in behaviour: that
is the one reason the hourly sweep re-queues, so every orphaned row
would have been rescanned every hour forever. It has its own reason
now, and goes through the same policy as a file the scanner could not
open.

Both tabs also gained the header shortcut to Quarantine, and Quarantine
one back to the settings, each shown only to somebody the destination
will actually let in.
2026-09-16 20:18:37 -03:00
ignacionelson c2039d9608 Find the files a real upgrade leaves behind
"Scan existing files" sat disabled on an installation with a library of
144 of them, and the hourly backfill would have found none either. Both
looked for `scan_note = 'before_scanning'`, and no file on any upgraded
installation carries it: the migration gives `scan_status` its default
and writes no note, and the v1 import inserts rows the same way. The
feature was inert on exactly the libraries it exists for.

A file with no reason beside its "not scanned" is now what it plainly
is — one nothing has ever looked at — through File::neverScanned(),
which the backfill, the counts and the badge all ask. Reported as
"Uploaded before virus scanning was switched on" rather than as a bare
"Not scanned", which is the one badge somebody would have had to come
and ask about.

Found on the real screen, not by a test. The tests now cover the shape
an upgrade actually produces.
2026-09-16 20:11:08 -03:00
ignacionelson da79969435 Put virus scanning on the System card as a line, not only as a warning
"Uploads checked by: ClamAV 1.5.4" now sits beside "Downloads sent by"
and "Files stored on", and is always there. Same reasoning those two
already carry: being able to confirm at a glance that uploads are
checked is worth as much as being told when they are not.

Four states in one row. A working scanner is named. One that is not
answering says so. One letting files through is amber. No scanner at
all reads "Nothing", amber, and links to the screen that sets it up —
which is where the "turn it on" link now lives, so the big alert above
is left to the cases where a configured scanner is misbehaving. Absent
entirely where the scanner is not this installation's to connect.

Also fixes a line the dashboard itself exposed: the activity log read
'The file "" was quarantined'. The scan job has no actor and attaches no
subject, so those two templates have to take the name from their
context, not from :subject. There is a test now, which there was not
before, because a real screen caught it and a green suite did not.
2026-09-16 15:41:47 -03:00
ignacionelson 85b1650ef0 Tell a self-hosted installation when nothing is checking its uploads
The dashboard's System card now says so when no scanner is configured
at all, not only when a configured one is failing: "Anything uploaded
here — by staff, by clients, or through an upload link — is passed on
unchecked", with a link to set it up.

Said only where somebody can act on it. Connecting a scanner is a new
capability, scanning.connect, community only — on a hosted installation
the scanner is infrastructure the platform runs, so its address is not a
tenant's to set and its absence is not a tenant's to fix. The two
policies stay on both editions, because what to do with a file nobody
could scan is a decision about somebody's own files. An edition
difference through the registry, never an edition check.

Also: PROJECTSEND_SCANNER_DEFAULT_ADDRESS, seeded into the settings on
first boot by the command that already does this for two-factor
enforcement. It is the opposite of PROJECTSEND_SCANNER_ADDRESS — a
starting value rather than a policy, so a Docker install that brings up
the optional scanner container arrives configured while the address and
the switch stay on the settings screen. Both are seeded together or
neither: an address with scanning off would look configured and check
nothing.

Nothing changes for an existing installation on upgrade: scanning stays
off, existing files are marked "never scanned", and the scanner
container is still opt-in.
2026-09-16 15:34:49 -03:00
ignacionelson f2a7bbb182 Split the virus scanning screen in two, and make the Test button answer
The Test button did nothing visible. The page read `scanner_test_result`
off the shared props, and HandleInertiaRequests shares `success` and
`error` and nothing else — so the answer was set on the session and
never arrived. It is read in the controller and handed over as a prop
now, the way the CAPTCHA screen does it.

The screen is two tabs, Scanner and Options, following the scheduler's
`?tab=` links. Scanner holds the connection and the Test button; Options
holds the policies and the backfill. Both end with their Save, and
nothing sits below it — before this, "Files already here" and its button
were stranded under the Save button of a form they had nothing to do
with.

Verified by clicking the real button in a browser against a real ClamAV:
"Working. ClamAV 1.5.4 detected the test file as Eicar-Test-Signature."
2026-09-16 15:08:41 -03:00
ignacionelson 73d5a5f8e4 Record which engine and definitions reached each verdict
`scan_engine` was always null: the column existed, the client never
filled it. Found by running a real ClamAV against a real upload rather
than by a test, since the fake scanner reports whatever it is told.

Asked once per scanner instance — so once per queue job, and the worker
is recycled hourly — rather than on every scan, which would double the
connections to answer a question that changes daily.
2026-09-16 15:02:54 -03:00
ignacionelson c0494c6b4b Explain virus scanning in both install guides
Docker gets the profile command, the memory it needs, and why the first
start is slow. A manual install gets the packages, the three clamd
settings without which an unopenable file comes back clean, and where
the socket usually lives. Both point at the Test button, because
"connected" and "detecting" are different answers.
2026-09-16 14:46:19 -03:00
ignacionelson 5493955bea Show staff where a file stands, and say the same through the API
Staff keep seeing every file they always saw — withholding is about
recipients, not about the library — so the library now carries the state
on the row: Checking, Quarantined, Released, or Not scanned with the
reason behind it. Nothing at all for a clean file, which is the common
case.

The API says the same in a `scan` object on every file, with an
`available` flag so a caller need not learn which of six states mean
"you can have it", and `scan_status` is a filter, so an integration can
wait for the file it just uploaded or collect what is in quarantine.
The download endpoint answers 423 for a file that is not available,
which it already did through the shared controller.

Re-exported the OpenAPI document.
2026-09-16 14:45:13 -03:00
ignacionelson 0ae3f3d0f0 Hold the "shared with you" email until the file can actually be had
Sharing a file that is still being checked writes the assignment and
says nothing. The announcement goes out when the file becomes
available — a clean scan, a file let through while the scanner was
down, or an administrator releasing it from quarantine — so nobody is
ever sent to a page that refuses them, and a file about to be
quarantined is not announced to everyone before anybody knows.

Recipients are derived from the assignments as they stand at that
moment, not remembered from the moment of sharing: a share taken back
in the meantime produces no email, and one added does. New-version
notices ride the same path, which they had to anyway — the audience
rule re-checks visibility, and a file being scanned is not visible.

Two bugs found while writing the tests, both in the hourly command:

Re-queuing a file marked it pending first. Pending means withheld, so
running --existing over a library that predates scanning would have
hidden every file in it from every client for as long as the backfill
ran, and then announced each one to its recipients a second time when
it came back. The job now knows which state it expects instead, and a
rescan leaves the file downloadable until a verdict actually arrives.
2026-09-16 14:41:59 -03:00