Commit Graph

509 Commits

Author SHA1 Message Date
ignacionelson a9b17ddc1e Release 2.6.0 v2.6.0 2026-09-25 15:00:17 -03:00
ignacionelson 24a94d3beb Translate the strings added in the 2026-09-25 issue run
Eight strings, in all sixteen locales: bulk delete's confirmation and its
error, the upload page's "Uploading into", and the Branding page's site-name
switch and logo size hint.
2026-09-25 02:50:18 -03:00
ignacionelson 27f994263f Label the bulk delete confirmation "Delete", not the permission name
"Delete files" is already the label of the delete_files permission, and
several locales translate it as a noun ("deletion of files"), which reads
wrongly on a button. "Delete" is translated as a verb everywhere.
2026-09-25 02:50:18 -03:00
ignacionelson 8180a66243 Drop two nullsafe operators PHPStan flags: ?? already covers a missing branding row 2026-09-25 02:49:03 -03:00
ignacionelson 1d483f6a81 Delete several files at once from the staff selection bar
The selection bar could zip and bulk-edit the ticked files, including
moving them to a folder, but deleting was one file at a time.

A Delete button now appears when at least one ticked file is one this
person may delete. It asks for confirmation and sends only those files.
The server asks each file the same question a single delete asks, through
FilePolicy, and gives each the same soft delete and the same FileDeleted
activity entry. A file the person may not delete is dropped from the
batch rather than failing it, as bulk edit already does. A batch with
nothing left to delete is a 422. The route sits before files/{file},
which would otherwise read "bulk-delete" as a file id.

Reported by @lolgufdHD (#1800)
2026-09-25 02:47:34 -03:00
ignacionelson bd26740390 Upload into the folder you are in, on the staff Files page
Inside a folder, Upload opened the upload page with no folder, so every
file landed at the top of the library and had to be moved. The client
portal already carried the folder; the staff page now does the same.

The upload page takes ?folder=, says "Uploading into <folder>", passes
it to the upload, and sends a multi-file upload back to that folder. The
same two checks as the portal's upload page: a folder outside the staff
member's library is a 404, so the page never confirms it exists, and one
they may not upload into is a 403. ChunkedUploadsController still checks
the destination again when the upload starts. While searching, the
button keeps uploading to the top, since results span folders.

Reported by @lolgufdHD (#1801)
2026-09-25 02:44:54 -03:00
ignacionelson 37c9cb839f Never remember a JSON request as the page to go back to
Saving a settings form could open Inertia's error dialog showing
{"count":0}. back() prefers the Referer and falls back to the URL the
session recorded last. Laravel records every GET not marked as Ajax, and
the notification bell's plain fetch() of its unread count is not marked,
so the poll became "the previous page". Where the Referer did not arrive,
because a proxy or a browser stripped it, the save redirected to
/notifications/unread-count, and Inertia rendered the JSON as an error.

The session middleware is swapped for a subclass that skips recording
when the request asked for JSON. The rule is about the request, not about
that one route: nothing that asked for JSON is a page anybody goes back
to. Inertia visits ask for HTML and are recorded as before, and the
middleware order is unchanged (the sorter matches the subclass by its
parent).

A test reproduces it: open the privacy form, poll the count the way the
bell does, and save with no Referer. It failed with a redirect to
/notifications/unread-count before this change.

Reported by @0xVavaldi (#1799)
2026-09-25 02:40:29 -03:00
ignacionelson 1b3f014f5f Show the logo larger on the sign-in pages, and let the site name appear under it
The sign-in, password reset, setup and share-link pages drew a custom logo
48 pixels tall, so a square logo was a 48x48 stamp. It now gets a box 80
pixels tall and up to 240 wide, so a square logo reads and a wide one still
fits a phone. ProjectSend's own logo is unchanged.

The site name appeared nowhere on those pages except as the image's alt
text. A new switch on Branding → Logo prints it under the logo. It is off
by default, because many logos already say the name and would then say it
twice. It is stored on the branding row, gated like the rest of the
screen (staff, edit_settings, branding.customize), and a withheld
capability takes it off the pages along with the logo. The branding API's
logo endpoint reports it as show_site_name.

The Logo tab now says what size to use and which formats are accepted.
SVG stays refused: it can carry script, and the logo is served from the
site's own origin. The crop tool the issue also asks for is not part of
this.

Reported by @jiits (#1798)
2026-09-25 02:38:47 -03:00
ignacionelson c795c58963 Use Pdo\Mysql::ATTR_SSL_CA, which PHP 8.5 no longer warns about
PHP 8.5 deprecated PDO::MYSQL_ATTR_SSL_CA, so loading config/database.php
printed two "Deprecated" warnings, one for each of the MySQL and MariaDB
connections. On a server that displays warnings, they appeared on every
page.

Pdo\Mysql::ATTR_SSL_CA exists since PHP 8.4, which is our minimum, so no
version check is needed. Checked by loading the real config file under
PHP 8.5 with pdo_mysql: the old file prints both warnings, and the new one
prints none and sets the same option.

Reported by @jiits (#1796)
2026-09-25 01:59:18 -03:00
ignacionelson acab833b72 Put the Docker quick start first, and fill the gaps a first install falls into
The README now opens its instructions before the screenshots, and says
what the quick start assumed: Docker Engine with Compose installed, your
own user in the docker group (so nobody reaches for sudo), and which
directory the commands run from.

The quick start also saved the file as compose.example.yaml and started
it with -f. Every later command in DOCKER.md, UPDATE.md and the migration
guide is a plain `docker compose ...`, which only finds a file called
compose.yaml, so each of them failed with "no configuration file
provided". It is now saved as compose.yaml, as the Docker Hub page
already said, with one line for people who kept the old name.

The migration guide covered "Legacy on this machine, ProjectSend in
Docker" in one sentence. It now has a worked route through a bundle. The
exporter runs with the host's own PHP, where Legacy's `localhost`
database really is local, so no container networking is needed. The
guide also says why Direct is harder there: the database, and hardlinks
that cannot cross a mount. Step 2 was run against a real v1 install on
the host (60 files, 63 MB).

Reported by @lukatong (#1635), with the install and migration gaps
pointed out by @jjoelc.
2026-09-25 01:34:38 -03:00
ignacionelson a150dc4955 Let a disk sign its links with a separate key
Downloads, previews and public links from managed storage have failed in
every browser since late August with the bucket's AccessDenied XML. The
platform pins each instance's R2 key to our servers' IP (portal 0125be7,
2026-08-26). Core started redirecting to signed URLs at about the same
time (57540164, d6fd5a91). A signed URL carries every restriction of the
key that signed it, so it worked from the server and nowhere else.

A disk can now name another disk in `signing_disk`, and links are signed
with that one. The platform gives it a read-only key without the IP pin.
The read-write key stays pinned. A name that points at no configured disk
is ignored, and the file's own disk signs as before. Uploads are
unaffected: they go through the server, and nothing else signs.
2026-09-24 22:56:00 -03:00
ignacionelson bccf3d1f29 Stop serving a self-deleted account's files, and let them go at once
Reported from the shared free instance. A client deleted their own account
on 2026-09-18. Their five files kept serving through share links with no
expiry for the whole 30-day grace period. Somebody who asked to leave
stayed published.

Rule 1: once an account is soft-deleted, its uploads are served to nobody
but staff. That covers the client scope (assignment, group, shared
folder), share links, the public listing, public comments and zips.
Staff keep them, because the grace period exists to undo a mistake.
Nothing is deleted and share links are kept, so a restored account is
served again. A withdrawn share link answers like a token that never
existed. In practice this only meets self-deleted accounts: an
administrator deleting an account that owns anything must already choose
to delete or reassign it.

Rule 2, new in Privacy settings: when someone deletes their own account,
their files are deleted "when the grace period ends" (the default, and
today's behaviour) or "right away". "Right away" uses
DeletedAccountContent's cascade: their own uploads, and their folders only
if nothing else is left inside. It runs in the same transaction as the
account delete. A platform can force "right away" through the new
ResolvingSelfDeletion hook. The screen then shows the choice as set by
the hosting plan instead of offering a switch.

A second setting decides whose deletion both rules apply to: any account
(the default) or clients only. A staff member's uploads are often the
organization's work for its clients.

The delete-account screen now says what happens to the files before the
person confirms. New strings are in all sixteen locales.
2026-09-24 16:58:48 -03:00
ignacionelson 4524b75c9d Let a hosted plan switch zip downloads off with downloads.zip
A new capability, granted by both editions. Self-hosted installs keep zip
downloads as they are. A platform removes it through
PROJECTSEND_CAPABILITIES_DISABLED. The free shared instances do this,
because building an archive holds the zips worker, the disk and a CPU on
a server that thousands of accounts share.

- The three zip routes sit behind capability:downloads.zip, so a
  hand-made request gets a 404, not just a missing button.
- BuildZipDownloadJob refuses a build that was queued before the key went
  away. The row ends failed and is never stamped as started.
  StalledZipBuilds stays quiet when the key is off, so leftover rows
  raise no worker banner.
- The zip buttons are hidden. In the portal, the checkboxes and the
  selection bar are hidden too, since they exist only to pick files for a
  zip. Staff /files keeps its checkboxes, which also drive bulk edit.
- Archives already built are not touched. They expire on the normal
  purge schedule.
- A guard test walks the router. It fails if any route that reaches
  ZipDownloadsController, or dispatches the build job, lacks the
  middleware. No API route builds zips today.

The case goes last in the enum, because the control plane reads keys in
enum order.
2026-09-24 15:39:44 -03:00
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