2.3.0 — 1 September 2026
If you run ProjectSend on Apache or LiteSpeed, this is the release to take. It installed fine on
both before. Then every download arrived empty and every thumbnail was broken. That is fixed, and
you do not have to configure anything. Installations on nginx were never affected and nothing
changes for them.
The rest is mostly security work. Most of it is the same kind of thing: a screen or an API endpoint
that showed a little more than the person asking was allowed to see.
New
- Downloads work on any web server. Your files sit outside the web root, so ProjectSend checks
permission on every download before anything is sent. The fast way to finish is to hand the file
to the web server. Each web server wants that asked for differently, and until now ProjectSend
only knew how to ask nginx. On Apache and LiteSpeed it asked anyway, nothing answered, and the
visitor got an empty file. Now it works out what it is talking to. If it cannot hand the file
over, it sends the file itself, which is slower under load but works everywhere. - Apache and LiteSpeed can still have the fast version. Install
mod_xsendfile(LiteSpeed
needs no module), pointXSendFilePathat your storage directory, and set
PROJECTSEND_FILE_DELIVERY=xsendfile. See the upgrade notes. - The dashboard tells you which way downloads are going out. If PHP is sending them, there is a
warning next to it and a short explanation of what that costs you and how to change it. This is
the kind of thing that is invisible until the day the site falls over, so it says so up front. - Your logo and your watermark, on every installation. Upload a logo and it replaces ours in
the sidebar and on your public pages. Add a watermark and it goes on the thumbnails and previews
your clients and visitors see. Staff still see the originals, and the watermark is never written
into the stored file, so you can turn it off again. - You can find out which build you are running. Two images can say "2.2.1" and contain
different code.projectsend:statusnow reports the commit it was built from. - You will know if the nightly jobs stop running. When the scheduler dies, nothing looks wrong.
You find out weeks later, when a file you expired is still downloadable. ProjectSend now reports
when its scheduled work last ran and whether any of it failed. - You get told when the mailbox stops working, even when a send noticed the problem before the
scheduled check did.
Closed holes in who can see what
-
#1745 — Gate the comment moderation
surfaces on reading, not just on the library. Permission to moderate comments was letting somebody
read them, which is not the same thing: on the moderation screen and through the API, a role that
could moderate comments but could not open any file was shown every comment in the installation —
the text, staff-only notes, the client each conversation belongs to, and a visitor's IP address —
about files it would be refused on. Approving a comment over the API handed back its body the same
way.Who this affected. Only installations with a custom role built that way. None of the roles
ProjectSend ships is affected: Account Manager, the only one that moderates comments, can read
files as well, and so can a System Administrator. If you did build such a role, it can no longer
moderate — give it one of the file permissions (upload, edit files, or edit other people's files)
and it works again, now seeing only the comments on files it can actually open. -
#1759 — Publish the example Docker
quickstart on the loopback address instead of every network interface. The example set
TRUSTED_PROXIES: "*", which tells ProjectSend to believe the client address forwarded by
whoever connects to it. That is right behind a reverse proxy and wrong when anyone can reach the
container directly, because then anyone can claim any address: enough to walk past the login
lockout, every rate limit, and the address written to the download log and to guest comments.Who this affected. Installations started from
compose.example.yamlor from the Docker Hub
page, where port 8080 was reachable from outside the machine. A published Docker port is not
covered by a host firewall such asufw, so this was often open without anyone intending it. -
#1760 — Have the Docker image default to
production. On first boot the image copied its settings from the development template, which sets
APP_ENV=localandAPP_DEBUG=true. Two things followed that you could not see from inside the
application: every server error showed its stack trace — file, line and surrounding source — to
whoever triggered it, signed in or not; and "reject known-breached passwords" never actually
ran, while the security settings screen went on reporting it as switched on.Who this affected. Anyone who started the container without setting those two values: a plain
docker runwith a database address, the Portainer, unRAID and TrueNAS templates, or a Kubernetes
manifest naming only the database andAPP_URL. Installations usingcompose.example.yaml, which
sets both correctly, were never affected. -
The client portal dashboard lists only files that client can open. The API dashboard's recent
activity is cut the same way. -
Three lists were showing more than the viewer was allowed to see: the reassignment picker, the
account conversion list, and the membership an API member write handed back. -
Mail and storage credentials no longer end up in the boot configuration cache. A settings form
that gets rejected no longer sends the credential back to the browser. -
Connecting a sign-in provider asks for your password again. Every password prompt in front of an
account now has its own rate limit instead of sharing one. A two-factor code is claimed in a
single step, so the same code cannot be used twice. -
An expired file no longer locks a whole group shut for staff assigned to particular clients. A
shared folder's contents count towards what a client can reach. A client is added to the roster
of the staff member who created them. -
Whether something is an API request is decided by the route, not by a header the caller sets.
-
The interface font is served from your own installation. Loading a page no longer tells a font
CDN who is reading it. -
A stored filename can no longer push a control character into a response header.
Fixed
- The zip progress bar stops polling when you leave the page.
- A zip that fails to build no longer tells the person who asked for it why, in the server's words.
- Previews are written to a temporary file first, so a half-written one is never served. A file's
previews are deleted even when its storage cannot be reached. - An expiry date no longer moves because somebody else saved the file at the same time. Setting one
through the API means what it means on the web form. - Updating a client through the API no longer wipes custom fields the request never mentioned.
- The transfers chart lines up with the timezone its data is stored in.
- Creating an account over a deleted one's email address is refused instead of crashing.
- A comment still shows who wrote it after that account is deleted.
- Marking a file as a new version no longer emails people about a file they already had.
- The password reset and confirm-password screens say where the account's password actually lives,
which matters if you use LDAP or a sign-in provider. - A refused upload names the quota you are actually up against. A bulk edit that is refused says
which permission was missing. - Uploaded folders get the permissions the storage library actually asks for.
- The public preview log no longer records the same view repeatedly.
- Updating with
update.shno longer silently switches off route, event and view caching. The
script wiped the compiled caches while replacing the files, which is also how ProjectSend
recognised that you had cached them in the first place — so it rebuilt nothing, and every update
quietly left the site slower than the install instructions promised. - Every new screen in this release is translated into all sixteen languages.
Before you upgrade, read the notes below.
Upgrade notes
-
This upgrade adds two indexes to the activity log, and on a big installation that takes
minutes. It is the slowest part. Nothing goes offline while it runs — the application keeps
answering — but do not expect the migration to finish in seconds. -
On Apache or LiteSpeed you need to do nothing, but there is something worth doing. Downloads
will start working on their own. PHP will be sending them, which ties up a worker process for the
whole of each download. That is fine on a quiet site and not fine on a busy one. To move to the
fast path: installmod_xsendfile(LiteSpeed needs no module), allow your storage directory with
XSendFilePath, then setPROJECTSEND_FILE_DELIVERY=xsendfilein.env. The dashboard will
confirm the change. -
If you copied the example Docker file,
http://<your-server-ip>:8080will stop answering.
That is the change. Reach the application through your reverse proxy, asAPP_URLdescribes. If
your proxy runs on a different machine, publish the port on the interface it arrives from and
replaceTRUSTED_PROXIES: "*"with that address or subnet — the two settings only make sense
together. -
Docker:
APP_ENVandAPP_DEBUGset insidestorage/.envno longer take effect. The image
now sets them itself, and a real environment variable always beats that file. If you had turned
debug on by editingstorage/.env, pass-e APP_DEBUG=true(orenvironment:in compose)
instead. Anything you already set that way keeps working unchanged.
Thanks to @denkfabrik-li, who wrote all forty-four pull
requests in this release, and to @prbt2016, who reported the Apache
download failure that started the delivery work.
Pull requests merged since 2.2.1
The summary above is what changed. This is the paper trail, for anyone who wants to read the
original change. No issues were closed in this cycle — the work arrived as pull requests.
- #1718 — Narrow the reassignment picker to what a viewer may see
- #1719 — Count a shared folder's contents as reach, not just the folder
- #1720 — Stop an expired file locking a group shut for a scoped staff member
- #1721 — Scope the API dashboard's recent actions to what the viewer may read
- #1722 — Show the portal dashboard the files a client can actually open
- #1723 — Stop a client PATCH clearing custom fields it never mentioned
- #1725 — Write a rendition through a temporary file, and never serve an empty one
- #1726 — Delete a file's renditions even when its own disk cannot be resolved
- #1727 — Give an API expiry date the same meaning the web gives it
- #1728 — Stop an expiry moving because somebody else saved the file
- #1729 — Decide what is an API request from the route, not from the caller's headers
- #1730 — Refuse to provision over a deleted account's address instead of crashing
- #1731 — Fail a zip build without handing the requester the server's reason
- #1732 — Debounce the public preview log the way the signed-in one already is
- #1734 — Name the quota a client is actually held to when an upload is refused
- #1735 — Stop an editable-once checkbox locking before anybody ticks it
- #1736 — Put a client on the roster of the scoped staff member who created them
- #1737 — Compare the transfers window against the column's own timezone
- #1738 — Claim a TOTP code atomically instead of checking then writing
- #1739 — Refresh a mailbox on the schedule under the lock a send would hold
- #1740 — Leave the caches update.sh's own update command needs to see
- #1741 — Ask about the zips queue on every path that could answer it
- #1742 — Set the directory permission Flysystem actually reads
- #1743 — Check the read half of the redirect rule at every door, not one
- #1744 — Stop a version link telling people about a file they already had
- #1745 — Gate the comment moderation surfaces on reading, not just on the library
- #1746 — Say what expiry does to a client-scoped staff member's library
- #1747 — Say which permission a bulk edit was actually missing
- #1748 — Let a password reset know where the account's credentials live
- #1749 — A deleted account is still the person who wrote the comment
- #1750 — Tell the admins the mailbox is dead, even when a send noticed first
- #1751 — Keep the mail and storage credentials out of the boot-config cache
- #1752 — Bound the two preference endpoints by their own registries
- #1753 — Narrow the conversion list to the clients its own refusal allows
- #1754 — Narrow the membership an API member write hands back
- #1755 — Give every password check in front of an account its own bucket
- #1756 — Make linking a provider re-prove the password
- #1757 — Stop a rejected settings form flashing the credential it carried
- #1758 — Let the confirm-password screen ask where the password lives
- #1759 — Publish the quickstart on loopback, since it trusts any proxy
- #1760 — Have the production image default to production
- #1761 — Serve the interface font from the installation, not from a font CDN
- #1762 — Run the auth and settings screens through the translator
- #1763 — Stop the zip poll when its page goes away
- #1764 — Honour Laravel's placeholder case convention in t()