Commit Graph

554 Commits

Author SHA1 Message Date
ignacionelson f22a346469 Release 2.7.0 v2.7.0 2026-10-06 23:12:10 -03:00
ignacionelson 2597f1a53f Translate the first-password email into every locale 2026-10-06 22:55:37 -03:00
ignacionelson b121fb08fa Word a provider account's first-password email as setting, not resetting
Since GHSA-4r8h-mwfm-f5f4, an account that signs in through a provider
gets its first password only from the reset link emailed to it. That
email said "you are receiving this because we received a password reset
request", to somebody who never had a password and may well ignore it.

ResetPasswordNotification now takes firstPassword, which
User::sendPasswordResetNotification() sets for an AuthSource::Social
account: "Set your password", what the link is for, and that nothing
changes if they did not ask. It is not taken from the customisable reset
template, whose text is written about resetting. Accounts with a password
get the reset email exactly as before.
2026-10-06 22:55:37 -03:00
ignacionelson 8537ca58a9 Update shell-quote and source-map-js for their new advisories
shell-quote 1.10.0 -> 1.12.0 (GHSA-pqg4-j6r4-53mv, critical: command
injection through quote()) and source-map-js 1.2.1 -> 1.2.2
(GHSA-68fv-2mgg-jv7q, high: denial of service through section offsets).
Both arrive through build tools only, concurrently and vite's postcss,
so neither runs in what a release ships; updated so the release does not
carry an open critical alert. Within the ranges their parents already
allow, same maintainers, no dependencies or install scripts, and each
lockfile integrity matches the registry. npm audit --omit=dev reports
nothing.
2026-10-06 22:53:38 -03:00
ignacionelson 53c4a4304d Keep the open folder and the chosen sort while browsing My files
Changing the sort (or search, category, owner) inside a folder on the
client portal navigated to the top of My files, because the list query
was built without the folder. And opening a folder, or following a
breadcrumb, dropped the chosen sort, because folderUrl() carried only the
folder id. Every portal theme shares the hook, so all four were affected.

The list query now carries the open folder, and folderUrl() carries the
sort when it is not the default (newest first), so default addresses
stay clean. Search and the filters still show a flat list across every
folder, as MyFilesController intends; clearing them returns to the
folder. The staff library is unchanged: there, dropping the folder for a
search is documented intent.

Checked in a browser on a client account: sorting by name inside a folder
stays in it and reorders it, and a folder link from a sorted list keeps
the sort.

Reported by @cookiebaker (#1806)
2026-10-06 22:31:24 -03:00
ignacionelson 2ff09767c9 Merge pull request #1810 from veenone/feat/ldap-signin-by-username
Feat/ldap signin by username
2026-10-05 16:12:13 -03:00
ignacionelson dd8bf8a657 Translate the strings added this week into every locale
22 strings, all 16 locales: the logo crop, the folder API's non-empty
delete, your own credentials staying behind your profile, a provider
account's emailed first-password link, and the token form's warning
about account-control abilities. Each locale keeps its own register:
formal German, French, Czech, Russian and Turkish; informal Spanish,
Catalan, Dutch, Italian, Portuguese and Polish. Placeholders and the
literal content_action=cascade_delete are untouched. Added at the end of
each file; no existing entry moved or changed.
2026-10-05 02:37:42 -03:00
ignacionelson 4e8150541d Write the orphan item-key separator as \u0000, not a literal NUL byte
The separator in itemKey() was a raw NUL character inside a template
literal. It works, but git reads the file as binary because of it, so
every change to the orphans screen showed as "Binary files differ" and
went unreviewed, #1809's included. The escape is the same string at
runtime and the file is text again.
2026-10-05 02:27:18 -03:00
ignacionelson c77d80309e Harden the background orphan import from #1809
Found in review, none of them reachable in our shipped setups but each
cheap to close:

- Two chunks could adopt the same path when more than one worker runs
  the default queue: a run that stalls unblocks a new one after five
  minutes, and the old chain can resume beside it. Two rows on one set of
  bytes means deleting either deletes the other's file. Each path is now
  claimed under a cache lock and checked for a row inside it, so a path
  another chunk holds is left to it. A lock around the whole chunk was
  tried first and dropped: a chunk queues the next one while it still
  holds the lock, so the next one was discarded and the run died.
- A chunk now checks that the account that started the run is still
  active, still staff and still holds import_orphans. A run can outlast
  that access, and every chunk adopts files in that person's name.
- A failure shows a plain sentence and sends the exception to the log.
  A storage error can name a bucket, an endpoint or a path.
2026-10-05 02:27:18 -03:00
ignacionelson a63fea8a4d Merge pull request #1809 from veenone/feat/orphan-import-all
Import all matching orphans in a background job
2026-10-05 02:24:41 -03:00
ignacionelson 4641393d6e Changelog: the orphan tool refuses a disguised path
GHSA-pv88-7863-5hwq
2026-10-05 01:17:02 -03:00
ignacionelson c384a860c8 Test that no spelling of a tracked file's path makes it an orphan
GHSA-pv88-7863-5hwq
2026-10-05 01:16:52 -03:00
ignacionelson 1e34773ad3 Refuse an orphan path the storage layer would rewrite
The orphan check compared the path it was given with the paths file rows
hold, but Flysystem rewrites a path before it touches storage: "./a/b",
"a/./b", "a//b", "/a/b", "a\b" and "a/x/../b" all become "a/b". Any of
them made a file somebody owns look like an orphan, so deleting it
removed their bytes without delete_others_files, and importing it put a
second row on them. The same spellings walked past the exclusion of
derived-artifact folders.

isOrphan(), which import and delete both go through, now refuses a path
the normalizer would change or rejects outright. The scan only offers
paths as storage lists them, so nothing it sends is affected.

GHSA-pv88-7863-5hwq
2026-10-05 01:16:52 -03:00
ignacionelson c5a547ffc9 Changelog: invitations stop at a scoped staff member's reach
GHSA-phv7-54fm-qh4r
2026-10-05 01:14:23 -03:00
ignacionelson 7165d136e1 Test that the invitation list and revoke stop at a scoped staff member's reach
GHSA-phv7-54fm-qh4r
2026-10-05 01:14:10 -03:00
ignacionelson 2a6f77a02a Keep a scoped staff member's invitation list and revoke inside their reach
A staff member limited to some clients may only invite into the groups
those clients are in, but the invitation list showed every invitation
in the installation, names and addresses included, and revoking took
back any pending one. Both now ask InvitationController::visibleTo(): the
invitations they sent, and those into a group within their reach. Out of
reach reads as 404. Unscoped staff are unaffected.

GHSA-phv7-54fm-qh4r
2026-10-05 01:14:10 -03:00
ignacionelson 34fd0abc4c Changelog: a provider account's first password comes by email
GHSA-4r8h-mwfm-f5f4
2026-10-05 00:45:31 -03:00
ignacionelson 7d1bbb3485 Test that a provider account's first password needs its inbox
GHSA-4r8h-mwfm-f5f4
2026-10-05 00:44:26 -03:00
ignacionelson 717852ff6a A provider account's first password comes from its inbox, not its session
An account that signs in through a provider has no password to prove,
so the password screen let the signed-in session choose one with no
proof at all. A stolen session could then make itself permanent: set a
password, confirm it, enrol its own second factor and remove the owner's
last provider, since the account now read as local.

The screen now refuses to set a provider account's password and offers
to email a link instead: the ordinary reset link, to the account's own
address, so whoever sets the password must read that inbox. The reset
pages accept a signed-in visitor, since the owner opens the link in the
browser they are signed in with; the token, not the session, is the
authority. Using the link signs out every session holding the old
password, the one that asked for it included. Compulsory two-factor lets
the link through, so a provider account still has a way to enrol.

Ordinary accounts are unchanged: they prove their current password.

GHSA-4r8h-mwfm-f5f4
2026-10-05 00:44:26 -03:00
veenone 147fd23507 Translate the strings added for LDAP sign-in by username
Four strings, in all sixteen locales: the username attribute setting and
its hint, and the login field's label and description when username
sign-in is on.
2026-10-05 07:51:21 +07:00
veenone 9c43f9cb9a Let directory clients sign in with their username as well as their address
LDAP sign-in only took an email address. The LDAP settings now have an
optional username attribute (cn, uid, sAMAccountName and so on). Once it
is set, the login field also takes a username. The service account looks
the username up, and the login carries on with the address the directory
holds for it, through the same checks, single user bind, provisioning
and rate limiting as an email login.

Whether the input is an address is decided by the same email rule that
accepted every stored address, so an address such as someone@localhost
is never taken for a username. The username goes through the query
builder, so it is escaped, and it has to match exactly one entry. The
directory is client-only, so a username never signs in a staff account.
With the attribute left empty, nothing changes.

This ports feat/ldap_signin_by_username, which was written against v1
and has no history in common with this codebase.
2026-10-05 07:51:21 +07:00
veenone b8108cdf70 Translate the strings added for "Import all" on the orphans screen
Fourteen strings, in all sixteen locales: the select-all link, the note
about skipped files, the Import all button, the four states of a
background run, and the messages for a queued or already running import.

For the queued message, Russian, Polish and Czech get all three plural
forms Laravel picks from in those languages. With only two, a count such
as 5000 would use the singular.
2026-10-05 06:35:46 +07:00
veenone 4b30849a88 Let "Import all" adopt every orphan the search matches, in a background job
The header checkbox on Import orphan files selected only the 25 rows on
screen, so an install with thousands of stray files had to import them a
page at a time. Once a whole page is ticked, the selection bar now offers
"Select all N matching files", and "Import all" takes every orphan the
search matches, on every page.

The import runs in a queued job because it is too slow for a request.
Each file is hashed in full and written in three commits, so 5,000 files
of 4 MB take about four minutes, and PHP stops a request after 30 s of
CPU, around file 1,100. ImportOrphanFilesJob works on the default queue in
chunks of about 45 s: each chunk rescans, imports what is still orphaned
and queues the next one. That keeps every job inside the worker's 60 s
timeout and the queue's 90 s retry_after, so no extra worker is needed,
and mail queued in the meantime goes out between chunks. If a run dies
part way, the next one picks up what is left.

Only one run can be active at a time. OrphanImportProgress keeps its state
in the cache and starts a run under a lock. While a run is active, every
other import is refused, the per-row button included, so no file is
adopted twice. The page polls files/orphans/import-status every 3 s and
shows the run as running, finished, failed with the reason, or stalled
after 5 minutes without progress, which usually means no worker is
listening.

Bulk delete still works one page at a time. The adoption itself moved to
OrphanFileImporter so the request and the job share it, and the rule for
what can be imported now lives in OrphanFileScanner::importable().
2026-10-05 06:35:46 +07:00
ignacionelson 7a1aa4021b Update the dependencies behind the open security alerts
Composer, each package alone, nothing else in the lock moved:
laravel/framework 12.64.0 -> 12.69.3, league/commonmark 2.10.0 -> 2.10.3,
league/flysystem 3.35.2 -> 3.36.0, phpseclib/phpseclib 3.0.56 -> 3.0.57.
composer audit reports nothing.

npm, within the ranges package.json already allows: axios 1.19.0 ->
1.20.0, and brace-expansion 1.1.18 -> 1.1.21 and 2.1.4 -> 2.1.7 (both
dev-only, under minimatch). No new dependencies or install scripts, and
each lockfile integrity matches the registry. npm audit --omit=dev
reports nothing.
2026-10-04 04:19:11 -03:00
ignacionelson 5ed5719135 Merge branch feat/logo-crop
Crop the logo, keep the upload, and restore it
2026-10-03 23:37:28 -03:00
ignacionelson d3230a4b64 Say what edit_clients and edit_users reach, and document the new refusals
Setting a password and removing a second factor are how an
administrator lets a locked-out person back in, so a token holding
edit_clients or edit_users can sign in as the accounts it may edit. That
stays what those abilities mean; it is now said where it is chosen. The
token form warns when either is ticked, and the API guide says it beside
the abilities, with the three refusals on your own account under "Staff
accounts". The OpenAPI document carries the new 403s, and CHANGELOG.md
an Unreleased entry.

GHSA-j5cp-r8pr-m5cr
2026-10-03 23:09:03 -03:00
ignacionelson 2da341b821 Test that your own credentials stay behind your profile, and resets end tokens
GHSA-j5cp-r8pr-m5cr
2026-10-03 23:05:23 -03:00
ignacionelson db65731c3a Keep your own credentials behind your profile, and end tokens on a reset
Your own email address, password and second factor are changed from your
profile, which asks for your current password. The staff screen and the
API changed the first two with no password at all, and the API removed
the third on your own account without the confirmation the web asks
for. StaffAccounts::ownCredentialChanges is the one rule both now ask:
the staff screen refuses your own email or password with a validation
error and points to the profile, and the API answers 403, as it does for
removing your own second factor.

Changing somebody else's password is unchanged: that is what edit_users
and edit_clients mean, on the screen and over the API. It now also
revokes that account's API tokens. Browser sessions already ended with
the password hash; tokens did not.

GHSA-j5cp-r8pr-m5cr
2026-10-03 23:05:23 -03:00
ignacionelson 2d8562abba Keep a tall logo inside the crop dialog
react-image-crop's stylesheet gives the image max-height: inherit, so the
limit set on the image was overridden: a portrait logo ran past the
dialog and its bottom handles could not be reached. The limit now sits on
the crop wrapper, and the scrolling container is gone.
2026-10-03 12:10:42 -03:00
ignacionelson bbd424a8d1 Crop the logo from the Branding screen, and restore the original
A Crop button opens the uploaded image with a free-shape box, starting
from the last crop; Restore original appears once there is one. The box
is sent in the upload's own pixels and the server cuts the file. The
image is shown with image-orientation: none, the pixels as the server
reads them, since no image here has the exif extension to rotate by an
orientation tag.

Adds react-image-crop 11.1.2: no dependencies, no install scripts, and
its lockfile integrity matches the registry.
2026-10-03 12:07:26 -03:00
ignacionelson ac1093dc8c Test the logo crop: which pixels it keeps, restoring, cleanup and its limits 2026-10-03 12:03:51 -03:00
ignacionelson 4485e36c5c Crop the logo on the server, from the kept upload, and restore it
Cropping is optional: an upload is used whole until somebody crops it.
A crop is a new file cut from the upload with SimpleImage, which the
watermark already uses; the upload is kept (logo_original_path) with
the box (logo_crop), so cropping again starts from the whole picture and
restoring points back at the upload. A box covering the whole image is
a restore. The box must lie inside the image, and an image over 25
million pixels is refused before GD decodes it.

A new upload, or removing the logo, deletes both files.
2026-10-03 12:02:41 -03:00
ignacionelson 7fcfbb5c41 Merge branch feat/api-folders
Folders in API v1: list, read, create, rename, move, delete and share
2026-10-03 02:46:22 -03:00
ignacionelson e9b71993f5 Document the folder endpoints
The OpenAPI document gains the seven folder operations; `ancestors` gets
an explicit type so the schema says what it holds rather than Scramble's
guess. The guide gets a Folders section, the folder abilities, the
idempotent create under "Retries", and public folders under "Not in v1".

The abilities table was split in two by a blank line, with the groups row
left under the paragraph after it; both are back in the table.
2026-10-02 23:46:09 -03:00
ignacionelson 33bc90c9ef Test the folder API: scope, trails, placement, the delete guard and sharing 2026-10-02 23:46:09 -03:00
ignacionelson 70dc725858 Folders in the API: list, read, create, rename, move, delete and share
An integration could put a file into a folder by id but could not see,
make or arrange the folders themselves, so mirroring a directory tree
into ProjectSend was impossible over the API. The hosted AI connector
already creates, lists and shares folders.

GET /folders polls like every list (updated_since, cursor) and filters
on parent_id, top_level and search. Each folder carries its ancestors
and a display path, trimmed for a client-scoped token to the folders it
may see (BreadcrumbBuilder::visible's rule), worked out for a whole page
in two queries by FolderTrails.

POST /folders returns an existing folder of the same name in the same
place with a 200 rather than making a second one, so a retried request
is safe. PATCH renames and moves. DELETE refuses a non-empty folder with
409 unless content_action=cascade_delete is sent, and then asks
UndeletableFiles exactly as the web does. Sharing goes through
FolderSharing.

Every write uses the web's policy, scope and FolderService, and asks
Folder::uploadableBy for every parent it writes, creation included.

Public state stays web-only: the resource reports `public`, nothing here
changes it. A file's `folder` now carries `parent_id` as well.
2026-10-02 23:46:09 -03:00
ignacionelson a5b6538b31 Give folder sharing and the folder-delete guard one definition each
Sharing a folder was four steps written in the web controller: the
assignment row, the activity entry, the in-app notification and the
digest email. The hosted edition's AI connector repeated them, because
there was nothing in the core to call, and the two copies had already
drifted (one re-notifies on a repeated share, the other does not). The
folder API about to land would have been a third copy.

FolderSharing is the folder twin of FileSharing, and the web controller
now calls it. Behaviour on the web is unchanged.

The count of files a staff member may not delete inside a folder's
subtree moves out of FoldersController into UndeletableFiles, for the
same reason: deleting a folder over the API has to ask exactly the
question the web screen asks before the cascade takes files with it.
2026-10-02 23:46:09 -03:00
ignacionelson 48a1c9f227 Trim the staff breadcrumb to the library's reach, and ask before nesting into a public folder
Two edges of the staff folder screens, found while the folder API was
built to answer the same questions.

A client-scoped staff member can hold one of their clients' folders that
sits inside somebody else's tree. The breadcrumb above it named every
folder on the way up, including ones their library does not show them.
It now starts at the first folder they can reach, as the client portal's
already does (BreadcrumbBuilder::visible). Unscoped staff see the whole
trail as before.

Creating a folder did not ask Folder::uploadableBy for its parent, though
every other write of a parent_id does: a folder inside a public one is
public. Files were already refused there by the upload check, so what
this closes is an empty folder's name appearing on a public page without
upload_public. Staff holding upload_public, or creating inside a private
folder, are unaffected.
2026-10-02 23:45:51 -03:00
Ignacio Nelson 185c46fff1 Merge pull request #1807 from projectsend/feat/package-styling-hooks
Let an installed package restyle the staff area and supply its own browser icons
2026-10-02 15:44:52 -03:00
ignacionelson a8adf6f614 Show a custom logo larger again on the sign-in pages
The sign-in, password reset, setup and share-link pages drew a custom logo
in a box 80 pixels tall, up from 48 in 2.6.0. Tested on 2.6.0, a square
logo still read as small on both phone and desktop. The box is now 128
pixels tall and up to 320 wide. The card is 384 wide, so a wide logo still
fits a phone. ProjectSend's own logo is unchanged.

The Branding → Logo hint states the new size. Its existing translations
are carried over with only the numbers changed, rather than left to fall
back to English.

Reported by @jiits (#1798)
2026-10-01 16:48:53 -03:00
ignacionelson f9e08412f2 Still log a failing health check in the production image
#1804 dropped every /up request from the nginx access log, so the
container's health checks stopped flooding `docker logs`. That also hid
the failing ones: when the container goes unhealthy, the 5xx from /up is
the line someone looks for, and Docker's health status alone does not say
why.

Key the map on the status as well as the path, so only a 2xx /up is
dropped. Verified against the 2.6.0 image: a 503 /up logs, a 200 /up does
not, and a 200 /upload still logs.
2026-10-01 15:24:08 -03:00
ignacionelson 9c26d46374 Merge pull request #1804 from 01110111000001/feat/quieter-logs
Quieter logs in docker container
2026-10-01 15:23:37 -03:00
ignacionelson 60c82afe5a Let an installed package restyle the staff area and supply its own browser icons
Core imports any stylesheet a package ships under resources/css after its
own app.css, and marks the pieces worth restyling with data attributes:
the staff shell (data-surface="staff"), the header, cards, buttons with
their variant, list toolbars, table frames and the default logo marks.
The layout takes its icons from projectsend.icons when a package names
some, replacing the defaults as a set.

Core names no package and no style. With nothing installed that ships a
stylesheet or icons, nothing renders differently.
2026-09-29 17:51:47 -03:00
01110111000001 f24a8587b9 feat: disable php-fpm access logs 2026-09-27 03:19:19 +02:00
01110111000001 a640bf81ed feat: ignore nginx logs on /up parh 2026-09-27 03:18:59 +02:00
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