StaffAccounts opens with the rule: "Nobody hands out authority they do not hold ... that turns one permission into every permission and makes the rest of the matrix decorative." mayGrant() enforces it for a role's permissions, guardTarget() applies the same test to an existing account, and RolesController::guardGrantablePermissions() names the attack in full -- a non-administrator holding manage_users minting a role that carries more than they do, and then holding it. A role carries one more thing, and it is the larger one. `client_scoped` decides whether the role reaches the clients assigned to its holder or the whole library, which is the boundary StaffLibraryScope, ActivityLogScope and every listing in the application are built around. Nothing weighed it. store() and update() wrote the flag straight from the request, and mayGrant() looked only at permissions -- so `manage_users` on a client-scoped role was enough to take the limit off that role and keep working, or to mint a role without one and move into it. Either way the next request read the whole library, and the `assigned_clients` roster that #1697 protects stopped meaning anything for that account. Both halves of the existing pair get the missing clause: - guardScopeRemoval() in RolesController refuses a client-scoped actor who creates a role without the limit, or takes the limit off one that has it. Phrased as "removes the limit" rather than "is not limited", so only what this request changes is weighed -- the same reasoning guardGrantablePermissions() gives for looking at the diff. Editing an already-unlimited role's permissions is not this actor lifting a limit. Both writers resolve the flag with Request::boolean() and hand that same value to the guard and to the write: the `boolean` validation rule accepts "0" and 0 as well as false and validates without casting, so reading the validated array and comparing it strictly would leave this guard and the model's own `boolean` cast disagreeing about one value -- which is the shape the guard exists to prevent. - mayGrant() refuses a client-scoped actor granting a role that is not client-scoped, which closes assigning an existing one. It reaches both surfaces at once: assignableRoleIds() validates role_id on the web and API staff forms and on the account converter, assignableRoles() fills the pickers, and guardTarget() covers the account itself. Administrators are unaffected -- mayGrant() returns early for them, and an administrator role is never client-scoped. Unscoped staff are unaffected: the clause is conditioned on the actor's own scope, so a non-administrator with manage_users and no limit creates, edits and grants exactly as before. The seeded roles are untouched; update() already refused to move the flag on a system role, which is why the stock Client Manager was never the way in. Two changes a client-scoped holder of manage_users will notice, both following from mayGrant(): - the role picker on the staff form and the account converter now offers only client-scoped roles, rather than offering one the request behind it would refuse; - editing or deleting a staff account whose role is not client-scoped now answers 403, through guardTarget(), on the same "if you could not grant their role you have no business editing that account" rule that already applied to permissions. The roles API is read-only (GET /roles is the whole surface), so this half has no API twin to mirror; the account half is covered above.
ProjectSend
Share files with your clients, from your own server.
ProjectSend is a self-hosted application for getting files to the people you work with. You upload what you want to send, choose exactly who can see it, and each client signs in to their own private page to download it.
No public link passed around by email, no third-party service holding your clients' documents, no per-seat pricing. It runs on your server, and the files stay there.
What it does
For the people you send to
- A private area per client, showing only what has been shared with them
- Sign in with an email address, with optional two-factor authentication
- Search, filter and sort their files; download one, several as a zip, or a whole folder
- Optional comments on a file, so questions live next to the thing they are about
- Email notifications when something new arrives, in their own language
For you
- Resumable uploads that survive a dropped connection, so large files actually arrive
- Organise with folders, categories and client groups
- Share with one client, a whole group, or publicly — and set an expiry date or a download limit
- Thumbnails and previews for images and documents
- Storage quotas per client, and custom fields for the details you need to keep on them
- A full activity log and download history: who got what, and when
For the installation
- Roles and permissions for your own team, so an uploader is not an administrator
- Sign-in the way you already work: LDAP, social sign-in, or plain email and password
- Themes for the client-facing pages and for outgoing email
- 16 languages
- A REST API with scoped tokens and generated OpenAPI docs
- Privacy controls, including GDPR-grade account erasure with a grace period
- Local disk, S3-compatible storage, or Google Cloud Storage
Screenshots
The dashboard — what is in the installation, and what has been happening in it.
Your library — folders, categories, and who each file is shared with.
What your client sees — only their files, nothing else.
Getting started
With Docker — the quickest path, and the one we recommend. Nothing to build: the published image ships with its dependencies and its frontend already compiled.
curl -O https://raw.githubusercontent.com/projectsend/projectsend/main/docker/production/compose.example.yaml
# edit the passwords and APP_URL in it, then:
docker compose -f compose.example.yaml up -d
Open APP_URL and the first thing you see is a setup screen that creates your administrator
account — or uncomment ADMIN_EMAIL and ADMIN_PASSWORD in the file first, with a password of
your own, and it is created for you.
Before you put real files in it, read DOCKER.md — where your database and uploads actually live, how to move them onto paths you chose, and how to back them up so an upgrade can't take them with it.
Without Docker — for servers where it isn't an option, install from a release zip.
INSTALL.md covers requirements, .env, nginx, the background worker and cron,
updating and troubleshooting. You do not need Composer or npm on the server; the zip ships ready to
run.
Already running it? UPDATE.md is how you move to a new version — one command on Docker, one script on your own server, and what to check afterwards either way.
Want to work on ProjectSend itself? Cloning the repository gets you a development copy, not an installation: the dependencies and the compiled frontend are deliberately not in git, so a clone needs Composer and npm before it runs. CONTRIBUTING.md has the sequence, and it is short.
Coming from ProjectSend Legacy?
The previous generation of ProjectSend lives on at projectsend/legacy. This is a rebuild rather than an upgrade, so moving across is an import rather than an update — install fresh, then bring your old site into it with the migration tool: accounts, clients, groups, categories, folders, files and history.
It never writes to your old install, and any run can be undone with a single command. MIGRATING-FROM-V1.md explains what comes across, the two routes (same machine, or a portable export from a server you can't reach), and the one change your clients will notice: they sign in with their email address now, using the same password.
Contributing
Bug reports, translations and pull requests are all welcome — see CONTRIBUTING.md for how to set up a development copy, what the checks are, and the contributor agreement.
Found a security issue? Please report it privately through GitHub's security advisories rather than opening a public issue.
License
Free software under the GNU General Public License v2, or (at your option) any later version — see LICENSE. Use it, study it, change it, share it.
Commercial licenses are available for organizations that cannot work under copyleft terms; LICENSING.md explains both options. Contributions require signing a CLA, for reasons set out in CONTRIBUTING.md.


