mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 05:25:51 +00:00
Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96107fdcd5 | |||
| eecd5b804d | |||
| 616aa49867 | |||
| 525c464327 | |||
| 97596da7d0 | |||
| 2ebadf0793 | |||
| 25e4f77b63 | |||
| 90ed2d60b9 | |||
| d6fd5a917d | |||
| 6340b71dca | |||
| fb931819e2 | |||
| 41b22d003e | |||
| 78d5067c6b | |||
| b7cc5e8615 | |||
| ed82d748ea | |||
| fe3b7b7018 | |||
| 6783fa0b81 | |||
| 4556ccf691 | |||
| 85572eb45e | |||
| 227a08dfce | |||
| 43e9985b2b | |||
| f931c6a492 | |||
| 1a3260a397 | |||
| 07e7132747 | |||
| f4fd194991 | |||
| ea45943f40 | |||
| ce96313710 | |||
| 77dd5ff90b | |||
| 8984aba7d8 | |||
| 188848b549 | |||
| 7264c44fd7 | |||
| 3e24ccd42f | |||
| 74077993de | |||
| da7eb6f67d | |||
| 35d68a792b | |||
| bde86c10e4 | |||
| c72adadc44 | |||
| 1ed29ec072 | |||
| 9af0d643b1 | |||
| 19ee9d9833 | |||
| cad112522d | |||
| 81bb136e9e | |||
| a2bc3fa163 | |||
| ef6f8fea56 | |||
| 927c8fc991 | |||
| 144f5fc578 | |||
| 383c3b2ff5 | |||
| b9807bf610 | |||
| d91cf97bcb | |||
| 89b3d34c8f | |||
| d09cb602c1 | |||
| b7a94d4479 | |||
| fdcdad7fb2 | |||
| ff26fac9c5 | |||
| a7e883ef70 | |||
| 90009b7029 | |||
| 9508750c60 | |||
| 9c6f4df5bc | |||
| 2903a1da6d | |||
| c11cb3cc63 | |||
| 5117511946 | |||
| b6f4770795 | |||
| 037439e1f2 | |||
| 6b99e37d01 | |||
| f676e09bb2 | |||
| eb3d6e321d | |||
| d89807b237 | |||
| 7ff2674e4f | |||
| 262cb2457a | |||
| a285f86b93 | |||
| bc68a24ef5 | |||
| 1644d634d5 | |||
| abbe9a3acc | |||
| 3dc407a777 | |||
| d8ef21bb6a | |||
| 479dc61d2d | |||
| 530f30606d | |||
| afc2c74617 | |||
| d62c62f788 | |||
| 7be81d3586 | |||
| 92f50fdb85 | |||
| 27c289a4d6 | |||
| 5e60d2ef88 | |||
| 21cae2acb1 | |||
| defe488391 | |||
| fc5651faad | |||
| b838036a9a | |||
| 02eafb473b | |||
| 674781e57a | |||
| f39ad46dd6 | |||
| a1773cad5e | |||
| 17fc9ff4cb | |||
| 776d3d99f4 | |||
| 9ddd39c41d | |||
| 19c449ee20 | |||
| 4b998cda92 | |||
| 4164678ebc | |||
| fc758c701a | |||
| 250e8664d3 | |||
| 640c5db591 | |||
| e1cd010f9d | |||
| c2dd2c758a | |||
| 9d4b096c19 | |||
| 763777d282 | |||
| f424fe5365 | |||
| cd8da6a117 | |||
| db1dd71f3c | |||
| c8de16101f | |||
| cb53120779 | |||
| 84e9f6e2fe |
@@ -6,6 +6,15 @@ PROJECTSEND_EDITION=community
|
||||
# configured at /system/settings/captcha.
|
||||
# PROJECTSEND_CAPTCHA_DISABLED=true
|
||||
|
||||
# How downloads leave the server. Left unset (or "auto"), ProjectSend hands
|
||||
# files to nginx when it is running behind nginx, and streams them through
|
||||
# PHP on anything else -- which works everywhere but holds a PHP worker for
|
||||
# the whole of each download. Set "xsendfile" for Apache with mod_xsendfile
|
||||
# (or LiteSpeed) once XSendFilePath allows storage/app/files, "nginx" when
|
||||
# an nginx proxy in front is the one serving /protected-files/, or "php" to
|
||||
# stream deliberately. The dashboard's System panel shows which is in use.
|
||||
# PROJECTSEND_FILE_DELIVERY=auto
|
||||
|
||||
# Optional: uid/gid the app/web containers' internal user runs as, so the
|
||||
# bind-mounted repo needs no permission fixes. Defaults to 1000; override
|
||||
# if your host user's `id -u`/`id -g` differ.
|
||||
|
||||
@@ -46,3 +46,6 @@ yarn-error.log
|
||||
# mkcert certificates and the nginx config that terminates HTTPS on the
|
||||
# dev `web` container. Machine-specific, and one of them is a private key.
|
||||
/docker/web/local/
|
||||
|
||||
# Written into an artifact by build-release.sh, never into a checkout.
|
||||
/config/build.php
|
||||
|
||||
+199
-2
@@ -10,8 +10,205 @@ Anything under **Upgrade notes** is something you have to do, not something we d
|
||||
|
||||
## Unreleased
|
||||
|
||||
This section collects changes as they land; the release process turns it into a numbered entry when
|
||||
a version is cut.
|
||||
This section collects changes as they land; the release process turns it into a numbered entry
|
||||
when a version is cut.
|
||||
|
||||
## 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), point `XSendFilePath` at 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:status` now 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](https://github.com/projectsend/projectsend/pull/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](https://github.com/projectsend/projectsend/pull/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.yaml` or 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 as `ufw`, so this was often open without anyone intending it.
|
||||
|
||||
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the Docker image default to
|
||||
production. On first boot the image copied its settings from the development template, which sets
|
||||
`APP_ENV=local` and `APP_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 run` with a database address, the Portainer, unRAID and TrueNAS templates, or a Kubernetes
|
||||
manifest naming only the database and `APP_URL`. Installations using `compose.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.sh` no 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: install `mod_xsendfile` (LiteSpeed needs no module), allow your storage directory with
|
||||
`XSendFilePath`, then set `PROJECTSEND_FILE_DELIVERY=xsendfile` in `.env`. The dashboard will
|
||||
confirm the change.
|
||||
|
||||
- **If you copied the example Docker file, `http://<your-server-ip>:8080` will stop answering.**
|
||||
That is the change. Reach the application through your reverse proxy, as `APP_URL` describes. If
|
||||
your proxy runs on a different machine, publish the port on the interface it arrives from and
|
||||
replace `TRUSTED_PROXIES: "*"` with that address or subnet — the two settings only make sense
|
||||
together.
|
||||
|
||||
- **Docker: `APP_ENV` and `APP_DEBUG` set inside `storage/.env` no 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 editing `storage/.env`, pass `-e APP_DEBUG=true` (or `environment:` in compose)
|
||||
instead. Anything you already set that way keeps working unchanged.
|
||||
|
||||
Thanks to [@denkfabrik-li](https://github.com/denkfabrik-li), who wrote all forty-four pull
|
||||
requests in this release, and to [@prbt2016](https://github.com/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](https://github.com/projectsend/projectsend/pull/1718) — Narrow the reassignment picker to what a viewer may see
|
||||
- [#1719](https://github.com/projectsend/projectsend/pull/1719) — Count a shared folder's contents as reach, not just the folder
|
||||
- [#1720](https://github.com/projectsend/projectsend/pull/1720) — Stop an expired file locking a group shut for a scoped staff member
|
||||
- [#1721](https://github.com/projectsend/projectsend/pull/1721) — Scope the API dashboard's recent actions to what the viewer may read
|
||||
- [#1722](https://github.com/projectsend/projectsend/pull/1722) — Show the portal dashboard the files a client can actually open
|
||||
- [#1723](https://github.com/projectsend/projectsend/pull/1723) — Stop a client PATCH clearing custom fields it never mentioned
|
||||
- [#1725](https://github.com/projectsend/projectsend/pull/1725) — Write a rendition through a temporary file, and never serve an empty one
|
||||
- [#1726](https://github.com/projectsend/projectsend/pull/1726) — Delete a file's renditions even when its own disk cannot be resolved
|
||||
- [#1727](https://github.com/projectsend/projectsend/pull/1727) — Give an API expiry date the same meaning the web gives it
|
||||
- [#1728](https://github.com/projectsend/projectsend/pull/1728) — Stop an expiry moving because somebody else saved the file
|
||||
- [#1729](https://github.com/projectsend/projectsend/pull/1729) — Decide what is an API request from the route, not from the caller's headers
|
||||
- [#1730](https://github.com/projectsend/projectsend/pull/1730) — Refuse to provision over a deleted account's address instead of crashing
|
||||
- [#1731](https://github.com/projectsend/projectsend/pull/1731) — Fail a zip build without handing the requester the server's reason
|
||||
- [#1732](https://github.com/projectsend/projectsend/pull/1732) — Debounce the public preview log the way the signed-in one already is
|
||||
- [#1734](https://github.com/projectsend/projectsend/pull/1734) — Name the quota a client is actually held to when an upload is refused
|
||||
- [#1735](https://github.com/projectsend/projectsend/pull/1735) — Stop an editable-once checkbox locking before anybody ticks it
|
||||
- [#1736](https://github.com/projectsend/projectsend/pull/1736) — Put a client on the roster of the scoped staff member who created them
|
||||
- [#1737](https://github.com/projectsend/projectsend/pull/1737) — Compare the transfers window against the column's own timezone
|
||||
- [#1738](https://github.com/projectsend/projectsend/pull/1738) — Claim a TOTP code atomically instead of checking then writing
|
||||
- [#1739](https://github.com/projectsend/projectsend/pull/1739) — Refresh a mailbox on the schedule under the lock a send would hold
|
||||
- [#1740](https://github.com/projectsend/projectsend/pull/1740) — Leave the caches update.sh's own update command needs to see
|
||||
- [#1741](https://github.com/projectsend/projectsend/pull/1741) — Ask about the zips queue on every path that could answer it
|
||||
- [#1742](https://github.com/projectsend/projectsend/pull/1742) — Set the directory permission Flysystem actually reads
|
||||
- [#1743](https://github.com/projectsend/projectsend/pull/1743) — Check the read half of the redirect rule at every door, not one
|
||||
- [#1744](https://github.com/projectsend/projectsend/pull/1744) — Stop a version link telling people about a file they already had
|
||||
- [#1745](https://github.com/projectsend/projectsend/pull/1745) — Gate the comment moderation surfaces on reading, not just on the library
|
||||
- [#1746](https://github.com/projectsend/projectsend/pull/1746) — Say what expiry does to a client-scoped staff member's library
|
||||
- [#1747](https://github.com/projectsend/projectsend/pull/1747) — Say which permission a bulk edit was actually missing
|
||||
- [#1748](https://github.com/projectsend/projectsend/pull/1748) — Let a password reset know where the account's credentials live
|
||||
- [#1749](https://github.com/projectsend/projectsend/pull/1749) — A deleted account is still the person who wrote the comment
|
||||
- [#1750](https://github.com/projectsend/projectsend/pull/1750) — Tell the admins the mailbox is dead, even when a send noticed first
|
||||
- [#1751](https://github.com/projectsend/projectsend/pull/1751) — Keep the mail and storage credentials out of the boot-config cache
|
||||
- [#1752](https://github.com/projectsend/projectsend/pull/1752) — Bound the two preference endpoints by their own registries
|
||||
- [#1753](https://github.com/projectsend/projectsend/pull/1753) — Narrow the conversion list to the clients its own refusal allows
|
||||
- [#1754](https://github.com/projectsend/projectsend/pull/1754) — Narrow the membership an API member write hands back
|
||||
- [#1755](https://github.com/projectsend/projectsend/pull/1755) — Give every password check in front of an account its own bucket
|
||||
- [#1756](https://github.com/projectsend/projectsend/pull/1756) — Make linking a provider re-prove the password
|
||||
- [#1757](https://github.com/projectsend/projectsend/pull/1757) — Stop a rejected settings form flashing the credential it carried
|
||||
- [#1758](https://github.com/projectsend/projectsend/pull/1758) — Let the confirm-password screen ask where the password lives
|
||||
- [#1759](https://github.com/projectsend/projectsend/pull/1759) — Publish the quickstart on loopback, since it trusts any proxy
|
||||
- [#1760](https://github.com/projectsend/projectsend/pull/1760) — Have the production image default to production
|
||||
- [#1761](https://github.com/projectsend/projectsend/pull/1761) — Serve the interface font from the installation, not from a font CDN
|
||||
- [#1762](https://github.com/projectsend/projectsend/pull/1762) — Run the auth and settings screens through the translator
|
||||
- [#1763](https://github.com/projectsend/projectsend/pull/1763) — Stop the zip poll when its page goes away
|
||||
- [#1764](https://github.com/projectsend/projectsend/pull/1764) — Honour Laravel's placeholder case convention in t()
|
||||
|
||||
## 2.2.1 — 28 August 2026
|
||||
|
||||
|
||||
@@ -121,6 +121,13 @@ Without it every visitor appears to come from the proxy. The login rate limiter
|
||||
your users as one attacker, and the download log records the proxy's address instead of the
|
||||
person's. `compose.example.yaml` already sets this.
|
||||
|
||||
`"*"` means "trust whoever connected to me", so it belongs with a published port only the proxy can
|
||||
reach — which is why `compose.example.yaml` publishes on `127.0.0.1`. If anybody can open the
|
||||
container's port directly, they are the proxy as far as this setting is concerned, and the
|
||||
`X-Forwarded-For` they send is the address the rate limiters and the download log will use. Where
|
||||
the proxy runs on another host, publish on the interface it arrives from and name that address or
|
||||
subnet here instead of `"*"`.
|
||||
|
||||
Leaving it unset does not cause a `502` — that means your proxy could not get a usable response out
|
||||
of the container at all, which is a different problem with a different fix. It does cause a **419
|
||||
"page expired"**. Without it the application never learns the proxy terminated TLS, so it builds
|
||||
@@ -177,7 +184,7 @@ is the quickest way to separate "the app is down" from "the proxy cannot reach t
|
||||
during an outage, from the same machine:
|
||||
|
||||
```sh
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://<host-ip>:8080/up # straight at the container
|
||||
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/up # straight at the container
|
||||
curl -s -o /dev/null -w '%{http_code}\n' https://files.example.com/up
|
||||
```
|
||||
|
||||
|
||||
+105
-52
@@ -21,7 +21,7 @@ to create a database — this is not an install you can do over FTP alone.
|
||||
| **PHP** | 8.4 or newer, both the command-line PHP and PHP-FPM |
|
||||
| **PHP extensions** | `bcmath` `ctype` `curl` `dom` `fileinfo` `filter` `gd` `iconv` `intl` `json` `ldap` `mbstring` `openssl` `pcntl` `pdo_mysql` `session` `simplexml` `tokenizer` `zip` |
|
||||
| **Database** | MySQL 8.0 or newer (we test on 8.4 LTS) |
|
||||
| **Web server** | **nginx**, with PHP-FPM — see the note below |
|
||||
| **Web server** | Any, with PHP-FPM. **nginx is strongly recommended** — see the note below |
|
||||
| **Disk space** | The app itself is small; plan for whatever your users will upload |
|
||||
|
||||
A few notes on that list:
|
||||
@@ -29,66 +29,113 @@ A few notes on that list:
|
||||
- **`ldap` is required even if you never use LDAP.** One of the libraries ProjectSend depends on
|
||||
declares it, so PHP will refuse to start the app without it. On Debian/Ubuntu it is
|
||||
`php8.4-ldap`; on RHEL-family systems, `php-ldap`.
|
||||
- **nginx is not a preference, it is a requirement.** See [Why nginx](#why-nginx) — it is worth
|
||||
two minutes of reading before you commit to a server, because Apache cannot be made to work by
|
||||
configuring it differently.
|
||||
- **nginx is recommended, not required.** ProjectSend runs on Apache and LiteSpeed too, and
|
||||
downloads work on them out of the box. What differs is *how* the bytes are sent: on nginx the
|
||||
web server sends them, and everywhere else PHP does, which costs a worker process for the
|
||||
duration of every download. See [How downloads are sent](#how-downloads-are-sent) before you
|
||||
commit to a server — it is a capacity decision, not a compatibility one.
|
||||
- **Redis is optional.** The Docker setup uses it, but a manual install works fine with the
|
||||
database for sessions, cache and queues. If you already have Redis, see
|
||||
[Optional extras](#optional-extras) below.
|
||||
|
||||
### Why nginx
|
||||
### How downloads are sent
|
||||
|
||||
Your uploaded files do not live under `public/`. They sit in `storage/app/files/`, outside the web
|
||||
root, where no URL can reach them — which is the whole point: a file is only yours to download if
|
||||
ProjectSend says so, and a file sitting in a guessable public folder has already lost that
|
||||
argument.
|
||||
|
||||
So every download has to pass through a permission check. The obvious way to do that is to let PHP
|
||||
read the file and echo it back to the browser, and that is what most PHP applications do. It works,
|
||||
and it is a bad idea at any real size: a single 5 GB download occupies a PHP process for its entire
|
||||
duration, so a handful of people downloading at once can exhaust every worker your server has while
|
||||
the CPU sits idle. Resumable downloads, byte ranges and progress bars all have to be reimplemented
|
||||
by hand, usually incorrectly.
|
||||
So every download has to pass through a permission check in PHP first. What happens *after* that
|
||||
check passes is the thing this section is about, and ProjectSend can do it two ways.
|
||||
|
||||
ProjectSend does the other thing. PHP checks permissions, logs the download, and then answers with
|
||||
an empty response carrying a header that says *"nginx, please send this file."* nginx streams the
|
||||
bytes with the same code it uses for any static file — sendfile, byte ranges, resume support, no
|
||||
PHP process held open — and the visitor never sees the real path. The header is
|
||||
`X-Accel-Redirect`, and the matching `location /protected-files/` block in
|
||||
[step 6](#step-6--point-your-web-server-at-it) is marked `internal`, which is what stops anyone
|
||||
from requesting that path directly.
|
||||
**PHP sends the file.** It opens the file and writes it out to the visitor. This works on every
|
||||
web server and needs no configuration, which is why it is what ProjectSend falls back to. The cost
|
||||
is that one PHP worker process is occupied for the whole of each download — three minutes for a
|
||||
large file on a slow connection is three minutes that worker cannot answer anything else. A
|
||||
handful of concurrent large downloads can therefore occupy every worker you have and the site
|
||||
stops responding, with the processor idle and the workers all waiting on network transfers.
|
||||
|
||||
**Apache has no equivalent that ProjectSend can use.** Apache's closest feature, `mod_xsendfile`,
|
||||
reads a differently-named header (`X-Sendfile`) that ProjectSend does not send, and it is not
|
||||
installed by default anyway. LiteSpeed has its own third spelling. On any of them the application
|
||||
installs fine and every page works — you can log in, upload, manage clients, browse the library —
|
||||
but **every download returns an empty response or a 404**, because nothing is listening for the
|
||||
instruction PHP just gave. There is no setting to change; the header names simply do not match.
|
||||
**The web server sends the file.** PHP answers with an empty response and a header naming the
|
||||
file, and finishes immediately; the web server streams the bytes with the same code it uses for
|
||||
any static file — `sendfile`, byte ranges, resume support, no PHP process held open — and the
|
||||
visitor never sees the real path. This is what you want on anything busy.
|
||||
|
||||
Two ways out, if nginx really is impossible on your hosting:
|
||||
The second option needs a header, and **each web server reads a different one**, which is why
|
||||
ProjectSend has to know which one it is talking to. It works this out from the server itself and
|
||||
you can override it.
|
||||
|
||||
- Put nginx in front of Apache as a reverse proxy, serving `/protected-files/` itself. This works
|
||||
but is more moving parts than just using nginx. Give the proxy some header headroom while you are
|
||||
there — the same headroom the reference configuration in Step 6 gives PHP-FPM, in the directives a
|
||||
proxy uses instead:
|
||||
| Your server | What ProjectSend does | What you need to configure |
|
||||
|---|---|---|
|
||||
| nginx | `X-Accel-Redirect` | The `location /protected-files/` block in [step 6](#step-6--point-your-web-server-at-it). Detected automatically |
|
||||
| Apache | PHP sends the file, unless you enable `mod_xsendfile` | See below |
|
||||
| LiteSpeed / OpenLiteSpeed | PHP sends the file, unless you turn on X-Sendfile | See below |
|
||||
| Anything else | PHP sends the file | Nothing |
|
||||
|
||||
```nginx
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 8 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
```
|
||||
**The dashboard tells you which one is in use.** The System panel has a "Downloads sent by" line,
|
||||
with a warning icon and an explanation whenever PHP is doing the sending. You do not have to
|
||||
remember to check this file.
|
||||
|
||||
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
|
||||
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
|
||||
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
|
||||
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
|
||||
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
|
||||
([#1664](https://github.com/projectsend/projectsend/issues/1664))
|
||||
- Store your files in object storage instead — S3-compatible or Google Cloud Storage (see
|
||||
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
|
||||
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
|
||||
to the storage provider and the web server is not involved at all. This is a genuine, supported
|
||||
path — just decide it before people start uploading, not after.
|
||||
#### Enabling X-Sendfile on Apache or LiteSpeed
|
||||
|
||||
Apache needs [`mod_xsendfile`](https://github.com/nmaier/mod_xsendfile) installed and enabled, and
|
||||
a directive allowing it to serve your storage directory:
|
||||
|
||||
```apache
|
||||
XSendFile On
|
||||
XSendFilePath /home/projectsend/storage/app/files
|
||||
```
|
||||
|
||||
LiteSpeed and OpenLiteSpeed read the same header without an extra module; enable it in the server
|
||||
configuration.
|
||||
|
||||
Then tell ProjectSend to use it, in `.env`:
|
||||
|
||||
```dotenv
|
||||
PROJECTSEND_FILE_DELIVERY=xsendfile
|
||||
```
|
||||
|
||||
**ProjectSend will not switch this on by itself**, even when it can see the module is loaded,
|
||||
because it cannot see whether `XSendFilePath` allows the storage directory. Guessing wrong there
|
||||
produces empty downloads rather than slow ones, and an empty download is a much worse failure than
|
||||
a slow one — so this stays something you turn on having configured it.
|
||||
|
||||
#### Choosing explicitly
|
||||
|
||||
`PROJECTSEND_FILE_DELIVERY` accepts:
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `auto` | The default. nginx if the server says it is nginx, PHP otherwise |
|
||||
| `nginx` | Always `X-Accel-Redirect`. Use this if nginx is proxying another server |
|
||||
| `xsendfile` | Always `X-Sendfile`, for Apache with `mod_xsendfile`, or LiteSpeed |
|
||||
| `php` | Always PHP. Correct and slow, and never wrong |
|
||||
|
||||
The one case `auto` gets wrong is **nginx reverse-proxying Apache**: PHP is talking to Apache, so
|
||||
it picks PHP streaming, and downloads work but do not use the nginx in front. Set
|
||||
`PROJECTSEND_FILE_DELIVERY=nginx` and make sure the front nginx serves `/protected-files/`. While
|
||||
you are there, give the proxy some header headroom — the same headroom the reference configuration
|
||||
in Step 6 gives PHP-FPM, in the directives a proxy uses instead:
|
||||
|
||||
```nginx
|
||||
proxy_buffer_size 32k;
|
||||
proxy_buffers 8 32k;
|
||||
proxy_busy_buffers_size 64k;
|
||||
```
|
||||
|
||||
nginx buffers a response's headers into a single block that defaults to one memory page — 4 KB on
|
||||
most systems — and answers `502 Bad Gateway` with `upstream sent too big header` when they do not
|
||||
fit. The page that goes over is not always the same one, so it presents as an intermittent fault
|
||||
rather than as a misconfiguration. This applies to any proxy in front of ProjectSend, not just
|
||||
this one: Nginx Proxy Manager, Traefik and a hand-written nginx vhost all ship the same default.
|
||||
([#1664](https://github.com/projectsend/projectsend/issues/1664))
|
||||
|
||||
#### Or take your server out of it entirely
|
||||
|
||||
Store your files in object storage — S3-compatible or Google Cloud Storage (see
|
||||
[Storing files somewhere other than this server](#storing-files-somewhere-other-than-this-server)).
|
||||
Files kept there are never on your server's disk, so downloads become a signed, expiring redirect
|
||||
to the storage provider and the web server is not involved at all. Decide this before people start
|
||||
uploading, not after.
|
||||
|
||||
---
|
||||
|
||||
@@ -216,10 +263,10 @@ FILES_WEB_SERVER_READABLE=true
|
||||
|
||||
Uploaded files are written `0600` inside `0700` directories, readable only by the user that wrote
|
||||
them. That is deliberate, and on a same-user server it is the safer setting. But a download is not
|
||||
served by PHP: PHP checks permissions and then hands the web server the path with `X-Accel-Redirect`
|
||||
(see [Why nginx](#why-nginx)), so the web server has to open a file PHP owns. When it cannot, **the
|
||||
whole site works and only downloads fail** — the browser reports `ERR_INVALID_RESPONSE` and the
|
||||
nginx error log says:
|
||||
served by PHP on nginx: PHP checks permissions and then hands the web server the path with
|
||||
`X-Accel-Redirect` (see [How downloads are sent](#how-downloads-are-sent)), so the web server has
|
||||
to open a file PHP owns. When it cannot, **the whole site works and only downloads fail** — the
|
||||
browser reports `ERR_INVALID_RESPONSE` and the nginx error log says:
|
||||
|
||||
```
|
||||
open() ".../storage/app/files/..." failed (13: Permission denied)
|
||||
@@ -546,9 +593,15 @@ That is correct behaviour until the first administrator exists. Finish step 7. I
|
||||
created one and it still happens, ProjectSend cannot reach your database — check `storage/logs/`.
|
||||
|
||||
**Pages load but downloads give a 404, or download a 0-byte file.**
|
||||
The `/protected-files/` block is missing from your nginx config, or its `alias` path does not match
|
||||
where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash. If you
|
||||
are on Apache or LiteSpeed, no configuration will fix this — see [Why nginx](#why-nginx).
|
||||
On nginx, the `/protected-files/` block is missing from your config, or its `alias` path does not
|
||||
match where you installed ProjectSend. It must point at `storage/app/files/` and end with a slash.
|
||||
|
||||
On any server, check the "Downloads sent by" line in the dashboard's System panel against the
|
||||
server you are actually running. A 0-byte download means ProjectSend sent a header the server did
|
||||
not act on — most often `PROJECTSEND_FILE_DELIVERY` set to `nginx` or `xsendfile` on a server that
|
||||
is neither, or set to `xsendfile` without `XSendFilePath` allowing the storage directory. Setting
|
||||
`PROJECTSEND_FILE_DELIVERY=php` always works and is the quickest way to confirm that is the
|
||||
problem. See [How downloads are sent](#how-downloads-are-sent).
|
||||
|
||||
**Uploads fail partway through.**
|
||||
`client_max_body_size` in nginx, or `upload_max_filesize` / `post_max_size` in `php.ini`, is
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Identity\PasswordVerification;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
@@ -22,16 +22,21 @@ class ConfirmablePasswordController extends Controller
|
||||
|
||||
/**
|
||||
* Confirm the user's password.
|
||||
*
|
||||
* Through PasswordVerification, so this asks the same question the
|
||||
* sign-in form asks: is this the account's password, from wherever
|
||||
* that account's password lives. Checking only the local hash refused
|
||||
* every directory-provisioned account the password it actually has --
|
||||
* their local hash is a Str::password(64) nobody has ever seen -- and
|
||||
* this screen stands in front of enrolling in two-factor, so those
|
||||
* accounts could not enrol at all.
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(Request $request, PasswordVerification $passwords): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
if (! Auth::guard('web')->validate([
|
||||
'email' => $user->email,
|
||||
'password' => $request->password,
|
||||
])) {
|
||||
if (! $passwords->verify($user, (string) $request->string('password'))) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => __('auth.password'),
|
||||
]);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Identity\AuthSource;
|
||||
use App\Modules\Identity\Ldap\LdapAuthenticator;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -16,6 +18,10 @@ use Inertia\Response;
|
||||
|
||||
class NewPasswordController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LdapAuthenticator $ldap,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show the password reset page.
|
||||
*/
|
||||
@@ -46,10 +52,55 @@ class NewPasswordController extends Controller
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function ($user) use ($request) {
|
||||
$user->forceFill([
|
||||
// A directory account's password lives in the directory and
|
||||
// the local hash is not consulted at all, which is what
|
||||
// isDirectoryAccount() means. Writing one here reported
|
||||
// success and changed nothing anybody could use -- including
|
||||
// when the directory it points at is gone, which is exactly
|
||||
// when somebody reaches for a reset.
|
||||
//
|
||||
// Refused here rather than where the link is asked for: that
|
||||
// endpoint answers "A reset link will be sent if the account
|
||||
// exists" to everybody on purpose, and a refusal there would
|
||||
// tell a stranger both that an address is an account and how
|
||||
// it signs in. By this point the caller holds a token that
|
||||
// was emailed to the address, so the explanation reaches the
|
||||
// account holder and nobody else.
|
||||
//
|
||||
// Throwing before the write also leaves the token unspent:
|
||||
// PasswordBroker deletes it after the callback returns, so
|
||||
// the link still works if an administrator converts the
|
||||
// account in the meantime.
|
||||
if ($this->ldap->isDirectoryAccount($user)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.')],
|
||||
]);
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'password' => Hash::make($request->password),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
];
|
||||
|
||||
// `social` records that the account came into existence
|
||||
// without anybody choosing a password, which AuthSource
|
||||
// states outright -- along with "a social account may later
|
||||
// set a real password". This is that moment, and nothing
|
||||
// else in the application writes it: the Connected accounts
|
||||
// screen reads `auth_source === Local` as
|
||||
// `has_local_password`, so without this line its refusal
|
||||
// goes on asking for a password that has just been set.
|
||||
//
|
||||
// The two branches of this method are the same rule read
|
||||
// twice: `social` is where the account came from and the
|
||||
// hash here is what signs it in, so choosing one settles it;
|
||||
// `ldap` is the authentication path itself, so nothing
|
||||
// chosen here settles anything.
|
||||
if ($user->auth_source === AuthSource::Social) {
|
||||
$attributes['auth_source'] = AuthSource::Local;
|
||||
}
|
||||
|
||||
$user->forceFill($attributes)->save();
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Ldap\LdapAuthenticator;
|
||||
use App\Modules\Identity\Ldap\LdapProvisioner;
|
||||
use App\Modules\Identity\PasswordVerification;
|
||||
use App\Modules\Identity\SignIn;
|
||||
use App\Modules\Platform\Captcha\CaptchaForm;
|
||||
use App\Support\Rules;
|
||||
use Illuminate\Auth\Events\Lockout;
|
||||
use Illuminate\Auth\SessionGuard;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -115,10 +114,9 @@ class LoginRequest extends FormRequest
|
||||
/**
|
||||
* The account whose password checks out, or null.
|
||||
*
|
||||
* The local hash is tried first and the directory only on failure, so
|
||||
* a login that succeeds locally never generates directory traffic.
|
||||
* The exception is an account whose credentials are known to live in
|
||||
* the directory, where the local hash is a placeholder nobody holds.
|
||||
* The rule itself -- local hash first, directory when the credentials
|
||||
* live there -- is PasswordVerification's, because this is no longer
|
||||
* the only screen that has to ask it. See that class.
|
||||
*/
|
||||
private function verifyCredentials(?User $user): ?User
|
||||
{
|
||||
@@ -126,67 +124,9 @@ class LoginRequest extends FormRequest
|
||||
return null;
|
||||
}
|
||||
|
||||
$ldap = app(LdapAuthenticator::class);
|
||||
|
||||
if (! $ldap->isDirectoryAccount($user)
|
||||
&& Auth::validate($this->only('email', 'password'))) {
|
||||
$this->upgradeHashIfStale($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
$identity = $ldap->attempt(
|
||||
(string) $this->string('email'),
|
||||
(string) $this->string('password'),
|
||||
$user,
|
||||
);
|
||||
|
||||
if ($identity === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ldap->stamp($user, $identity);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-hash a password stored under weaker settings than this
|
||||
* installation now uses.
|
||||
*
|
||||
* Laravel does this for you inside SessionGuard::attempt(), but this
|
||||
* form does not use attempt() — it verifies with Auth::validate() and
|
||||
* hands the account to SignIn, which calls Auth::login(). Neither
|
||||
* re-hashes, so without this an account keeps whatever cost it was
|
||||
* created under forever, and raising BCRYPT_ROUNDS would quietly
|
||||
* apply to new accounts only.
|
||||
*
|
||||
* That is not hypothetical: every account the v1 migration carries
|
||||
* across arrives as `$2y$08$…`, because v1 hashed at cost 8, and
|
||||
* would otherwise stay four times cheaper to attack than an account
|
||||
* created here.
|
||||
*
|
||||
* **Only ever called on the local branch.** On the directory branch
|
||||
* the submitted plaintext is the *LDAP* password and the local hash
|
||||
* is a `Str::password(64)` placeholder nobody holds; writing the
|
||||
* directory credential into it would mint a second way into the
|
||||
* account that keeps working after LDAP is switched off.
|
||||
*/
|
||||
private function upgradeHashIfStale(User $user): void
|
||||
{
|
||||
$guard = Auth::guard('web');
|
||||
|
||||
// getProvider() is on SessionGuard rather than on the StatefulGuard
|
||||
// contract. This guard is a SessionGuard in every configuration this
|
||||
// application ships; the check is here so a custom driver degrades
|
||||
// to "no re-hash" instead of a fatal on the login path.
|
||||
if (! $guard instanceof SessionGuard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No-ops unless the hasher says the stored digest needs it, so
|
||||
// this costs an already-current account nothing.
|
||||
$guard->getProvider()->rehashPasswordIfRequired($user, $this->only('password'));
|
||||
return app(PasswordVerification::class)->verify($user, (string) $this->string('password'))
|
||||
? $user
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
||||
use App\Modules\Api\Auth\ApiTokens;
|
||||
use App\Modules\Api\Models\ApiRequestLog;
|
||||
use App\Modules\Audit\ActivityLog;
|
||||
use App\Modules\Audit\ActivityLogScope;
|
||||
use App\Modules\Audit\ActivityOrigin;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -27,6 +28,7 @@ class ApiUsage
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApiUsageScope $scope,
|
||||
private readonly ActivityLogScope $activityLog,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -145,7 +147,23 @@ class ApiUsage
|
||||
*/
|
||||
public function recentActions(User $viewer, bool $installWide, int $limit = 15): array
|
||||
{
|
||||
$query = ActivityLog::query()->where('origin', ActivityOrigin::Api);
|
||||
// Narrowed through ActivityLogScope, exactly as the activity page,
|
||||
// the download history and the dashboard widget are.
|
||||
// `view_actions_log` decides whether the install-wide view opens at
|
||||
// all, but it is not the whole answer for a client-scoped viewer: a
|
||||
// row carries the subject's name, so an unscoped feed reads out file
|
||||
// and client names to somebody who gets a 403 on the files
|
||||
// themselves. The Client Manager role ships with the permission, so
|
||||
// this is the default configuration, not an exotic one.
|
||||
//
|
||||
// Applied on both sides of the branch rather than only in the
|
||||
// install-wide one: the own-actor filter below already stays inside
|
||||
// what the scope allows, and a boundary that only exists in one arm
|
||||
// of an `if` is one refactor away from not existing.
|
||||
$query = $this->activityLog->apply(
|
||||
ActivityLog::query()->where('origin', ActivityOrigin::Api),
|
||||
$viewer,
|
||||
);
|
||||
|
||||
if (! $installWide) {
|
||||
$query->where('actor_id', $viewer->id);
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Modules\Api\Support;
|
||||
|
||||
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
||||
use App\Support\ApiSurface;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
@@ -16,7 +17,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* RFC 7807 error bodies for /api/* only.
|
||||
* RFC 7807 error bodies for the API surface only -- see ApiSurface, which
|
||||
* is the same question the capability middleware asks.
|
||||
*
|
||||
* Two properties this class exists to guarantee:
|
||||
*
|
||||
@@ -55,7 +57,7 @@ class ProblemDetails
|
||||
|
||||
public function shouldHandle(Request $request): bool
|
||||
{
|
||||
return $request->is('api/*');
|
||||
return ApiSurface::matches($request);
|
||||
}
|
||||
|
||||
public function render(Request $request, Throwable $e): JsonResponse
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Modules\Audit\ActivityPresenter;
|
||||
use App\Modules\Audit\DashboardWidgetPreferences;
|
||||
use App\Modules\Clients\ClientStorageUsage;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use App\Modules\Identity\UserType;
|
||||
@@ -51,6 +52,7 @@ class DashboardController extends Controller
|
||||
private readonly Settings $settings,
|
||||
private readonly ApiUsage $apiUsage,
|
||||
private readonly StorageDurability $storageDurability,
|
||||
private readonly FileDelivery $fileDelivery,
|
||||
private readonly Installation $installation,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
private readonly SystemEnvironment $environment,
|
||||
@@ -155,8 +157,12 @@ class DashboardController extends Controller
|
||||
*
|
||||
* Every boundary is built in the viewer's zone, so "last week" ends
|
||||
* when their evening does and not at whatever hour UTC midnight falls
|
||||
* on for them. The returned instants are still absolute — only the
|
||||
* day edges moved — so they compare against the UTC column directly.
|
||||
* on for them. The instants are absolute, but they carry that zone —
|
||||
* and a Carbon handed to the query builder is formatted in its own
|
||||
* zone, offset discarded, so comparing one against a UTC column asks
|
||||
* a question nine hours out for a viewer in Tokyo. transferSeries()
|
||||
* converts before it compares; the day cursor there keeps them as
|
||||
* they are, because that half really is about the viewer's calendar.
|
||||
*
|
||||
* @return array{0: Carbon, 1: Carbon, 2: string}
|
||||
*/
|
||||
@@ -248,7 +254,13 @@ class DashboardController extends Controller
|
||||
|
||||
$rows = ActivityLog::query()
|
||||
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
|
||||
->whereBetween('created_at', [$from, $to])
|
||||
// In UTC, because that is what the column is. The query
|
||||
// builder formats a Carbon in whatever zone the object holds
|
||||
// and drops the offset, so passing the viewer's midnight
|
||||
// straight in compares "2026-08-22 00:00:00" against a UTC
|
||||
// column — nine hours of somebody else's day, at both ends,
|
||||
// for a viewer in Tokyo.
|
||||
->whereBetween('created_at', [$from->copy()->utc(), $to->copy()->utc()])
|
||||
->get(['action', 'actor_type', 'created_at'])
|
||||
// Bucketed by the viewer's calendar day. Grouping on the UTC
|
||||
// one puts an evening upload from anywhere west of Greenwich
|
||||
@@ -467,7 +479,7 @@ class DashboardController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|int|bool|array<string, string|null>|null>
|
||||
* @return array<string, array<string, bool|string|null>|bool|int|string|null>
|
||||
*/
|
||||
private function systemInfo(): array
|
||||
{
|
||||
@@ -492,28 +504,36 @@ class DashboardController extends Controller
|
||||
// Installation. Always present, unlike storage_durability, which
|
||||
// is null whenever the durability question does not apply.
|
||||
'install_kind' => $this->installation->kind()->value,
|
||||
// How downloads leave the server, and whether that was
|
||||
// detected or stated. Reported even when it is the fast path:
|
||||
// "my downloads are handed to the web server" is worth being
|
||||
// able to confirm at a glance, not only worth warning about
|
||||
// when it is false — the same reasoning as storage_durability.
|
||||
'file_delivery' => $this->fileDelivery->describe(),
|
||||
];
|
||||
}
|
||||
|
||||
private function clientDashboard(User $client): Response
|
||||
{
|
||||
$assignedFiles = File::query()->whereHas('assignments', function ($query) use ($client): void {
|
||||
$query->where(function ($direct) use ($client): void {
|
||||
$direct->where('assignable_type', User::class)->where('assignable_id', $client->id);
|
||||
})->orWhere(function ($viaGroup) use ($client): void {
|
||||
$viaGroup->where('assignable_type', Group::class)
|
||||
->whereIn('assignable_id', $client->memberOfGroups()->pluck('groups.id'));
|
||||
});
|
||||
});
|
||||
// File::scopeVisibleToClient is the single source of truth for
|
||||
// client file access, and this page has to agree with the portal it
|
||||
// introduces. Restating the assignment half here made it disagree
|
||||
// in both directions: it counted expired files, which the scope
|
||||
// ends by excluding and /my-files therefore never shows, and it
|
||||
// missed everything that reaches a client another way — a file in a
|
||||
// folder shared with them, their own portal upload, and a revision,
|
||||
// which owns no assignment row and inherits its original's
|
||||
// recipients.
|
||||
$visibleFiles = File::query()->visibleToClient($client);
|
||||
|
||||
return Inertia::render('portal/dashboard', [
|
||||
'files_count' => (clone $assignedFiles)->count(),
|
||||
'files_count' => (clone $visibleFiles)->count(),
|
||||
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
|
||||
'storage' => [
|
||||
'used_bytes' => $this->storageUsage->usedBytes($client),
|
||||
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
|
||||
],
|
||||
'latest_files' => $assignedFiles->orderByDesc('created_at')->limit(5)->get()
|
||||
'latest_files' => $visibleFiles->orderByDesc('created_at')->limit(5)->get()
|
||||
->map(fn (File $file): array => [
|
||||
'id' => $file->id,
|
||||
'name' => $file->name,
|
||||
|
||||
@@ -45,8 +45,14 @@ class DashboardWidgetPreferencesController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'columns' => ['required', 'integer', 'between:1,4'],
|
||||
'widgets' => ['required', 'array'],
|
||||
'widgets.*.widget_key' => ['required', 'string', Rule::in(self::WIDGET_KEYS)],
|
||||
// Bounded by the allowlist itself, and unique on the key. The
|
||||
// Rule::in below checks each value; it says nothing about how
|
||||
// many there are or whether they repeat, and the loop writes
|
||||
// one row per element. A layout has at most one entry per
|
||||
// widget, so anything longer than the registry is not a layout
|
||||
// this screen could have produced.
|
||||
'widgets' => ['required', 'array', 'max:'.count(self::WIDGET_KEYS)],
|
||||
'widgets.*.widget_key' => ['required', 'string', 'distinct', Rule::in(self::WIDGET_KEYS)],
|
||||
'widgets.*.enabled' => ['required', 'boolean'],
|
||||
'widgets.*.column_index' => ['required', 'integer', 'between:0,3'],
|
||||
'widgets.*.position' => ['required', 'integer', 'min:0'],
|
||||
|
||||
@@ -158,7 +158,23 @@ class ClientPortalCustomFields
|
||||
*/
|
||||
private function isLocked(ClientCustomField $field, BaseCollection $values): bool
|
||||
{
|
||||
return $field->client_editability === ClientFieldEditability::EditableOnce
|
||||
&& filled($values->get($field->id));
|
||||
if ($field->client_editability !== ClientFieldEditability::EditableOnce) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stored = $values->get($field->id);
|
||||
|
||||
// A checkbox has a stored value from the first save onwards: an
|
||||
// unticked box is written as '0', and filled('0') is true. Asking
|
||||
// "is anything stored" therefore locked the field on the first save
|
||||
// of the form it sits on, whatever the client had chosen — and a
|
||||
// box they never ticked can then never be ticked. '0' is the
|
||||
// absence of a decision, which is the state the other types express
|
||||
// as null, so it is what an unlocked checkbox looks like.
|
||||
if ($field->type === ClientCustomFieldType::Checkbox) {
|
||||
return $stored === '1';
|
||||
}
|
||||
|
||||
return filled($stored);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,22 @@ class ClientProvisioning
|
||||
return $this->settings->get(Setting::ClientsAutoApprove) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an address is free for a new account.
|
||||
*
|
||||
* The unique index on `email` spans soft-deleted rows — AvailableEmailRule
|
||||
* is built on exactly that, so a deleted account keeps its address until
|
||||
* erasure takes the row away. The registration form learns this from
|
||||
* validation. The machine paths have no form to validate: a directory or
|
||||
* an identity provider hands over an address and provision() inserts it,
|
||||
* so without asking first the insert raises a QueryException in the
|
||||
* middle of somebody's sign-in.
|
||||
*/
|
||||
public function addressIsFree(string $email): bool
|
||||
{
|
||||
return ! User::withTrashed()->where('email', $email)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool|null $autoApprove Null asks Setting::ClientsAutoApprove,
|
||||
* which is the right question for the
|
||||
|
||||
@@ -29,6 +29,7 @@ use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -153,6 +154,20 @@ class ClientsController extends Controller
|
||||
|
||||
$this->activity->log(Action::UserCreated, subject: $client);
|
||||
|
||||
$creator = $request->user();
|
||||
assert($creator !== null);
|
||||
|
||||
// A client-scoped creator would otherwise lose the client they just
|
||||
// made. guardTarget() answers 404 for anything off their roster, so
|
||||
// the record they created is not theirs to open, and
|
||||
// StaffLibraryScope::clients() leaves it out of their list as well —
|
||||
// the client exists, is welcomed by email, and is invisible to the
|
||||
// person who made it. Their own roster is where a client they
|
||||
// created belongs; an unscoped creator has no roster to add to.
|
||||
if ($creator->isClientScoped()) {
|
||||
$creator->assignedClients()->attach($client->id);
|
||||
}
|
||||
|
||||
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
||||
|
||||
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||
@@ -206,7 +221,7 @@ class ClientsController extends Controller
|
||||
$client->save();
|
||||
|
||||
if (array_key_exists('custom_field_values', $validated)) {
|
||||
$this->saveCustomFieldValues($client, $validated['custom_field_values']);
|
||||
$this->patchCustomFieldValues($client, $validated['custom_field_values']);
|
||||
}
|
||||
|
||||
$this->activity->log(Action::UserUpdated, subject: $client);
|
||||
@@ -364,11 +379,43 @@ class ClientsController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Every field, whether or not the request named it — a new client has
|
||||
* no values yet, and create() is not a partial update.
|
||||
*
|
||||
* @param array<int, mixed> $values field id => submitted value
|
||||
*/
|
||||
private function saveCustomFieldValues(User $client, array $values): void
|
||||
{
|
||||
foreach (ClientCustomField::query()->get() as $field) {
|
||||
$this->writeCustomFieldValues($client, ClientCustomField::query()->get(), $values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the fields the request actually named.
|
||||
*
|
||||
* PATCH semantics, the same rule update() applies to every other
|
||||
* column: an absent key means "leave alone", not "clear". Sharing
|
||||
* create()'s "write every field" pass here emptied every custom field
|
||||
* the caller had not mentioned, which is silent data loss on a request
|
||||
* that looked like it changed one thing.
|
||||
*
|
||||
* @param array<int, mixed> $values field id => submitted value
|
||||
*/
|
||||
private function patchCustomFieldValues(User $client, array $values): void
|
||||
{
|
||||
$this->writeCustomFieldValues(
|
||||
$client,
|
||||
ClientCustomField::query()->whereIn('id', array_keys($values))->get(),
|
||||
$values,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, ClientCustomField> $fields
|
||||
* @param array<int, mixed> $values field id => submitted value
|
||||
*/
|
||||
private function writeCustomFieldValues(User $client, Collection $fields, array $values): void
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
$submitted = $values[$field->id] ?? null;
|
||||
$value = $field->type === ClientCustomFieldType::Checkbox
|
||||
? ($submitted ? '1' : '0')
|
||||
|
||||
@@ -98,7 +98,12 @@ class ClientsController extends Controller
|
||||
'clients' => $clients->items(),
|
||||
'pagination' => Pagination::meta($clients),
|
||||
'filters' => $filters,
|
||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
||||
// Only for somebody who may actually reassign: the picker is
|
||||
// part of the delete dialog, and React filtering it out of the
|
||||
// page is not the same as it never being on the page.
|
||||
'reassign_candidates' => $viewer->can('delete_clients')
|
||||
? $this->accountDeletion->candidates($viewer)
|
||||
: [],
|
||||
// Null on a self-hosted install: no limit, nothing to say.
|
||||
'seats' => $this->seats->clientState(),
|
||||
]);
|
||||
@@ -154,6 +159,20 @@ class ClientsController extends Controller
|
||||
|
||||
$this->activity->log(Action::UserCreated, subject: $client);
|
||||
|
||||
$creator = $request->user();
|
||||
assert($creator !== null);
|
||||
|
||||
// A client-scoped creator would otherwise lose the client they just
|
||||
// made. guardTarget() answers 404 for anything off their roster, so
|
||||
// the record they created is not theirs to open, and
|
||||
// StaffLibraryScope::clients() leaves it out of their list as well —
|
||||
// the client exists, is welcomed by email, and is invisible to the
|
||||
// person who made it. Their own roster is where a client they
|
||||
// created belongs; an unscoped creator has no roster to add to.
|
||||
if ($creator->isClientScoped()) {
|
||||
$creator->assignedClients()->attach($client->id);
|
||||
}
|
||||
|
||||
$this->saveCustomFieldValues($client, $validated['custom_field_values'] ?? []);
|
||||
|
||||
if ($this->settings->get(Setting::EmailNotificationsEnabled) === true) {
|
||||
@@ -166,7 +185,7 @@ class ClientsController extends Controller
|
||||
// Fall back to the create form: it shares this route's own gate, so
|
||||
// it is reachable by exactly whoever just created the record, and
|
||||
// the success toast shows there.
|
||||
$target = $request->user()?->can('edit_clients')
|
||||
$target = $creator->can('edit_clients')
|
||||
? redirect()->route('clients.edit', $client)
|
||||
: redirect()->route('clients.create');
|
||||
|
||||
@@ -214,7 +233,9 @@ class ClientsController extends Controller
|
||||
->where('user_id', $client->id)
|
||||
->pluck('value', 'client_custom_field_id'),
|
||||
'content' => $this->accountContent->summarize($client),
|
||||
'reassign_candidates' => $this->accountDeletion->candidates($client->id),
|
||||
'reassign_candidates' => $request->user()?->can('delete_clients') === true
|
||||
? $this->accountDeletion->candidates($request->user(), $client->id)
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Modules\Comments\GuestCommentIdentity;
|
||||
use App\Modules\Comments\Models\FileComment;
|
||||
use App\Modules\Files\Access\ShareTargets;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Identity\UserType;
|
||||
@@ -51,6 +52,7 @@ class VisibleCommentScope
|
||||
{
|
||||
public function __construct(
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly ViewableFileScope $viewable,
|
||||
private readonly ShareTargets $shareTargets,
|
||||
private readonly GuestCommentIdentity $guests,
|
||||
) {}
|
||||
@@ -123,6 +125,13 @@ class VisibleCommentScope
|
||||
* way around the visibility model** — moderating means deciding about
|
||||
* comments you can already see.
|
||||
*
|
||||
* Which is why the files come from ViewableFileScope rather than from
|
||||
* StaffLibraryScope: FilePolicy::view() is a permission half AND a
|
||||
* library half, and narrowing by the library alone would hand every
|
||||
* comment in the installation to a role holding moderate_comments and
|
||||
* none of the three file keys — somebody who gets a 403 on every file
|
||||
* these comments are about.
|
||||
*
|
||||
* Staff only. A client has no cross-file view of comments and asking
|
||||
* for one is a mistake rather than an empty result, but returning
|
||||
* nothing is the safe way to be wrong.
|
||||
@@ -136,7 +145,7 @@ class VisibleCommentScope
|
||||
}
|
||||
|
||||
return $this->applyVisibility(
|
||||
FileComment::query()->whereIn('file_id', $this->scope->files($viewer)->select('files.id')),
|
||||
FileComment::query()->whereIn('file_id', $this->viewable->for($viewer)->select('files.id')),
|
||||
$viewer,
|
||||
// Publicness is a property of each file, so it cannot be one
|
||||
// value for a query spanning many. It does not have to be: the
|
||||
@@ -156,6 +165,12 @@ class VisibleCommentScope
|
||||
* than about what this viewer may read, and a moderator who cannot see
|
||||
* a particular client's thread must still be told the file has
|
||||
* something waiting.
|
||||
*
|
||||
* The file boundary is still the same one, though. ViewableFileScope
|
||||
* rather than StaffLibraryScope: which files is the part that varies
|
||||
* per client, whether any is the part that does not, and a badge
|
||||
* counting the whole installation for somebody who may open none of it
|
||||
* is a number about other people's files.
|
||||
*/
|
||||
public function pendingTotal(User $viewer): int
|
||||
{
|
||||
@@ -165,7 +180,7 @@ class VisibleCommentScope
|
||||
|
||||
return FileComment::query()
|
||||
->whereNull('approved_at')
|
||||
->whereIn('file_id', $this->scope->files($viewer)->select('files.id'))
|
||||
->whereIn('file_id', $this->viewable->for($viewer)->select('files.id'))
|
||||
->count();
|
||||
}
|
||||
|
||||
|
||||
@@ -141,14 +141,19 @@ class CommentPresenter
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked of the column, not of the relation — the same rule
|
||||
* isFromGuest() and authorName() follow. Since author() reads a
|
||||
* deleted account too this would now answer correctly either way; it
|
||||
* is written this way so the next reader does not re-derive "no
|
||||
* author row means guest", which is what it used to mean here.
|
||||
*/
|
||||
private function authorType(FileComment $comment): string
|
||||
{
|
||||
$author = $comment->author;
|
||||
|
||||
if ($author === null) {
|
||||
if ($comment->isFromGuest()) {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
return $author->isStaff() ? 'staff' : 'client';
|
||||
return $comment->author?->isStaff() === true ? 'staff' : 'client';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
||||
use App\Modules\Comments\Access\VisibleCommentScope;
|
||||
use App\Modules\Comments\Models\FileComment;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
@@ -22,6 +23,7 @@ class FileCommentPolicy
|
||||
private readonly VisibleCommentScope $scope,
|
||||
private readonly CommentingRules $rules,
|
||||
private readonly StaffLibraryScope $library,
|
||||
private readonly ViewableFileScope $viewable,
|
||||
) {}
|
||||
|
||||
public function view(User $user, FileComment $comment): bool
|
||||
@@ -69,6 +71,15 @@ class FileCommentPolicy
|
||||
return false;
|
||||
}
|
||||
|
||||
// Moderating is deciding about comments you can already see, so the
|
||||
// permission half of file reading is part of the answer in both
|
||||
// forms. Without one of the three file keys this user gets a 403 on
|
||||
// every file these comments are about, and approving one hands back
|
||||
// its body — so this is a reading door, not only a writing one.
|
||||
if (! $this->viewable->permitsAnyFile($user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($comment === null || ! $user->isClientScoped()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Modules\Comments\FileComments;
|
||||
use App\Modules\Comments\Http\Resources\Api\FileCommentResource;
|
||||
use App\Modules\Comments\Models\FileComment;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -30,16 +30,17 @@ class CommentModerationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileComments $comments,
|
||||
private readonly StaffLibraryScope $library,
|
||||
private readonly ViewableFileScope $viewable,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List comments awaiting approval.
|
||||
*
|
||||
* Scoped by the same library boundary as everything else: a
|
||||
* client-scoped token sees pending comments only on files its owner
|
||||
* could already open. Oldest first, so working through the list means
|
||||
* working through the backlog.
|
||||
* Scoped by the same file boundary as everything else — the whole of
|
||||
* it, not just its library half: a client-scoped token sees pending
|
||||
* comments only on files its owner could already open, and a token
|
||||
* whose owner holds no file key at all sees none. Oldest first, so
|
||||
* working through the list means working through the backlog.
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
@@ -49,7 +50,7 @@ class CommentModerationController extends Controller
|
||||
|
||||
$pending = FileComment::query()
|
||||
->whereNull('approved_at')
|
||||
->whereIn('file_id', $this->library->files($viewer)->select('id'))
|
||||
->whereIn('file_id', $this->viewable->for($viewer)->select('id'))
|
||||
->with(['author', 'clientContext'])
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
|
||||
@@ -147,15 +147,17 @@ class CommentsController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* See CommentPresenter::authorType(): asked of the column, because
|
||||
* that is what decides whether a comment is a guest's.
|
||||
*/
|
||||
private function authorType(FileComment $comment): string
|
||||
{
|
||||
$author = $comment->author;
|
||||
|
||||
if ($author === null) {
|
||||
if ($comment->isFromGuest()) {
|
||||
return 'guest';
|
||||
}
|
||||
|
||||
return $author->isStaff() ? 'staff' : 'client';
|
||||
return $comment->author?->isStaff() === true ? 'staff' : 'client';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,11 +76,30 @@ class FileComment extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* The account that wrote this comment, deleted or not.
|
||||
*
|
||||
* `author_id` is cascadeOnDelete and the cascade never fires, because
|
||||
* a user is soft-deleted: the row behind a deleted commenter is still
|
||||
* there and the column still points at it. Handing back null for one
|
||||
* left every caller to invent a meaning for the absence, and they
|
||||
* invented different ones — the author type became "guest" on two
|
||||
* screens and "client" in the API, while the name beside it stayed
|
||||
* correct, and the author filter and the name search stopped matching
|
||||
* the comment at all.
|
||||
*
|
||||
* Whether a comment is from a guest is decided by `author_id` alone.
|
||||
* isFromGuest() and authorName() already say so; this makes the
|
||||
* relation agree with them.
|
||||
*
|
||||
* Nothing that decides who may *read* a comment goes through here —
|
||||
* VisibleCommentScope and FileCommentPolicy both compare `author_id`
|
||||
* directly — so this widens no visibility.
|
||||
*
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function author(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'author_id');
|
||||
return $this->belongsTo(User::class, 'author_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -268,6 +268,15 @@ class StaffLibraryScope
|
||||
* row rather than from the assignment ignores the dead ones by
|
||||
* construction, which is also the right answer: a deleted file is
|
||||
* not reach, because nobody can reach it.
|
||||
*
|
||||
* An expired file is the same answer for the same reason. Membership
|
||||
* in this group grants nobody access to it — File::scopeVisibleToClient
|
||||
* ends in notExpired(), so it is gone from every member's /my-files and
|
||||
* the download is refused — while its absence from files() otherwise
|
||||
* reads as "outside my library" and locks the group exactly as a
|
||||
* deleted file used to. Expiry is reversible where deletion is not, so
|
||||
* the file counts as reach again the moment it does: this asks what is
|
||||
* reachable now, at the moment somebody is added or removed.
|
||||
*/
|
||||
private function groupReachesNoFurther(User $user, Group $group): bool
|
||||
{
|
||||
@@ -282,6 +291,7 @@ class StaffLibraryScope
|
||||
|
||||
$outside = File::query()
|
||||
->whereIn('id', $assignedFiles)
|
||||
->notExpired()
|
||||
->whereNotIn('id', $this->files($user)->select('id'))
|
||||
->exists();
|
||||
|
||||
@@ -292,9 +302,54 @@ class StaffLibraryScope
|
||||
$assignedFolders = FolderAssignment::query()->select('folder_id')
|
||||
->where('assignable_type', $morph)->where('assignable_id', $group->id);
|
||||
|
||||
return ! Folder::query()
|
||||
->whereIn('id', $assignedFolders)
|
||||
// The whole subtree, not the folder the assignment names. A folder
|
||||
// shared with a group hands its members everything inside it —
|
||||
// File::scopeVisibleToClient matches on folder placement, and a
|
||||
// folder is visible to a client when it or an ancestor is shared
|
||||
// with them — so "is anything shared with this group outside my
|
||||
// library" has to ask about the contents, which is what the
|
||||
// docblock above already claims ("the folders whose subtrees it
|
||||
// can browse").
|
||||
//
|
||||
// Measured: a scoped staff member's own folder, with a subfolder
|
||||
// somebody else created inside it and somebody else's file in
|
||||
// that. The folder is theirs, its contents are not, and adding
|
||||
// their own client to a group holding the parent handed that
|
||||
// client the file — which then enters the staff member's own
|
||||
// library too, because files() is "everything my clients can
|
||||
// see". That is the widening this guard exists to refuse, and the
|
||||
// test above it says so in as many words.
|
||||
$reachable = Folder::query()->whereIn('id', $assignedFolders)->get()
|
||||
->flatMap(fn (Folder $folder): array => $folder->subtreeFolderIds())
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($reachable === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Folder::query()
|
||||
->whereIn('id', $reachable)
|
||||
->whereNotIn('id', $this->folders($user)->select('id'))
|
||||
->exists()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// And the files sitting in them. A folder can be inside the
|
||||
// library while a file in it is not: files() is own uploads plus
|
||||
// what an assigned client may see, and neither covers somebody
|
||||
// else's upload into a folder this staff member happens to own.
|
||||
//
|
||||
// notExpired() for the same reason the assignment half above skips
|
||||
// deleted files: membership in this group grants nobody access to
|
||||
// an expired file, because scopeVisibleToClient ends by excluding
|
||||
// them, and something nobody can reach is not reach.
|
||||
return ! File::query()
|
||||
->whereIn('folder_id', $reachable)
|
||||
->notExpired()
|
||||
->whereNotIn('id', $this->files($user)->select('id'))
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,15 +40,25 @@ class ViewableFileScope
|
||||
return File::query()->visibleToClient($user);
|
||||
}
|
||||
|
||||
// Mirrors FilePolicy::view()'s staff branch: the permission half is
|
||||
// a property of the viewer, not the row, so it either opens the
|
||||
// whole scope or closes it entirely.
|
||||
$permitted = $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files');
|
||||
|
||||
if (! $permitted) {
|
||||
if (! $this->permitsAnyFile($user)) {
|
||||
return File::query()->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
return $this->scope->files($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a staff member holds any of the three keys that open file
|
||||
* reading at all — the permission half of FilePolicy::view()'s staff
|
||||
* branch, named once because more than one module has to ask it.
|
||||
*
|
||||
* It is a property of the viewer rather than of a row, so it either
|
||||
* opens the whole scope or closes it entirely. That is also why a
|
||||
* query narrowed by StaffLibraryScope alone is only half the check:
|
||||
* the library says *which* files, this says *whether any*.
|
||||
*/
|
||||
public function permitsAnyFile(User $user): bool
|
||||
{
|
||||
return $user->can('upload') || $user->can('edit_files') || $user->can('edit_others_files');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Delivery;
|
||||
|
||||
/**
|
||||
* How a file's bytes get from this server's disk to the visitor.
|
||||
*
|
||||
* Uploads live outside the web root, so every download passes through a
|
||||
* permission check in PHP first. What differs is what happens after that
|
||||
* check passes: PHP can read the file and write it out itself, or it can
|
||||
* answer with an empty body and a header telling the web server to send
|
||||
* the file instead.
|
||||
*
|
||||
* The header is the fast path and it is not portable — each server reads
|
||||
* a different one, and a server reading none of them serves the empty
|
||||
* body, which is how an installation ends up handing out 0-byte
|
||||
* downloads while every other page works. ProjectSend v1 had this as a
|
||||
* four-way setting with PHP as the default; v2 hard-coded nginx's
|
||||
* spelling for its first releases, which is
|
||||
* https://github.com/projectsend/projectsend/issues/1765.
|
||||
*/
|
||||
enum DeliveryMethod: string
|
||||
{
|
||||
/**
|
||||
* nginx: `X-Accel-Redirect`, carrying a *URL path* that the
|
||||
* `location /protected-files/` block maps back onto the storage
|
||||
* directory. That block is marked `internal`, which is what stops a
|
||||
* visitor requesting the path directly.
|
||||
*/
|
||||
case Nginx = 'nginx';
|
||||
|
||||
/**
|
||||
* Apache with `mod_xsendfile`, and LiteSpeed, which reads the same
|
||||
* header: `X-Sendfile`, carrying an *absolute filesystem path*.
|
||||
*
|
||||
* Never chosen automatically. The module also needs `XSendFilePath`
|
||||
* to whitelist the storage directory, and there is no way to detect
|
||||
* that from here — picking this on the strength of the module being
|
||||
* loaded would trade one silent failure for another.
|
||||
*/
|
||||
case XSendFile = 'xsendfile';
|
||||
|
||||
/**
|
||||
* PHP reads the file and streams it.
|
||||
*
|
||||
* Works on every server, and costs a worker process for the duration
|
||||
* of each download — a handful of large concurrent downloads can
|
||||
* occupy every worker while the CPU sits idle. That is why it is the
|
||||
* fallback rather than the default, and why an installation using it
|
||||
* says so on the dashboard rather than being quietly slow.
|
||||
*/
|
||||
case Php = 'php';
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Delivery;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* Puts a file that lives on this server's local disk on the wire.
|
||||
*
|
||||
* The single place that knows how the bytes travel. Four routes used to
|
||||
* decide that for themselves and all four hard-coded nginx's header, so
|
||||
* an Apache or LiteSpeed installation served four different flavours of
|
||||
* empty response — uploads worked, thumbnails were broken images, and
|
||||
* downloads arrived as 0 bytes. Callers now say *what* to send and this
|
||||
* decides *how*.
|
||||
*
|
||||
* It authorizes nothing. Every caller has already done that its own way
|
||||
* — a policy, a share token, a public-listing check — and the path it
|
||||
* passes is always derived from a row it just authorized, never from the
|
||||
* request. That is load-bearing: `serve()` will send any file under the
|
||||
* storage root, so a caller that passed user input would have built a
|
||||
* file-disclosure bug. The root check below is the backstop, not the
|
||||
* rule.
|
||||
*
|
||||
* ### Choosing the method
|
||||
*
|
||||
* `PROJECTSEND_FILE_DELIVERY` picks one explicitly. Left at `auto` — the
|
||||
* default — nginx gets its own fast path and everything else gets PHP
|
||||
* streaming.
|
||||
*
|
||||
* Auto deliberately never chooses `xsendfile`. Apache's `mod_xsendfile`
|
||||
* needs `XSendFilePath` to whitelist the storage directory as well as
|
||||
* being loaded, and nothing here can see whether it does; choosing it
|
||||
* because the module is present would swap a silent failure anybody can
|
||||
* diagnose from the dashboard for one nobody can. So it stays something
|
||||
* an operator turns on having configured it.
|
||||
*
|
||||
* A value that is not a method falls back to auto rather than throwing.
|
||||
* A typo in an environment variable should cost speed, not every
|
||||
* download on the installation.
|
||||
*/
|
||||
class FileDelivery
|
||||
{
|
||||
/**
|
||||
* The disk uploads live on. Named rather than injected because the
|
||||
* whole class is about the local-disk case: a file on S3 never
|
||||
* reaches here, it is a signed redirect from StoredFileResponse.
|
||||
*/
|
||||
private const DISK = 'files';
|
||||
|
||||
/** The internal nginx location that maps back onto the storage root. */
|
||||
private const NGINX_LOCATION = '/protected-files/';
|
||||
|
||||
public function __construct(private readonly Request $request) {}
|
||||
|
||||
/**
|
||||
* The method in force, and whether it was detected or stated.
|
||||
*
|
||||
* @return array{method: DeliveryMethod, detected: bool}
|
||||
*/
|
||||
public function resolve(): array
|
||||
{
|
||||
$configured = config('projectsend.file_delivery');
|
||||
$explicit = is_string($configured) ? DeliveryMethod::tryFrom($configured) : null;
|
||||
|
||||
if ($explicit !== null) {
|
||||
return ['method' => $explicit, 'detected' => false];
|
||||
}
|
||||
|
||||
return ['method' => $this->detect(), 'detected' => true];
|
||||
}
|
||||
|
||||
public function method(): DeliveryMethod
|
||||
{
|
||||
return $this->resolve()['method'];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same answer as a plain array, for a screen or a probe.
|
||||
*
|
||||
* Spelled out rather than leaning on a backed enum encoding itself,
|
||||
* because this shape is read by the dashboard and by whatever watches
|
||||
* the installation from outside, and neither should change meaning if
|
||||
* the enum ever grows a JsonSerializable of its own.
|
||||
*
|
||||
* @return array{method: string, detected: bool}
|
||||
*/
|
||||
public function describe(): array
|
||||
{
|
||||
$resolved = $this->resolve();
|
||||
|
||||
return [
|
||||
'method' => $resolved['method']->value,
|
||||
// True when nobody said which to use. The distinction matters
|
||||
// to the reader: a detected `php` is an installation that
|
||||
// could be faster, a stated one is somebody's decision.
|
||||
'detected' => $resolved['detected'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the server says it is.
|
||||
*
|
||||
* `SERVER_SOFTWARE` is set by the web server itself through the
|
||||
* FastCGI parameters, so it describes the process actually holding
|
||||
* the connection to PHP. That is the right thing to ask: the header
|
||||
* has to be understood by *that* server, not by whatever sits in
|
||||
* front of it.
|
||||
*
|
||||
* The known-wrong case is nginx reverse-proxying Apache, which
|
||||
* INSTALL.md offers as a way to keep an existing Apache. This reads
|
||||
* Apache and picks PHP streaming, so downloads work and are slower
|
||||
* than they need to be — the safe direction, and the reason the
|
||||
* override exists.
|
||||
*/
|
||||
private function detect(): DeliveryMethod
|
||||
{
|
||||
$software = $this->request->server('SERVER_SOFTWARE');
|
||||
$software = strtolower(is_string($software) ? $software : '');
|
||||
|
||||
return str_contains($software, 'nginx') ? DeliveryMethod::Nginx : DeliveryMethod::Php;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $path disk-relative, and always derived from an
|
||||
* already-authorized row — never from the request
|
||||
* @param int|null $length when the caller already knows it; PHP
|
||||
* streaming ignores it and measures the file
|
||||
*/
|
||||
public function serve(string $path, string $mimeType, string $disposition, ?int $length = null): Response|BinaryFileResponse
|
||||
{
|
||||
$this->assertRelative($path);
|
||||
|
||||
$headers = array_filter([
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Disposition' => $disposition,
|
||||
'Content-Length' => $length === null ? null : (string) $length,
|
||||
], static fn (?string $value): bool => $value !== null);
|
||||
|
||||
return match ($this->method()) {
|
||||
DeliveryMethod::Nginx => response('', 200, [
|
||||
'X-Accel-Redirect' => self::NGINX_LOCATION.$path,
|
||||
...$headers,
|
||||
]),
|
||||
DeliveryMethod::XSendFile => response('', 200, [
|
||||
// An absolute filesystem path, unlike nginx's URL path.
|
||||
// Renaming the header without changing the value is the
|
||||
// obvious way to "add Apache support" and produces a
|
||||
// second broken install.
|
||||
'X-Sendfile' => $this->absolutePathWithin($path),
|
||||
...$headers,
|
||||
]),
|
||||
DeliveryMethod::Php => $this->stream($this->absolutePathWithin($path), $headers),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
private function stream(string $absolute, array $headers): BinaryFileResponse
|
||||
{
|
||||
// A large download can outlive max_execution_time, and the visitor
|
||||
// sees a truncated file rather than an error. The web server is
|
||||
// not holding this one open for us.
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit(0);
|
||||
}
|
||||
|
||||
// BinaryFileResponse rather than a hand-written readfile loop: it
|
||||
// answers Range requests, which is what makes seeking through a
|
||||
// long video work. nginx does that for itself on the fast path, so
|
||||
// rolling our own here would break preview scrubbing on exactly
|
||||
// the installations this fallback exists for.
|
||||
//
|
||||
// Content-Length is deliberately dropped from the headers: the
|
||||
// response sets its own from the file, and a caller's figure that
|
||||
// disagrees — a stale `files.size`, or a range being served —
|
||||
// truncates the download.
|
||||
unset($headers['Content-Length']);
|
||||
|
||||
return new BinaryFileResponse($absolute, 200, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path must stay a path *inside* the storage area.
|
||||
*
|
||||
* Checked for every method, and without touching the filesystem,
|
||||
* because nginx resolves `..` in the URL it is handed just as
|
||||
* happily as a filesystem call would -- and because every method
|
||||
* puts this value into a response header. Callers pass paths from rows
|
||||
* they authorized rather than from the request, so this is a
|
||||
* backstop; it is here because the cost of being wrong about that,
|
||||
* once, is handing over any file the web server can read.
|
||||
*/
|
||||
private function assertRelative(string $path): void
|
||||
{
|
||||
abort_if(
|
||||
$path === ''
|
||||
|| str_starts_with($path, '/')
|
||||
|| preg_match('#(^|/)\.\.(/|$)#', $path) === 1
|
||||
// A control character in the path is header injection, not
|
||||
// traversal: this value is written into X-Accel-Redirect or
|
||||
// X-Sendfile, and a CR or LF in a header value splits the
|
||||
// response. PHP's header() refuses to emit one, so the real
|
||||
// effect is a 500 on every download, preview and thumbnail
|
||||
// of that file rather than a split -- a file permanently
|
||||
// broken by its own name.
|
||||
//
|
||||
// Paths are `Y/m/{uuid}.{ext}` and generated here, so this
|
||||
// should be unreachable. It is checked because the
|
||||
// extension is not: it is taken from the uploader's
|
||||
// filename, and on a migrated installation from a v1
|
||||
// database, which is somebody else's data.
|
||||
|| preg_match('/[\x00-\x1F\x7F]/', $path) === 1,
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path, proven to resolve inside the storage root.
|
||||
*
|
||||
* Only the two methods that hand over a *filesystem* path need this,
|
||||
* and only they can afford it: it resolves symlinks, so it answers
|
||||
* the question `assertRelative()` cannot — whether the file is really
|
||||
* where the path says it is.
|
||||
*
|
||||
* It also requires the file to exist, which is why nginx does not go
|
||||
* through it. On that path PHP never opens the file, and adding a
|
||||
* stat to every download to discover something nginx is about to
|
||||
* discover anyway would be a cost with no answer attached.
|
||||
*/
|
||||
private function absolutePathWithin(string $path): string
|
||||
{
|
||||
$disk = Storage::disk(self::DISK);
|
||||
|
||||
$absolute = realpath($disk->path($path));
|
||||
$root = realpath($disk->path(''));
|
||||
|
||||
abort_if(
|
||||
$absolute === false || $root === false || ! str_starts_with($absolute, rtrim($root, '/').'/'),
|
||||
404,
|
||||
);
|
||||
|
||||
return $absolute;
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ namespace App\Modules\Files\Delivery;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* A stored file's own bytes, put on the wire for whichever disk it lives
|
||||
@@ -20,15 +20,16 @@ use Illuminate\Support\Facades\Storage;
|
||||
* asking. The one thing it knows is the thing each caller kept getting
|
||||
* wrong on its own: that `$file->disk` decides how the bytes travel.
|
||||
*
|
||||
* Local disk: X-Accel-Redirect, so nginx streams the file and PHP never
|
||||
* touches the bytes. Anything else — S3, GCS and friends — gets a
|
||||
* short-lived presigned URL carrying the disposition, which an object
|
||||
* store ranges just as well.
|
||||
* Local disk: handed to FileDelivery, which decides whether the web
|
||||
* server sends the bytes or PHP does. Anything else — S3, GCS and
|
||||
* friends — gets a short-lived presigned URL carrying the disposition,
|
||||
* which an object store ranges just as well.
|
||||
*
|
||||
* That distinction matters most for inline(): a <video> seeking through
|
||||
* an hour of footage issues a long tail of Range requests, and nginx's
|
||||
* static handler answers those with 206s on its own, dropping the
|
||||
* Content-Length below in favour of the range it actually served.
|
||||
* an hour of footage issues a long tail of Range requests. Every local
|
||||
* delivery method answers those — nginx's static handler on the fast
|
||||
* path, BinaryFileResponse when PHP is streaming — each dropping the
|
||||
* Content-Length passed here in favour of the range actually served.
|
||||
*
|
||||
* Callers of inline() must have established that the mime type is
|
||||
* inline-safe first; PreviewKind is the allowlist, and the reason there
|
||||
@@ -36,6 +37,8 @@ use Illuminate\Support\Facades\Storage;
|
||||
*/
|
||||
class StoredFileResponse
|
||||
{
|
||||
public function __construct(private readonly FileDelivery $delivery) {}
|
||||
|
||||
/** Shown in place — a preview. */
|
||||
public function inline(File $file): Response|RedirectResponse
|
||||
{
|
||||
@@ -60,11 +63,6 @@ class StoredFileResponse
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$file->path,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => $disposition,
|
||||
'Content-Length' => (string) $file->size,
|
||||
]);
|
||||
return $this->delivery->serve($file->path, $file->mime_type, $disposition, $file->size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,15 +24,37 @@ class FileDiskCleanup
|
||||
{
|
||||
public function delete(File $file): void
|
||||
{
|
||||
try {
|
||||
Storage::disk($file->disk)->delete($file->path);
|
||||
$this->attempt($file, fn () => Storage::disk($file->disk)->delete($file->path));
|
||||
|
||||
// Every rendition, for every audience — a deleted file's bytes
|
||||
// must not survive on disk because whoever wrote the cleanup
|
||||
// only knew about the one copy they had in mind.
|
||||
// Every rendition, for every audience — a deleted file's bytes must
|
||||
// not survive on disk because whoever wrote the cleanup only knew
|
||||
// about the one copy they had in mind.
|
||||
//
|
||||
// Attempted separately from the original above, not because the two
|
||||
// are unrelated but because they are on different disks: renditions
|
||||
// are always local, and Storage::disk() throws outright for a name
|
||||
// with no configured driver — which is exactly the state the
|
||||
// original's disk is in when this fails at all. Sharing one `try`
|
||||
// meant a file whose source disk had been removed kept every cached
|
||||
// copy of itself, and nothing looks for those again:
|
||||
// OrphanFileScanner skips the rendition directories on purpose.
|
||||
$this->attempt($file, function () use ($file): void {
|
||||
foreach (ThumbnailGenerator::pathsFor($file->id, $file->mime_type) as $renditionPath) {
|
||||
Storage::disk('files')->delete($renditionPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately tolerant, as the class docblock says: the warning is the
|
||||
* whole report. Nothing else will find these bytes -- the row is
|
||||
* soft-deleted, and OrphanFileScanner::knownPaths() counts a trashed
|
||||
* row's path as claimed, so a scan never lists it.
|
||||
*/
|
||||
private function attempt(File $file, callable $work): void
|
||||
{
|
||||
try {
|
||||
$work();
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Could not remove disk bytes for deleted file '.$file->id.': '.$exception->getMessage());
|
||||
}
|
||||
|
||||
@@ -21,9 +21,12 @@ use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Storage\ResolvingUploadDisk;
|
||||
use App\Modules\Files\Uploads\StoreUploadedFile;
|
||||
use App\Modules\Files\Uploads\UploadExtensionPolicy;
|
||||
use App\Modules\Platform\Localization\LocalDay;
|
||||
use App\Modules\Platform\Localization\TimezoneRegistry;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\Rules;
|
||||
use Carbon\Carbon;
|
||||
use Closure;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
@@ -59,6 +62,7 @@ class FilesController extends Controller
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
private readonly TimezoneRegistry $timezones,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -130,9 +134,12 @@ class FilesController extends Controller
|
||||
}
|
||||
|
||||
// Expiry is a filter, not a default: staff see expired files in the
|
||||
// UI too (that is how they notice and act on them). Only the client
|
||||
// branch of the visibility rules drops them, and it does so inside
|
||||
// ViewableFileScope where it belongs.
|
||||
// UI too (that is how they notice and act on them). Dropping them
|
||||
// is the client branch's rule, applied inside the visibility scopes
|
||||
// where it belongs — which is also why a client-scoped caller does
|
||||
// not get their clients' expired files back here whatever this
|
||||
// filter says: their library is built on that same branch. See
|
||||
// File::isExpired.
|
||||
if ($request->has('expired') && ($filters['expired'] ?? null) !== null) {
|
||||
$request->boolean('expired') ? $query->expired() : $query->notExpired();
|
||||
}
|
||||
@@ -263,6 +270,12 @@ class FilesController extends Controller
|
||||
* without the matching permission leaves that field untouched rather
|
||||
* than failing the whole request, which mirrors the web interface.
|
||||
*
|
||||
* `expires_at` accepts either a calendar day (`2026-09-12`) or a full
|
||||
* timestamp. A day means the end of that day in the caller's timezone,
|
||||
* which is what the same value means on the web and what the file's
|
||||
* own `expires_at` reads back as; a timestamp is taken as the instant
|
||||
* it names.
|
||||
*
|
||||
* `commentable` only has an effect while the installation's comment
|
||||
* setting is "only files marked as commentable"; under any other
|
||||
* setting it is ignored, again rather than failing.
|
||||
@@ -306,7 +319,7 @@ class FilesController extends Controller
|
||||
$attributes = array_intersect_key($validated, array_flip(['name', 'description', 'folder_id']));
|
||||
|
||||
if (array_key_exists('expires_at', $validated) && $user->can('set_file_expiration_date')) {
|
||||
$attributes['expires_at'] = $validated['expires_at'];
|
||||
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'], $user);
|
||||
}
|
||||
|
||||
if (array_key_exists('download_limit', $validated) && $user->can('limit_downloads')) {
|
||||
@@ -356,4 +369,29 @@ class FilesController extends Controller
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* What an `expires_at` value means.
|
||||
*
|
||||
* A bare `YYYY-MM-DD` is a calendar day, and a calendar day ends where
|
||||
* the person naming it lives — the same rule the web form's date input
|
||||
* gets from FilesController::expiryInstant. Stored as it arrives it
|
||||
* would be midnight UTC instead, so a file asked to expire on the 12th
|
||||
* would die at the *start* of the 12th, and for a caller west of
|
||||
* Greenwich partway through the 11th.
|
||||
*
|
||||
* Anything carrying a time is an instant the caller named on purpose
|
||||
* and is stored as it arrives, unchanged from before: the API can
|
||||
* express a moment, and a date input cannot.
|
||||
*/
|
||||
private function expiryInstant(?string $value, User $setter): ?Carbon
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1
|
||||
? LocalDay::end($value, $this->timezones->resolve($setter))
|
||||
: Carbon::parse($value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,14 @@ class ChunkedUploadsController extends Controller
|
||||
|
||||
if ($quotaBytes > 0 && $this->storageUsage->usedBytes($user) + (int) $validated['size'] > $quotaBytes) {
|
||||
throw ValidationException::withMessages([
|
||||
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
|
||||
'size' => __('This upload would exceed your storage quota of :quota MB.', [
|
||||
// The resolved quota, not the column: a client who
|
||||
// was never given one of their own carries 0 there
|
||||
// and inherits the site default, so printing the
|
||||
// column reads "your storage quota of 0 MB" at the
|
||||
// moment somebody is asking what their limit is.
|
||||
'quota' => (string) $this->storageUsage->quotaMb($user),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -306,7 +313,9 @@ class ChunkedUploadsController extends Controller
|
||||
$session->delete();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'size' => __('This upload would exceed your storage quota of :quota MB.', ['quota' => (string) $user->storage_quota_mb]),
|
||||
'size' => __('This upload would exceed your storage quota of :quota MB.', [
|
||||
'quota' => (string) $this->storageUsage->quotaMb($user),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Files\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -24,12 +25,21 @@ class DownloadSettingsController extends Controller
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
|
||||
public function edit(): Response
|
||||
{
|
||||
return Inertia::render('system/settings/downloads', [
|
||||
'max_zip_download_size_mb' => $this->settings->get(Setting::MaxZipDownloadSizeMb),
|
||||
// Not a setting, and shown here because this is where somebody
|
||||
// coming from v1 looks for one: v1 had a "Download method"
|
||||
// dropdown on its uploads options screen. It is an environment
|
||||
// variable now rather than a stored setting, because it
|
||||
// describes the server the installation is running on rather
|
||||
// than a preference — a value in the database can be restored
|
||||
// onto a different server and be wrong there.
|
||||
'file_delivery' => $this->delivery->describe(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,15 +12,16 @@ use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Authorized downloads without the bytes ever traversing PHP: the app
|
||||
* checks the policy, and StoredFileResponse answers with either an
|
||||
* X-Accel-Redirect for nginx to stream from the protected location
|
||||
* (brief §3) or a presigned URL when the file lives on external storage,
|
||||
* since nginx has no way to serve bytes it doesn't have on disk.
|
||||
* Authorized downloads: the app checks the policy, and StoredFileResponse
|
||||
* decides how the bytes travel — a presigned URL when the file lives on
|
||||
* external storage, and otherwise whichever local delivery method this
|
||||
* installation's web server understands (see FileDelivery). On nginx that
|
||||
* is an X-Accel-Redirect and the bytes never traverse PHP at all; on a
|
||||
* server with no such header PHP streams them, which is slower and works.
|
||||
*/
|
||||
class FileDownloadController extends Controller
|
||||
{
|
||||
|
||||
@@ -6,11 +6,12 @@ namespace App\Modules\Files\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Preview\PreviewKind;
|
||||
use App\Modules\Files\Preview\PreviewLog;
|
||||
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||
@@ -21,15 +22,14 @@ use App\Modules\Platform\Settings\Settings;
|
||||
use App\Support\ContentDisposition;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Two inline (never `attachment`) views of a file, same X-Accel-Redirect
|
||||
* pattern as FileDownloadController: a bounded thumbnail for listing rows,
|
||||
* Two inline (never `attachment`) views of a file, delivered the same way
|
||||
* FileDownloadController delivers one: a bounded thumbnail for listing rows,
|
||||
* and a larger view opened in a new tab when a thumbnail is clicked.
|
||||
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
|
||||
* for every row on every listing render, not a deliberate action, and
|
||||
@@ -72,11 +72,12 @@ class FileThumbnailController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ThumbnailGenerator $thumbnails,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly PreviewLog $previews,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly LocalSourceFile $source,
|
||||
private readonly Settings $settings,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
|
||||
public function thumbnail(Request $request, File $file): Response
|
||||
@@ -144,7 +145,10 @@ class FileThumbnailController extends Controller
|
||||
// file.
|
||||
abort_unless($this->allowance->allows($file, $request->user()), 403);
|
||||
|
||||
$this->logPreview($file, $request);
|
||||
// Debounced, because a browser turns one video into dozens of
|
||||
// Range requests — see PreviewLog, which the anonymous twin in
|
||||
// PublicGroupsController::preview shares.
|
||||
$this->previews->record(Action::FilePreviewed, $file, $request->user());
|
||||
|
||||
if ($kind === PreviewKind::Image) {
|
||||
$audience = ImageAudience::forViewer($request->user());
|
||||
@@ -164,29 +168,6 @@ class FileThumbnailController extends Controller
|
||||
return $this->bytes->inline($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* One log row per viewer per file per five minutes.
|
||||
*
|
||||
* Watching a video is a single deliberate act that the browser turns
|
||||
* into dozens of Range requests against this route, and each one
|
||||
* arrives here indistinguishable from someone clicking preview again.
|
||||
* Cache::add is the whole mechanism: it writes only if the key is
|
||||
* absent, so the first request through the window logs and the rest
|
||||
* are silent, without a read-then-write race between two of them.
|
||||
*
|
||||
* Keyed by viewer, so one client's playback never suppresses another
|
||||
* person's preview of the same file. Anonymous viewers do not reach
|
||||
* this route at all — see PublicGroupsController::preview.
|
||||
*/
|
||||
private function logPreview(File $file, Request $request): void
|
||||
{
|
||||
$key = 'file-preview-logged:'.$file->id.':'.($request->user()->id ?? 'guest');
|
||||
|
||||
if (Cache::add($key, true, now()->addMinutes(5))) {
|
||||
$this->activity->log(Action::FilePreviewed, subject: $file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cached rendition's path on the local disk, generating it first
|
||||
* if this is the first time anyone has asked for it. Null only when
|
||||
@@ -202,8 +183,19 @@ class FileThumbnailController extends Controller
|
||||
|
||||
$disk = Storage::disk('files');
|
||||
|
||||
// Existence is the cache, and an empty file is not a rendition: it
|
||||
// is what a render that died before writing anything leaves behind,
|
||||
// and serving it hands the viewer a broken image for as long as the
|
||||
// file lives — nothing invalidates a rendition once it is there.
|
||||
// ThumbnailGenerator writes through a temporary file now, so this
|
||||
// state can no longer be created here; it can still be inherited
|
||||
// from an installation that ran an older version.
|
||||
if ($disk->exists($path)) {
|
||||
return $path;
|
||||
if ($disk->size($path) > 0) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$disk->delete($path);
|
||||
}
|
||||
|
||||
$disk->makeDirectory(dirname($path));
|
||||
@@ -221,10 +213,12 @@ class FileThumbnailController extends Controller
|
||||
|
||||
private function serve(File $file, string $path): Response
|
||||
{
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$path,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
||||
]);
|
||||
// No Content-Length: this is the rendition's size, not the
|
||||
// original file's, and $file->size is the wrong number for it.
|
||||
return $this->delivery->serve(
|
||||
$path,
|
||||
$file->mime_type,
|
||||
ContentDisposition::inline($file->original_name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ class FilesController extends Controller
|
||||
// calendar date the editor typed — read back in their
|
||||
// zone, not the server's, or a file set to expire on the
|
||||
// 12th reopens showing the 11th.
|
||||
'expires_at' => $file->expires_at?->copy()->setTimezone($this->timezones->resolve($request->user()))->toDateString(),
|
||||
'expires_at' => $this->expiryDateFor($file, $request->user()),
|
||||
'expired' => $file->isExpired(),
|
||||
'download_limit' => $file->download_limit,
|
||||
'download_limit_scope' => ($file->download_limit_scope ?? DownloadLimitScope::Total)->value,
|
||||
@@ -303,7 +303,19 @@ class FilesController extends Controller
|
||||
// own expiry — same "leave it alone if you lack the permission"
|
||||
// rule as the upload_public gate below.
|
||||
if ($request->user()?->can('set_file_expiration_date') === true) {
|
||||
$attributes['expires_at'] = $this->expiryInstant($validated['expires_at'] ?? null, $request->user());
|
||||
$posted = $validated['expires_at'] ?? null;
|
||||
|
||||
// Re-derived only when the date actually changed. The form was
|
||||
// rendered with the stored instant read back as a date in *this*
|
||||
// viewer's zone, and posts it again untouched with every other
|
||||
// edit — so deriving it unconditionally moves the expiry by the
|
||||
// difference between two people's zones each time somebody
|
||||
// merely renames the file. Compared against the same string the
|
||||
// form was given, above, so "unchanged" means what the editor
|
||||
// saw.
|
||||
if ($posted !== $this->expiryDateFor($file, $request->user())) {
|
||||
$attributes['expires_at'] = $this->expiryInstant($posted, $request->user());
|
||||
}
|
||||
}
|
||||
|
||||
// Same rule again for the download cap, behind its own
|
||||
@@ -504,9 +516,23 @@ class FilesController extends Controller
|
||||
});
|
||||
|
||||
$requested = count($validated['file_ids']);
|
||||
$message = $updated < $requested
|
||||
? __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested])
|
||||
: trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]);
|
||||
|
||||
// Two different reasons a selected file can go unchanged, and they
|
||||
// are not the same sentence. Files dropped by the Gate::allows
|
||||
// filter above are ones this user may not edit at all. A file that
|
||||
// survived the filter and still changed nothing was editable --
|
||||
// every field they asked to change was one their role does not let
|
||||
// them set, which is the case the single-file editor states
|
||||
// separately too. Reporting the first reason for the second told a
|
||||
// staff member with edit_files but without set_file_expiration_date
|
||||
// that three files they own are not theirs to edit.
|
||||
$unreachable = $requested - $files->count();
|
||||
|
||||
$message = match (true) {
|
||||
$updated === $requested => trans_choice(':count file updated.|:count files updated.', $updated, ['count' => $updated]),
|
||||
$updated + $unreachable === $requested => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to edit them.', ['updated' => $updated, 'requested' => $requested]),
|
||||
default => __(':updated of :requested selected files were updated. The rest were skipped because you don\'t have permission to make those changes.', ['updated' => $updated, 'requested' => $requested]),
|
||||
};
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
@@ -540,4 +566,16 @@ class FilesController extends Controller
|
||||
? null
|
||||
: LocalDay::end($date, $this->timezones->resolve($setter));
|
||||
}
|
||||
|
||||
/**
|
||||
* The inverse: the calendar date a stored expiry falls on for this
|
||||
* viewer, which is what the date input is given and what it posts back.
|
||||
*
|
||||
* The pair has to agree, or a re-save reads one date and writes
|
||||
* another.
|
||||
*/
|
||||
private function expiryDateFor(File $file, ?User $viewer): ?string
|
||||
{
|
||||
return $file->expires_at?->copy()->setTimezone($this->timezones->resolve($viewer))->toDateString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\ShareLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The public, unauthenticated side of a share link: no Gate/policy is
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Access\ViewableFileScope;
|
||||
use App\Modules\Files\Jobs\BuildZipDownloadJob;
|
||||
@@ -21,10 +22,10 @@ use App\Support\ContentDisposition;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Number;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* A folder's "Download as zip" button and the file listing's multi-select
|
||||
@@ -45,6 +46,7 @@ class ZipDownloadsController extends Controller
|
||||
private readonly ViewableFileScope $viewable,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly Settings $settings,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
@@ -186,12 +188,12 @@ class ZipDownloadsController extends Controller
|
||||
|
||||
$size = Storage::disk('files')->size($path);
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$path,
|
||||
'Content-Type' => 'application/zip',
|
||||
'Content-Disposition' => ContentDisposition::attachment($this->filenameFor($zipDownload)),
|
||||
'Content-Length' => (string) $size,
|
||||
]);
|
||||
return $this->delivery->serve(
|
||||
$path,
|
||||
'application/zip',
|
||||
ContentDisposition::attachment($this->filenameFor($zipDownload)),
|
||||
$size,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -245,9 +245,21 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
@unlink($tempFile);
|
||||
}
|
||||
|
||||
// Same division as the write failure above: the reason is the
|
||||
// operator's, the sentence is the requester's. An exception
|
||||
// message here has already named a disk in practice — "Disk
|
||||
// [x] does not have a configured driver." — and can name a
|
||||
// server path, and this column is shown to whoever asked for
|
||||
// the archive, including clients.
|
||||
Log::error('A zip download could not be built.', [
|
||||
'zip_download_id' => $zipDownload->id,
|
||||
'exception' => $e::class,
|
||||
'reason' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$zipDownload->update([
|
||||
'status' => ZipDownload::STATUS_FAILED,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => 'The zip archive could not be built.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -312,22 +324,51 @@ class BuildZipDownloadJob implements ShouldQueue
|
||||
throw new \RuntimeException('Could not create a temp file for '.$file->original_name);
|
||||
}
|
||||
|
||||
// Registered before anything else can fail. tempnam() has already
|
||||
// created the file, and the caller's cleanup only knows the paths
|
||||
// it was told about — so every throw between here and the end of
|
||||
// the copy used to leave a zip-src- file behind for good.
|
||||
$tempFiles[] = $tempPath;
|
||||
|
||||
$stream = Storage::disk($file->disk)->readStream($file->path);
|
||||
$out = fopen($tempPath, 'wb');
|
||||
|
||||
if ($stream === null || $out === false) {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
if ($out !== false) {
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Could not read '.$file->original_name.' from its storage disk.');
|
||||
}
|
||||
|
||||
stream_copy_to_stream($stream, $out);
|
||||
fclose($out);
|
||||
try {
|
||||
// A copy that stops early is a truncated member added to the
|
||||
// archive as though it were the file: the build reports ready,
|
||||
// and the recipient gets something that opens and is wrong.
|
||||
// fclose is checked for the same reason it is in
|
||||
// LocalPartStore: it flushes, so a volume that filled on the
|
||||
// last buffer fails there rather than here.
|
||||
$copied = stream_copy_to_stream($stream, $out);
|
||||
$flushed = fclose($out);
|
||||
$out = false;
|
||||
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
if ($copied === false || ! $flushed) {
|
||||
throw new \RuntimeException('Could not copy '.$file->original_name.' from its storage disk.');
|
||||
}
|
||||
} finally {
|
||||
if ($out !== false) {
|
||||
fclose($out);
|
||||
}
|
||||
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
$tempFiles[] = $tempPath;
|
||||
|
||||
return $tempPath;
|
||||
}
|
||||
|
||||
|
||||
@@ -110,8 +110,12 @@ class File extends Model
|
||||
// an account's content — deletes many rows in one transaction,
|
||||
// and anything that rolls it back afterwards puts every row
|
||||
// back while the bytes are already gone: a loss nothing can
|
||||
// undo. Deferred, the worst case is bytes left on disk with no
|
||||
// row, which OrphanFileScanner already finds and reports.
|
||||
// undo. Deferred, the worst case is bytes left on disk with a
|
||||
// row that is only trashed, and a scan will not offer those:
|
||||
// OrphanFileScanner::knownPaths() counts a trashed row's path
|
||||
// as claimed, on purpose, so nothing double-adopts a file still
|
||||
// inside its erasure grace period. FileDiskCleanup's warning is
|
||||
// therefore the only record that it happened.
|
||||
//
|
||||
// Outside a transaction the callback runs immediately, so
|
||||
// deleting one file is unchanged. Nested transactions only fire
|
||||
@@ -247,8 +251,20 @@ class File extends Model
|
||||
/**
|
||||
* A file's own expiration date — independent of any share link's.
|
||||
* Null means never expires. Once past, the file is hidden from
|
||||
* clients and the public site (see scopeNotExpired) but staff keep
|
||||
* full access to view, download, and manage it.
|
||||
* clients and the public site (see scopeNotExpired) and staff keep
|
||||
* full access to view, download, and manage it — with one boundary
|
||||
* this used to leave out.
|
||||
*
|
||||
* A client-scoped staff member's library is their own uploads ∪ what
|
||||
* each assigned client may see (StaffLibraryScope::buildFiles), and
|
||||
* that second half is scopeVisibleToClient, which ends in
|
||||
* notExpired(). So an expired file they held only through a client
|
||||
* leaves their library too, while their own expired upload stays.
|
||||
* That is deliberate: c8078f65 weighed widening it and left the
|
||||
* boundary where it is, because scopeVisibleToClient is the single
|
||||
* source of truth for client file access, and relabelled the
|
||||
* expired-files widget instead. ExpiredFileStaffAccessTest pins both
|
||||
* halves so the sentence above cannot drift from the code again.
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Files\Preview;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Models\File;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* One log row per viewer per file per five minutes, for both preview
|
||||
* routes — FileThumbnailController::preview (signed in) and
|
||||
* PublicGroupsController::preview (anonymous).
|
||||
*
|
||||
* Watching a video is a single deliberate act that the browser turns into
|
||||
* dozens of Range requests, each arriving indistinguishable from someone
|
||||
* clicking preview again. Cache::add is the whole mechanism: it writes
|
||||
* only if the key is absent, so the first request through the window logs
|
||||
* and the rest are silent, without a read-then-write race between two of
|
||||
* them.
|
||||
*
|
||||
* Keyed by viewer, so one person's playback never suppresses another's
|
||||
* view of the same file. An anonymous visitor has no account to key on,
|
||||
* so the request IP stands in — the same substitute the API's rate
|
||||
* limiter makes for an unauthenticated caller. It is a cache key with a
|
||||
* five-minute life and never reaches the log, which keeps its own
|
||||
* decision about recording an IP (see ActivityLogger::shouldRecordIp and
|
||||
* Setting::DownloadIpLogging).
|
||||
*
|
||||
* Shared rather than restated, because the window is the rule: two copies
|
||||
* of "five minutes" are two things to change and one to forget.
|
||||
*/
|
||||
class PreviewLog
|
||||
{
|
||||
private const WINDOW_MINUTES = 5;
|
||||
|
||||
public function __construct(
|
||||
private readonly ActivityLogger $activity,
|
||||
) {}
|
||||
|
||||
public function record(Action $action, File $file, ?User $viewer): void
|
||||
{
|
||||
$viewerKey = $viewer !== null ? (string) $viewer->id : 'ip:'.request()->ip();
|
||||
|
||||
if (Cache::add('file-preview-logged:'.$file->id.':'.$viewerKey, true, now()->addMinutes(self::WINDOW_MINUTES))) {
|
||||
$this->activity->log($action, subject: $file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,10 @@ use App\Modules\Files\Thumbnails\ImageRendition;
|
||||
*
|
||||
* A thumbnail never asks: it is a rendering by definition, nothing else
|
||||
* would fit in a listing row. A preview is the case with two valid
|
||||
* answers. Serving the stored file is far cheaper — an X-Accel-Redirect
|
||||
* with no PHP in the path at all, or a redirect straight to external
|
||||
* storage — and it is what this app has always done. Decoding and
|
||||
* answers. Serving the stored file is far cheaper — handed to the web
|
||||
* server with no PHP in the path at all where that is possible, or a
|
||||
* redirect straight to external storage — and it is what this app has
|
||||
* always done. Decoding and
|
||||
* re-encoding a full-size photograph instead is only worth it when
|
||||
* something actually intends to change what the viewer sees.
|
||||
*
|
||||
|
||||
@@ -134,6 +134,31 @@ class ThumbnailGenerator
|
||||
// listening the image is written exactly as produced above.
|
||||
Event::dispatch(new RenderingImage($image, $mimeType, $audience, $rendition));
|
||||
|
||||
$image->toFile($destinationPath, $mimeType);
|
||||
// Written beside the destination and renamed into place, so the
|
||||
// cached path never exists half-finished. Both callers test only
|
||||
// that the path exists and then serve whatever is there
|
||||
// (FileThumbnailController::render, PublicGroupsController::
|
||||
// thumbnail), and nothing ever invalidates a rendition —
|
||||
// RenderedImageCache::flush() runs on an event no core code raises.
|
||||
// A render that died partway would therefore be served as the
|
||||
// rendition from then on.
|
||||
//
|
||||
// It also settles the race: two requests rendering the same file at
|
||||
// once used to encode into one path together. rename() within a
|
||||
// directory is atomic and replaces what is there, so now the loser
|
||||
// leaves a complete rendition behind rather than a mixture of two.
|
||||
$temporaryPath = $destinationPath.'.'.bin2hex(random_bytes(8)).'.partial';
|
||||
|
||||
try {
|
||||
$image->toFile($temporaryPath, $mimeType);
|
||||
|
||||
if (! rename($temporaryPath, $destinationPath)) {
|
||||
throw new RuntimeException('Could not move the rendered image into place.');
|
||||
}
|
||||
} finally {
|
||||
if (is_file($temporaryPath)) {
|
||||
@unlink($temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +96,9 @@ class FileVersions
|
||||
DB::transaction(function () use ($file, $previous, $root, $actor): void {
|
||||
// Move, never drop: a revision holds no recipients of its
|
||||
// own, but the people who already had this file must not
|
||||
// lose it. Through FileSharing so each target still gets
|
||||
// its activity entry, notification and digest.
|
||||
// lose it. Through FileSharing, so a target the root does
|
||||
// not hold yet still gets its activity entry, notification
|
||||
// and digest — and only such a target, see below.
|
||||
$this->moveAssignmentsToRoot($file, $root);
|
||||
|
||||
$file->update([
|
||||
@@ -544,8 +545,27 @@ class FileVersions
|
||||
continue;
|
||||
}
|
||||
|
||||
// firstOrCreate inside, so a target the root already has is a
|
||||
// no-op rather than a duplicate notification.
|
||||
// A target the root already holds gains nothing here, so it
|
||||
// is skipped rather than handed to FileSharing::assign().
|
||||
// That method's firstOrCreate makes the assignment row
|
||||
// idempotent but not the three side effects under it, so such
|
||||
// a target was told a file had been shared with it about a
|
||||
// file it already had — on top of the file_new_version it
|
||||
// gets from sharedAudience(), which is exactly the two
|
||||
// notifications for one action link() resolves that audience
|
||||
// early to avoid. copyAssignmentsFrom() below states the rule
|
||||
// outright for its own case: nobody is gaining access, so the
|
||||
// notification would be a lie.
|
||||
$alreadyOnRoot = FileAssignment::query()
|
||||
->where('file_id', $root->id)
|
||||
->where('assignable_type', $target->getMorphClass())
|
||||
->where('assignable_id', $target->getKey())
|
||||
->exists();
|
||||
|
||||
if ($alreadyOnRoot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->sharing->assign($root, $target, $target->name);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Groups\Http\Resources\Api\GroupResource;
|
||||
use App\Modules\Groups\Models\Group;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -56,7 +57,7 @@ class GroupMembersController extends Controller
|
||||
|
||||
$this->activity->log(Action::GroupMemberAdded, subject: $group, context: ['member' => $client->name]);
|
||||
|
||||
return new GroupResource($group->loadCount('members')->load('members'));
|
||||
return $this->response($group, $actor);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Group $group, User $member): GroupResource
|
||||
@@ -72,6 +73,28 @@ class GroupMembersController extends Controller
|
||||
|
||||
$this->activity->log(Action::GroupMemberRemoved, subject: $group, context: ['member' => $member->name]);
|
||||
|
||||
return new GroupResource($group->loadCount('members')->load('members'));
|
||||
return $this->response($group, $actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* The group as this actor may see it.
|
||||
*
|
||||
* GroupResource carries a name and an email per member, and its own
|
||||
* docblock puts the boundary here: "the controller loading this
|
||||
* relation is where that narrowing is applied". Api\GroupsController
|
||||
* ::show() applies it for the read of the same group; changing the
|
||||
* membership is not a reason to be told more than reading it, so both
|
||||
* halves narrow by the same query.
|
||||
*
|
||||
* The count is deliberately not narrowed. members_count is the size of
|
||||
* the group, which is a fact about the group rather than about who is
|
||||
* in it, and the web screen shows the same total.
|
||||
*/
|
||||
private function response(Group $group, User $actor): GroupResource
|
||||
{
|
||||
return new GroupResource($group->loadCount('members')->load([
|
||||
'members' => fn (BelongsToMany $members) => $members
|
||||
->whereIn('users.id', $this->scope->clients($actor)->select('id')),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Comments\CommentingRules;
|
||||
use App\Modules\Files\Access\DownloadAllowance;
|
||||
use App\Modules\Files\Delivery\FileDelivery;
|
||||
use App\Modules\Files\Delivery\StoredFileResponse;
|
||||
use App\Modules\Files\Models\Category;
|
||||
use App\Modules\Files\Models\File;
|
||||
use App\Modules\Files\Models\Folder;
|
||||
use App\Modules\Files\Preview\PreviewKind;
|
||||
use App\Modules\Files\Preview\PreviewLog;
|
||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||
use App\Modules\Files\Thumbnails\ImageRendition;
|
||||
use App\Modules\Files\Thumbnails\LocalSourceFile;
|
||||
@@ -32,12 +34,12 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response as InertiaResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The guest-facing side of a public group: no Gate/policy involved (same
|
||||
@@ -77,6 +79,7 @@ class PublicGroupsController extends Controller
|
||||
public function __construct(
|
||||
private readonly Settings $settings,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly PreviewLog $previews,
|
||||
private readonly DownloadAllowance $allowance,
|
||||
private readonly ThumbnailGenerator $thumbnails,
|
||||
private readonly PublicThemeRegistry $themes,
|
||||
@@ -84,6 +87,7 @@ class PublicGroupsController extends Controller
|
||||
private readonly CommentingRules $commenting,
|
||||
private readonly StoredFileResponse $bytes,
|
||||
private readonly LocalSourceFile $source,
|
||||
private readonly FileDelivery $delivery,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, string $publicSlug): InertiaResponse|RedirectResponse
|
||||
@@ -251,6 +255,13 @@ class PublicGroupsController extends Controller
|
||||
|
||||
$disk = Storage::disk('files');
|
||||
|
||||
// An empty file is not a rendition — same rule as the signed-in
|
||||
// twin in FileThumbnailController::render(), and the same reason:
|
||||
// nothing invalidates one once it is cached.
|
||||
if ($disk->exists($thumbnailPath) && $disk->size($thumbnailPath) === 0) {
|
||||
$disk->delete($thumbnailPath);
|
||||
}
|
||||
|
||||
if (! $disk->exists($thumbnailPath)) {
|
||||
$disk->makeDirectory(dirname($thumbnailPath));
|
||||
|
||||
@@ -267,11 +278,11 @@ class PublicGroupsController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => '/protected-files/'.$thumbnailPath,
|
||||
'Content-Type' => $file->mime_type,
|
||||
'Content-Disposition' => ContentDisposition::inline($file->original_name),
|
||||
]);
|
||||
return $this->delivery->serve(
|
||||
$thumbnailPath,
|
||||
$file->mime_type,
|
||||
ContentDisposition::inline($file->original_name),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,7 +309,11 @@ class PublicGroupsController extends Controller
|
||||
// nothing.
|
||||
abort_unless($this->allowance->allows($file, null), 403);
|
||||
|
||||
$this->activity->log(Action::PublicFilePreviewed, subject: $file);
|
||||
// Debounced exactly as the signed-in twin is, and for the same
|
||||
// reason: a single visitor watching one video arrives here dozens
|
||||
// of times. Without a viewer to key on, PreviewLog keys on the
|
||||
// request IP.
|
||||
$this->previews->record(Action::PublicFilePreviewed, $file, null);
|
||||
|
||||
return $this->bytes->inline($file);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Identity;
|
||||
use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Audit\ActivityLogger;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -32,20 +33,39 @@ class AccountContentDeletion
|
||||
public function __construct(
|
||||
private readonly DeletedAccountContent $content,
|
||||
private readonly ActivityLogger $activity,
|
||||
private readonly StaffLibraryScope $scope,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Every other active account, for the reassignment-target picker.
|
||||
* $excludeId is omitted on index pages, where one candidate list is
|
||||
* shared across every row and each row's own id is filtered out
|
||||
* client-side instead.
|
||||
* Every other active account this viewer may be shown, for the
|
||||
* reassignment-target picker. $excludeId is omitted on index pages,
|
||||
* where one candidate list is shared across every row and each row's
|
||||
* own id is filtered out client-side instead.
|
||||
*
|
||||
* The client half is narrowed by StaffLibraryScope, the same rule that
|
||||
* narrows the list this picker sits next to: a client-scoped staff
|
||||
* member is not shown the name of somebody they can reach nothing of,
|
||||
* and a picker is no more a reason to hand one over than a listing is.
|
||||
* Staff accounts are not narrowed anywhere in the application and are
|
||||
* not narrowed here.
|
||||
*
|
||||
* An unscoped viewer's list is unchanged — StaffLibraryScope::clients()
|
||||
* returns every client for them.
|
||||
*
|
||||
* $viewer is null only where the picker is about the installation
|
||||
* rather than about a screen: the erasure default in privacy settings
|
||||
* is stored once for everybody, behind edit_settings, so narrowing it
|
||||
* by whoever happens to be editing would store the wrong answer.
|
||||
*
|
||||
* @return array<int, array{id: int, name: string, role: string}>
|
||||
*/
|
||||
public function candidates(?int $excludeId = null): array
|
||||
public function candidates(?User $viewer, ?int $excludeId = null): array
|
||||
{
|
||||
return User::query()
|
||||
->when($excludeId, fn (Builder $query, int $id) => $query->whereKeyNot($id))
|
||||
->when($viewer, fn (Builder $query, User $for) => $query->where(fn (Builder $reachable) => $reachable
|
||||
->where('type', UserType::Staff)
|
||||
->orWhereIn('id', $this->scope->clients($for)->select('users.id'))))
|
||||
->where('active', true)
|
||||
->with('role')
|
||||
->orderBy('name')
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Identity\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Api\Auth\ApiTokens;
|
||||
use App\Modules\Files\Access\StaffLibraryScope;
|
||||
use App\Modules\Files\DeletedAccountContent;
|
||||
use App\Modules\Identity\AccountConversion;
|
||||
use App\Modules\Identity\Models\Role;
|
||||
@@ -46,6 +47,7 @@ class AccountConversionController extends Controller
|
||||
private readonly StaffAccounts $accounts,
|
||||
private readonly DeletedAccountContent $accountContent,
|
||||
private readonly ApiTokens $apiTokens,
|
||||
private readonly StaffLibraryScope $library,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
@@ -59,8 +61,18 @@ class AccountConversionController extends Controller
|
||||
$search = $validated['search'] ?? null;
|
||||
$actor = $this->actor($request);
|
||||
|
||||
$accounts = User::query()
|
||||
->where('type', $direction === 'to_client' ? UserType::Staff : UserType::Client)
|
||||
// Narrowed for the direction that lists clients, by the same rule
|
||||
// store() refuses one with: AccountConversion::guardToStaff() aborts
|
||||
// 404 unless StaffLibraryScope::canAssignClient() allows the target,
|
||||
// and clients() is that same method's listing half. Without this a
|
||||
// client-scoped staff member was refused the promotion and then
|
||||
// shown the person's name and address in the list it was refused
|
||||
// from. Staff are not narrowed: whoever may demote a staff member
|
||||
// may see the staff roster, which is what assignableRoles() and
|
||||
// guardTarget() already decide on the write side.
|
||||
$accounts = ($direction === 'to_client'
|
||||
? User::query()->where('type', UserType::Staff)
|
||||
: $this->library->clients($actor))
|
||||
->with('role')
|
||||
->when($search, fn (Builder $query, string $term) => $query->where(fn (Builder $inner) => $inner
|
||||
->where('name', 'like', "%{$term}%")
|
||||
|
||||
@@ -108,7 +108,11 @@ class UsersController extends Controller
|
||||
'filters' => $filters,
|
||||
'roles' => Role::query()->orderBy('name')->get(['id', 'name'])
|
||||
->map(fn (Role $role): array => ['id' => $role->id, 'name' => $role->name])->all(),
|
||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
||||
// Same rule as the clients list: the picker belongs to the
|
||||
// delete dialog, so it is sent to whoever may open one.
|
||||
'reassign_candidates' => $this->actor()->can('delete_users')
|
||||
? $this->accountDeletion->candidates($this->actor())
|
||||
: [],
|
||||
// Null on a self-hosted install: no limit, nothing to say.
|
||||
'seats' => $this->seats->staffState(),
|
||||
]);
|
||||
@@ -184,7 +188,9 @@ class UsersController extends Controller
|
||||
&& $this->accounts->isAdministratorRole($user->role_id)
|
||||
&& $this->accounts->activeAdministratorCount() === 1,
|
||||
'content' => $this->accountContent->summarize($user),
|
||||
'reassign_candidates' => $this->accountDeletion->candidates($user->id),
|
||||
'reassign_candidates' => $this->actor()->can('delete_users')
|
||||
? $this->accountDeletion->candidates($this->actor(), $user->id)
|
||||
: [],
|
||||
// Read-only, deliberately: an administrator may see that an
|
||||
// integration exists and what it is allowed to do, but only
|
||||
// the owner can rename, re-scope or revoke it. See ApiTokens.
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Clients\ClientProvisioning;
|
||||
use App\Modules\Identity\AuthSource;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@@ -55,6 +56,19 @@ class LdapProvisioner
|
||||
return null;
|
||||
}
|
||||
|
||||
// Same reason as SocialProvisioner: a deleted account keeps its
|
||||
// address until erasure removes the row, so provisioning over one
|
||||
// raises a QueryException at the moment of login. Refused here, the
|
||||
// sign-in fails the ordinary way instead, and a directory identity
|
||||
// does not silently reclaim an account somebody deleted.
|
||||
if (! $this->clients->addressIsFree($identity->email)) {
|
||||
Log::warning('A directory identity was not provisioned: the address belongs to a deleted account.', [
|
||||
'email' => $identity->email,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->clients->provision(
|
||||
name: $identity->name,
|
||||
email: $identity->email,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Identity;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Identity\Ldap\LdapAuthenticator;
|
||||
use Illuminate\Auth\SessionGuard;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Whether a password is this account's password.
|
||||
*
|
||||
* The sibling of SignIn, on the other side of the line it draws. SignIn is
|
||||
* everything that happens *after* a credential checks out; this is the one
|
||||
* question asked before it, for the one credential source that has two
|
||||
* possible homes -- the local hash, or the directory the account was
|
||||
* provisioned from.
|
||||
*
|
||||
* It exists for the reason SignIn gives for existing: "the way they get
|
||||
* broken is by being written twice". The sign-in form asked this question
|
||||
* properly, taking a directory bind when the local hash is a placeholder
|
||||
* nobody holds. The confirm-password screen asked only half of it, and so
|
||||
* refused every directory account the password it actually has.
|
||||
*
|
||||
* The order is the sign-in form's, and matters: the local hash is tried
|
||||
* first so an account that answers locally never generates directory
|
||||
* traffic, and an account whose credentials are *known* to live in the
|
||||
* directory skips the local check entirely, because there the local hash
|
||||
* is a Str::password(64) placeholder that cannot match anything.
|
||||
*/
|
||||
class PasswordVerification
|
||||
{
|
||||
public function __construct(private readonly LdapAuthenticator $ldap) {}
|
||||
|
||||
public function verify(User $user, string $password): bool
|
||||
{
|
||||
if (! $this->ldap->isDirectoryAccount($user)
|
||||
&& Auth::guard('web')->validate(['email' => $user->email, 'password' => $password])) {
|
||||
$this->rehashIfStale($user, $password);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$identity = $this->ldap->attempt($user->email, $password, $user);
|
||||
|
||||
if ($identity === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->ldap->stamp($user, $identity);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-hash a password stored under weaker settings than this
|
||||
* installation now uses.
|
||||
*
|
||||
* Laravel does this inside SessionGuard::attempt(), which neither
|
||||
* caller uses -- they verify and then hand the account to SignIn,
|
||||
* which calls Auth::login(). Neither re-hashes, so without this an
|
||||
* account keeps whatever cost it was created under forever, and
|
||||
* raising BCRYPT_ROUNDS would quietly apply to new accounts only.
|
||||
*
|
||||
* That is not hypothetical: every account the v1 migration carries
|
||||
* across arrives as `$2y$08$…`, because v1 hashed at cost 8, and would
|
||||
* otherwise stay four times cheaper to attack than an account created
|
||||
* here.
|
||||
*
|
||||
* **Only ever called on the local branch.** On the directory branch the
|
||||
* submitted plaintext is the *LDAP* password and the local hash is a
|
||||
* placeholder nobody holds; writing the directory credential into it
|
||||
* would mint a second way into the account that keeps working after
|
||||
* LDAP is switched off.
|
||||
*/
|
||||
private function rehashIfStale(User $user, string $password): void
|
||||
{
|
||||
$guard = Auth::guard('web');
|
||||
|
||||
// getProvider() is on SessionGuard rather than on the StatefulGuard
|
||||
// contract. This guard is a SessionGuard in every configuration this
|
||||
// application ships; the check is here so a custom driver degrades
|
||||
// to "no re-hash" instead of a fatal on the login path.
|
||||
if (! $guard instanceof SessionGuard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No-ops unless the hasher says the stored digest needs it, so this
|
||||
// costs an already-current account nothing.
|
||||
$guard->getProvider()->rehashPasswordIfRequired($user, ['password' => $password]);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\User;
|
||||
use App\Modules\Audit\Action;
|
||||
use App\Modules\Clients\ClientProvisioning;
|
||||
use App\Modules\Identity\AuthSource;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,21 @@ class SocialProvisioner
|
||||
return null;
|
||||
}
|
||||
|
||||
// A deleted account still holds its address, and the insert below
|
||||
// would hit the unique index — a 500 in the middle of a sign-in.
|
||||
// Refusing here gives the caller the same "there is no account here
|
||||
// for that address" it gives every other unprovisionable identity,
|
||||
// which is also all a stranger should learn: whether an address was
|
||||
// once an account here is not the provider's to publish.
|
||||
if (! $this->clients->addressIsFree($identity->email)) {
|
||||
Log::warning('A provider identity was not provisioned: the address belongs to a deleted account.', [
|
||||
'provider' => $settings->provider->value,
|
||||
'email' => $identity->email,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->clients->provision(
|
||||
name: $identity->name ?? $identity->email,
|
||||
email: $identity->email,
|
||||
|
||||
@@ -62,19 +62,20 @@ class TwoFactorService
|
||||
|
||||
$replayKey = "two-factor.used.{$user->id}.".hash('sha256', $code);
|
||||
|
||||
if (Cache::has($replayKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->engine->verifyKey($secret, $code) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A TOTP code is valid for one window either side; block reuse
|
||||
// for slightly longer than that.
|
||||
Cache::put($replayKey, true, now()->addSeconds(90));
|
||||
|
||||
return true;
|
||||
// Claiming the code *is* the answer. Cache::add writes only if the
|
||||
// key is absent, so of two requests carrying the same valid code
|
||||
// exactly one is told true — where has()-then-put() let both read
|
||||
// "unused" before either wrote, and a code intercepted once could
|
||||
// be spent twice inside its window. Same mechanism, and the same
|
||||
// reason, as the preview log's debounce.
|
||||
//
|
||||
// A TOTP code is valid for one window either side; the claim
|
||||
// outlives that by a little.
|
||||
return Cache::add($replayKey, true, now()->addSeconds(90));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,13 +46,22 @@ class NotificationPreferencesController extends Controller
|
||||
$user = $request->user();
|
||||
assert($user !== null);
|
||||
|
||||
$keys = $this->emailableKeys();
|
||||
|
||||
$validated = $request->validate([
|
||||
'preferences' => ['required', 'array'],
|
||||
// Bounded by the registry, and unique on the type. The
|
||||
// Rule::in below checks each value; it says nothing about how
|
||||
// many there are or whether they repeat, and the loop writes
|
||||
// one row per element. The count comes from the registry
|
||||
// rather than a literal because the registry is open --
|
||||
// modules register their own types into it, so a number here
|
||||
// would be wrong the moment one does.
|
||||
'preferences' => ['required', 'array', 'max:'.count($keys)],
|
||||
// Against the registry, not merely "a string": a preference row
|
||||
// for a type nothing can send is a row that will never be read
|
||||
// again, and the screen only ever offers back what edit() gave
|
||||
// it.
|
||||
'preferences.*.type' => ['required', 'string', Rule::in($this->emailableKeys())],
|
||||
'preferences.*.type' => ['required', 'string', 'distinct', Rule::in($keys)],
|
||||
'preferences.*.email_enabled' => ['required', 'boolean'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding;
|
||||
|
||||
use App\Modules\Api\Events\RegisteringApiModules;
|
||||
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
||||
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||
use App\Modules\Platform\Branding\Watermark\ThumbnailWatermarker;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Inertia\Inertia;
|
||||
|
||||
/**
|
||||
* An installation dressed in its own logo, and a watermark on what its
|
||||
* clients and visitors see.
|
||||
*
|
||||
* Lived in the private cloud-modules package until 2026-08-28, gated
|
||||
* Cloud-only. That was a fact about where the code had been written
|
||||
* rather than about who should have it: nothing here needs a hosted
|
||||
* platform, and a self-hosted installation wanting its own mark on the
|
||||
* pages it serves is the ordinary case rather than the exotic one.
|
||||
*
|
||||
* **What did not move.** Hiding the "Powered by ProjectSend" line is the
|
||||
* white-label half, and white-labelling is one of the things a hosted
|
||||
* customer pays for. Its listener still ships only in cloud-modules, so
|
||||
* an installation without that package has no code able to answer "hide
|
||||
* it" — flipping an edition variable buys nothing. This module carries
|
||||
* the column, because it owns the table, and no way to set it.
|
||||
*
|
||||
* **What a plan withholds is a separate question.** A free hosted plan
|
||||
* has branding subtracted from its environment, which the capability
|
||||
* registry applies; see PROJECTSEND_CAPABILITIES_DISABLED. The row is
|
||||
* never deleted by that, so a plan that lapses and resumes restores what
|
||||
* the customer had rather than asking them to build it again.
|
||||
*/
|
||||
class BrandingServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
// Registered unconditionally. The listeners ask whether branding
|
||||
// is available each time they fire, so an edition change, or a
|
||||
// plan change that subtracts the capability, takes effect on the
|
||||
// next request rather than needing a restart.
|
||||
// Through the module registry rather than routes/api.php, so the
|
||||
// URL stays /api/v1/modules/branding/* exactly as it was when this
|
||||
// shipped in a package. A caller's integration does not care which
|
||||
// repository the code moved to, and moving the path would be a
|
||||
// breaking change dressed up as a refactor.
|
||||
Event::listen(RegisteringApiModules::class, function (RegisteringApiModules $event): void {
|
||||
$event->register(
|
||||
slug: 'branding',
|
||||
routes: __DIR__.'/api-routes.php',
|
||||
capability: Capability::Branding->value,
|
||||
);
|
||||
});
|
||||
|
||||
Event::listen(RenderingImage::class, [ThumbnailWatermarker::class, 'handle']);
|
||||
Event::listen(ResolvingImageRendering::class, [ThumbnailWatermarker::class, 'resolve']);
|
||||
|
||||
// Gated like the screen that sets it. Without the capability there
|
||||
// is no branding page to reach, so a row that outlived a gate
|
||||
// change — a downgraded plan, a restored backup — would put
|
||||
// somebody's logo on every page of an installation offering no way
|
||||
// to see it, change it or take it off. Evaluated per request, so
|
||||
// uploading a logo or changing plan takes effect on the next one.
|
||||
Inertia::share('branding', fn (): array => [
|
||||
'logo_url' => $this->available()
|
||||
? BrandingSetting::query()->first()?->logoUrl()
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function available(): bool
|
||||
{
|
||||
return $this->app->make(CapabilityRegistry::class)->has(Capability::Branding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding\Http\Controllers\Api;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Routing\Controller;
|
||||
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||
|
||||
/**
|
||||
* This installation's branding — its logo and its thumbnail watermark —
|
||||
* over the host's API.
|
||||
*
|
||||
* Read-only on purpose: uploading an image is a multipart flow with
|
||||
* content-sniffing rules that only make sense with a file picker in front
|
||||
* of them (see the web controller), and nothing has asked to automate it.
|
||||
* An integration that wants to render this installation's branding — an
|
||||
* email builder, a status page — only needs to read it.
|
||||
*
|
||||
* This controller knows nothing about authentication, rate limiting,
|
||||
* error formats or which edition it is running in. The host supplies all
|
||||
* of that: the module is registered through RegisteringApiModules, which
|
||||
* mounts these routes inside the API's own auth stack and behind
|
||||
* `capability:branding.customize`. That is the whole point of the seam —
|
||||
* a package declares paths and controllers, and nothing else.
|
||||
*/
|
||||
class BrandingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get this installation's logo.
|
||||
*
|
||||
* Returns a null `logo_url` when no logo has been uploaded, which is
|
||||
* the normal state rather than an error.
|
||||
*/
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
$setting = BrandingSetting::query()->first();
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'logo_url' => $setting?->logoUrl(),
|
||||
'updated_at' => $setting?->updated_at?->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the watermark applied to this installation's rendered images.
|
||||
*
|
||||
* Applies to the thumbnails and previews clients and anonymous
|
||||
* public visitors see; what this installation's own staff see is
|
||||
* never marked. The stored files, and every download of them, are
|
||||
* never altered either way.
|
||||
*
|
||||
* `enabled` is false whenever no watermark is being drawn, including
|
||||
* when the toggle is on but its image has since been removed —
|
||||
* it answers "is this installation watermarking?", not "which way is
|
||||
* the switch pointing?". `position` is one of `top-left`,
|
||||
* `top-center`, `top-right`, `middle-left`, `center`, `middle-right`,
|
||||
* `bottom-left`, `bottom-center`, `bottom-right`; `size` is the
|
||||
* percentage of the image the mark is fitted into, and `opacity`
|
||||
* a percentage.
|
||||
*
|
||||
* Read-only, same as the logo: an integration rendering its own
|
||||
* derivative images can reproduce the mark, but uploading one is a
|
||||
* multipart flow with content-sniffing rules that only make sense
|
||||
* behind a file picker.
|
||||
*/
|
||||
public function watermark(): JsonResponse
|
||||
{
|
||||
// Falls back to an unsaved instance so an installation that has
|
||||
// never opened the branding screen answers with the defaults it
|
||||
// would start from, rather than a payload of nulls a caller would
|
||||
// have to invent its own meaning for.
|
||||
$setting = BrandingSetting::query()->first() ?? new BrandingSetting;
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'enabled' => $setting->watermarksThumbnails(),
|
||||
'image_url' => $setting->watermarkUrl(),
|
||||
'position' => $setting->watermark_position->value,
|
||||
'size' => $setting->watermark_size,
|
||||
'opacity' => $setting->watermark_opacity,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use App\Modules\Files\Thumbnails\Events\ImageRenderingChanged;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||
use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
|
||||
use App\Modules\Platform\Branding\Watermark\WatermarkSample;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
/**
|
||||
* Two site-wide pieces of artwork: the logo shown in the sidebar in
|
||||
* place of the default icon, and the mark stamped onto the thumbnails
|
||||
* and previews clients and public visitors see. Every route here is gated end-to-end by the host's
|
||||
* `capability:branding.customize` middleware (see routes.php) — this
|
||||
* module has no idea what edition it's running in, it just trusts the
|
||||
* gate.
|
||||
*/
|
||||
class BrandingController extends Controller
|
||||
{
|
||||
public function edit(): Response
|
||||
{
|
||||
// An unsaved instance rather than `current()`: rendering a settings
|
||||
// screen must not write a row, and the model carries the same
|
||||
// defaults the table does (see its $attributes) so the form starts
|
||||
// on the values a first save would produce.
|
||||
$setting = BrandingSetting::query()->first() ?? new BrandingSetting;
|
||||
|
||||
return Inertia::render('branding/edit', [
|
||||
'logo_url' => $setting->logoUrl(),
|
||||
// Read, never written here. Hiding attribution is the
|
||||
// white-label half and stays a hosted feature: the switch is
|
||||
// rendered only where Capability::AttributionHide is held, and
|
||||
// the route that saves it is registered by cloud-modules. Core
|
||||
// carries the column because it owns the table, and carries no
|
||||
// way to set it.
|
||||
'hide_attribution' => $setting->hide_attribution,
|
||||
'watermark' => [
|
||||
'enabled' => $setting->watermark_enabled,
|
||||
'image_url' => $setting->watermarkUrl(),
|
||||
'position' => $setting->watermark_position->value,
|
||||
'size' => $setting->watermark_size,
|
||||
'opacity' => $setting->watermark_opacity,
|
||||
],
|
||||
'watermark_positions' => WatermarkPosition::values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'logo' => ['required', 'image', 'max:2048'],
|
||||
]);
|
||||
|
||||
/** @var UploadedFile $upload */
|
||||
$upload = $validated['logo'];
|
||||
|
||||
$setting = BrandingSetting::current();
|
||||
|
||||
if ($setting->logo_path !== null) {
|
||||
Storage::disk('public')->delete($setting->logo_path);
|
||||
}
|
||||
|
||||
$setting->update(['logo_path' => $this->storeImage($upload)]);
|
||||
|
||||
return back()->with('success', __('Logo updated.'));
|
||||
}
|
||||
|
||||
public function destroy(): RedirectResponse
|
||||
{
|
||||
$setting = BrandingSetting::query()->first();
|
||||
|
||||
if ($setting?->logo_path !== null) {
|
||||
Storage::disk('public')->delete($setting->logo_path);
|
||||
$setting->update(['logo_path' => null]);
|
||||
}
|
||||
|
||||
return back()->with('success', __('Logo removed.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the whole watermark form at once — toggle, artwork, placement,
|
||||
* scale and opacity. One endpoint rather than one per field because
|
||||
* they are only meaningful together: turning it on without an image,
|
||||
* or changing the size without seeing the position, are not states
|
||||
* worth being able to save.
|
||||
*/
|
||||
public function updateWatermark(Request $request): RedirectResponse
|
||||
{
|
||||
$setting = BrandingSetting::current();
|
||||
|
||||
$validated = $request->validate([
|
||||
// The image is optional on every save *except* the one that
|
||||
// turns watermarking on with nothing stored yet — otherwise
|
||||
// adjusting the opacity would mean re-picking the file each
|
||||
// time. `exclude_if` keeps the rule off the payload entirely
|
||||
// rather than requiring a re-upload.
|
||||
'image' => [
|
||||
$setting->watermark_path === null && $request->boolean('enabled') ? 'required' : 'nullable',
|
||||
'image',
|
||||
'max:2048',
|
||||
],
|
||||
'enabled' => ['required', 'boolean'],
|
||||
'position' => ['required', Rule::in(WatermarkPosition::values())],
|
||||
'size' => ['required', 'integer', 'min:5', 'max:100'],
|
||||
'opacity' => ['required', 'integer', 'min:1', 'max:100'],
|
||||
], [
|
||||
'image.required' => __('Choose the image to use as the watermark.'),
|
||||
]);
|
||||
|
||||
$attributes = [
|
||||
'watermark_enabled' => (bool) $validated['enabled'],
|
||||
'watermark_position' => $validated['position'],
|
||||
'watermark_size' => (int) $validated['size'],
|
||||
'watermark_opacity' => (int) $validated['opacity'],
|
||||
];
|
||||
|
||||
if (($validated['image'] ?? null) instanceof UploadedFile) {
|
||||
if ($setting->watermark_path !== null) {
|
||||
Storage::disk('public')->delete($setting->watermark_path);
|
||||
}
|
||||
|
||||
$attributes['watermark_path'] = $this->storeImage($validated['image']);
|
||||
}
|
||||
|
||||
$setting->update($attributes);
|
||||
|
||||
$this->forgetRenderedImages();
|
||||
|
||||
return back()->with('success', __('Watermark settings saved.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in photograph with the mark drawn on it, so the settings
|
||||
* screen can show what a client will actually see. Staff surfaces are
|
||||
* never watermarked, so without this an administrator has no way to
|
||||
* judge their own settings short of signing in as a client.
|
||||
*
|
||||
* Takes placement, scale and opacity from the *query string* rather
|
||||
* than from the saved row: the point is to answer "what would this
|
||||
* look like" while the form is still being adjusted. The artwork
|
||||
* itself has to be the stored one — an unsaved file lives in the
|
||||
* browser, not on this server — which is why the screen tells you to
|
||||
* save after choosing a new image.
|
||||
*
|
||||
* Drawn by the same WatermarkPainter that renders the real thing, so
|
||||
* the sample cannot flatter the settings.
|
||||
*/
|
||||
public function watermarkSample(Request $request, WatermarkSample $sample): SymfonyResponse
|
||||
{
|
||||
$setting = BrandingSetting::query()->first();
|
||||
$markPath = $setting?->watermark_path;
|
||||
|
||||
// Not 404 for "you have not uploaded one yet" — the screen asks for
|
||||
// this image before there is anything to draw, and a broken <img>
|
||||
// is a worse answer than none. It hides the sample instead.
|
||||
abort_if($markPath === null || ! Storage::disk('public')->exists($markPath), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'position' => ['required', Rule::in(WatermarkPosition::values())],
|
||||
'size' => ['required', 'integer', 'min:5', 'max:100'],
|
||||
'opacity' => ['required', 'integer', 'min:1', 'max:100'],
|
||||
]);
|
||||
|
||||
$image = $sample->render(
|
||||
Storage::disk('public')->path($markPath),
|
||||
WatermarkPosition::from($validated['position']),
|
||||
(int) $validated['size'],
|
||||
(int) $validated['opacity'],
|
||||
);
|
||||
|
||||
return new SymfonyResponse($image->toString('image/png'), 200, [
|
||||
'Content-Type' => 'image/png',
|
||||
// Every request has different parameters and the artwork behind
|
||||
// it can be replaced at any moment; a cached sample would show
|
||||
// an administrator the settings they had a minute ago.
|
||||
'Cache-Control' => 'no-store, max-age=0',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the artwork and switch watermarking off with it — an "enabled"
|
||||
* that no image backs is not a state this screen can leave behind.
|
||||
*/
|
||||
public function destroyWatermark(): RedirectResponse
|
||||
{
|
||||
$setting = BrandingSetting::query()->first();
|
||||
|
||||
if ($setting === null) {
|
||||
return back();
|
||||
}
|
||||
|
||||
if ($setting->watermark_path !== null) {
|
||||
Storage::disk('public')->delete($setting->watermark_path);
|
||||
}
|
||||
|
||||
$setting->update([
|
||||
'watermark_path' => null,
|
||||
'watermark_enabled' => false,
|
||||
]);
|
||||
|
||||
$this->forgetRenderedImages();
|
||||
|
||||
return back()->with('success', __('Watermark removed.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The host caches every image it renders and never revisits it, so
|
||||
* without this a settings change would only reach files nobody has
|
||||
* looked at yet.
|
||||
*
|
||||
* Dispatched by *string* class name: the host's event class cannot be
|
||||
* constructed from here (this package builds with no host present),
|
||||
* and it carries no payload precisely so that it doesn't have to be.
|
||||
* With no host listening this is an inert no-op.
|
||||
*/
|
||||
private function forgetRenderedImages(): void
|
||||
{
|
||||
Event::dispatch(new ImageRenderingChanged);
|
||||
}
|
||||
|
||||
/**
|
||||
* The extension comes from the *content*, never from the uploaded
|
||||
* filename. This disk is web-served (public/storage is symlinked into
|
||||
* the document root and nginx serves it as a static file), and the
|
||||
* `image` rule only inspects the sniffed content — so a GIF whose
|
||||
* filename says ".html" passes validation and would then be stored,
|
||||
* and served back, as text/html: stored XSS on this app's own origin,
|
||||
* from any account that can reach this page. guessExtension() is
|
||||
* derived from the same sniffed mime type the validator just
|
||||
* accepted, so the two can no longer disagree.
|
||||
*/
|
||||
private function storeImage(UploadedFile $upload): string
|
||||
{
|
||||
$extension = $upload->guessExtension() ?? 'bin';
|
||||
|
||||
$path = $upload->storeAs('branding', Str::uuid().'.'.$extension, 'public');
|
||||
|
||||
if ($path === false) {
|
||||
throw new RuntimeException('Could not store the uploaded image.');
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use App\Modules\Platform\Branding\Watermark\WatermarkPosition;
|
||||
|
||||
/**
|
||||
* A single-row settings table — see the migration's comment for why no
|
||||
* tenant/owner column is needed.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string|null $logo_path
|
||||
* @property bool $watermark_enabled
|
||||
* @property string|null $watermark_path
|
||||
* @property WatermarkPosition $watermark_position
|
||||
* @property int $watermark_size
|
||||
* @property int $watermark_opacity
|
||||
* @property bool $hide_attribution
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
*/
|
||||
class BrandingSetting extends Model
|
||||
{
|
||||
protected $table = 'branding_settings';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'watermark_enabled' => 'boolean',
|
||||
'watermark_position' => WatermarkPosition::class,
|
||||
'watermark_size' => 'integer',
|
||||
'watermark_opacity' => 'integer',
|
||||
'hide_attribution' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Mirrors the migration's column defaults, so an unsaved instance
|
||||
* answers the same as a freshly created row would. That is what lets
|
||||
* the settings screen render `new BrandingSetting` on an install that
|
||||
* has never touched branding, instead of either creating a row on a
|
||||
* GET or restating these numbers a second time in the controller.
|
||||
*/
|
||||
protected $attributes = [
|
||||
'watermark_enabled' => false,
|
||||
'watermark_position' => 'bottom-right',
|
||||
'watermark_size' => 30,
|
||||
'watermark_opacity' => 60,
|
||||
'hide_attribution' => false,
|
||||
];
|
||||
|
||||
public static function current(): self
|
||||
{
|
||||
return static::query()->firstOrCreate([]);
|
||||
}
|
||||
|
||||
public function logoUrl(): ?string
|
||||
{
|
||||
return $this->logo_path === null ? null : Storage::disk('public')->url($this->logo_path);
|
||||
}
|
||||
|
||||
public function watermarkUrl(): ?string
|
||||
{
|
||||
return $this->watermark_path === null ? null : Storage::disk('public')->url($this->watermark_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* The artwork to stamp on a thumbnail being rendered right now, or
|
||||
* null when this installation is not watermarking.
|
||||
*
|
||||
* Phrased as "which image, if any" rather than as a boolean because
|
||||
* the toggle alone is not enough to act on: removing the image
|
||||
* leaves the toggle standing, and a row restored from a backup can
|
||||
* carry an `enabled` that its file no longer backs. Answering both
|
||||
* halves at once means a caller cannot check one and use the other.
|
||||
*/
|
||||
public function activeWatermarkPath(): ?string
|
||||
{
|
||||
return $this->watermark_enabled ? $this->watermark_path : null;
|
||||
}
|
||||
|
||||
public function watermarksThumbnails(): bool
|
||||
{
|
||||
return $this->activeWatermarkPath() !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding\Watermark;
|
||||
|
||||
use claviska\SimpleImage;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use App\Modules\Files\Thumbnails\Events\RenderingImage;
|
||||
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
|
||||
use App\Modules\Files\Thumbnails\ImageAudience;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Stamps the configured mark onto an image the host is about to render,
|
||||
* for both of the host's rendering hooks:
|
||||
*
|
||||
* - `RenderingImage` — the drawing itself, on a thumbnail or a preview.
|
||||
* - `ResolvingImageRendering` — the host asking, before it decodes
|
||||
* anything, whether this viewer has to be served a rendering at all.
|
||||
* Answering yes is what turns a client's preview from the stored file
|
||||
* into a watermarked copy; leaving it alone is what keeps previews
|
||||
* free on installations that do not watermark.
|
||||
*
|
||||
* Both events are duck-typed (`object`, `$event->audience`) rather than
|
||||
* imported: this package is built and tested with no host application
|
||||
* present, so `use App\Modules\Files\...` would not resolve. See the
|
||||
* host's own docblocks and docs/extension-points-architecture.md in the
|
||||
* host repo.
|
||||
*
|
||||
* Nothing here throws. The host deliberately does not wrap listeners in
|
||||
* a try/catch — a listener that fails takes the request down with it —
|
||||
* and for a decoration that is the wrong trade: an unreadable or
|
||||
* since-deleted watermark file must degrade to a plain image, not to a
|
||||
* broken one on every listing row in the app. Failures are logged so the
|
||||
* setting can be fixed rather than silently doing nothing.
|
||||
*
|
||||
* The one asymmetry worth knowing: `wouldMark()` and `apply()` ask the
|
||||
* same question a moment apart, so a watermark switched off between the
|
||||
* two would yield a rendered-but-unmarked preview. That is a plain copy
|
||||
* of the original at preview size — the correct content, reached by a
|
||||
* slower path — and it self-corrects on the next request, since saving
|
||||
* the setting flushes the cache anyway.
|
||||
*/
|
||||
class ThumbnailWatermarker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WatermarkPainter $painter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The audience whose images go unmarked: this installation's own
|
||||
* staff. Watermarking exists for the copies that leave the building —
|
||||
* clients in the portal, anonymous visitors on a public listing — and
|
||||
* stamping the staff file manager and file editor too would only
|
||||
* obscure the originals from the people who uploaded them.
|
||||
*/
|
||||
public function handle(RenderingImage $event): void
|
||||
{
|
||||
try {
|
||||
if ($event->audience === ImageAudience::Staff) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->apply($event->image);
|
||||
} catch (Throwable $exception) {
|
||||
Log::warning('Could not watermark a rendered image: '.$exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an image must be rendered rather than served as stored.
|
||||
* Only ever sets the flag — never clears it, since another listener's
|
||||
* yes is not this one's to overrule.
|
||||
*/
|
||||
public function resolve(ResolvingImageRendering $event): void
|
||||
{
|
||||
try {
|
||||
if ($event->audience === ImageAudience::Staff) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->wouldMark()) {
|
||||
$event->required = true;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
// Leaves the host on its fast path, which serves the original
|
||||
// — the behaviour of every installation that does not
|
||||
// watermark, and never a failed request.
|
||||
Log::warning('Could not decide whether to watermark a preview: '.$exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is a mark to draw at all: switched on, with an image
|
||||
* that is still on disk. Deliberately the same three conditions
|
||||
* apply() checks, so the host is never told to render something this
|
||||
* listener would then decline to touch.
|
||||
*/
|
||||
private function wouldMark(): bool
|
||||
{
|
||||
if (! $this->capabilityAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$markPath = BrandingSetting::query()->first()?->activeWatermarkPath();
|
||||
|
||||
return $markPath !== null && Storage::disk('public')->exists($markPath);
|
||||
}
|
||||
|
||||
private function apply(SimpleImage $canvas): void
|
||||
{
|
||||
if (! $this->capabilityAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$setting = BrandingSetting::query()->first();
|
||||
|
||||
if ($setting === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$markPath = $setting->activeWatermarkPath();
|
||||
|
||||
if ($markPath === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
if (! $disk->exists($markPath)) {
|
||||
Log::warning('Watermarking is on but its image is missing from disk: '.$markPath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->painter->paint(
|
||||
$canvas,
|
||||
$disk->path($markPath),
|
||||
$setting->watermark_position,
|
||||
$setting->watermark_size,
|
||||
$setting->watermark_opacity,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watermarking is part of the Cloud-exclusive Branding capability, so
|
||||
* it renders nothing where that capability is absent — the same
|
||||
* "no capability, no output" stance the host takes for Custom Assets.
|
||||
* The capability registry holds the one definition of
|
||||
* the check; the shared logo answers to it too.
|
||||
*/
|
||||
private function capabilityAvailable(): bool
|
||||
{
|
||||
return app(CapabilityRegistry::class)->has(Capability::Branding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding\Watermark;
|
||||
|
||||
use claviska\SimpleImage;
|
||||
|
||||
/**
|
||||
* Draws the mark onto a canvas. The only place that decides how a
|
||||
* watermark is positioned, scaled and blended.
|
||||
*
|
||||
* Extracted so the settings screen's live sample and the real rendering
|
||||
* pipeline cannot drift: an administrator tuning the size slider against
|
||||
* a preview drawn by *different* code would be tuning against a lie, and
|
||||
* the lie would be discovered on a client's screen. The sample and the
|
||||
* thumbnail a client actually gets are the same function, called with
|
||||
* different arguments.
|
||||
*
|
||||
* Takes its settings as arguments rather than reading BrandingSetting,
|
||||
* for the same reason: the sample renders values that are still unsaved
|
||||
* in a form.
|
||||
*/
|
||||
class WatermarkPainter
|
||||
{
|
||||
/**
|
||||
* The mark's clearance from the edge it is anchored to, as a fraction
|
||||
* of the canvas's shorter side. A fraction rather than a pixel count
|
||||
* so a 300px thumbnail and a 1600px preview look like the same
|
||||
* design. Not a setting: the difference between "flush against the
|
||||
* edge" and "a few pixels in" is the whole of the visual judgement,
|
||||
* and there is no useful second answer to offer an administrator.
|
||||
*/
|
||||
private const EDGE_INSET_RATIO = 0.04;
|
||||
|
||||
/**
|
||||
* @param string $markPath an absolute local path to the artwork
|
||||
* @param int $size percentage of the canvas the mark is fitted into
|
||||
* @param int $opacity percentage
|
||||
*/
|
||||
public function paint(
|
||||
SimpleImage $canvas,
|
||||
string $markPath,
|
||||
WatermarkPosition $position,
|
||||
int $size,
|
||||
int $opacity,
|
||||
): void {
|
||||
$width = $canvas->getWidth();
|
||||
$height = $canvas->getHeight();
|
||||
|
||||
// A box that is `size`% of *both* dimensions, so the setting reads
|
||||
// the same on a portrait and a landscape canvas and a wide mark
|
||||
// can never overflow a narrow one.
|
||||
$mark = new SimpleImage($markPath);
|
||||
|
||||
$scale = min(
|
||||
$width * $size / 100 / $mark->getWidth(),
|
||||
$height * $size / 100 / $mark->getHeight(),
|
||||
);
|
||||
|
||||
// Scaled by hand rather than with bestFit(), which returns early
|
||||
// when the image already fits: a small logo would then keep its
|
||||
// native size and the size setting would silently do nothing above
|
||||
// whatever percentage happened to match it. Enlarging a small mark
|
||||
// is soft, but it is what was asked for — a control that only works
|
||||
// in one direction is worse than a slightly blurry one.
|
||||
$mark->resize(
|
||||
max(1, (int) round($mark->getWidth() * $scale)),
|
||||
max(1, (int) round($mark->getHeight() * $scale)),
|
||||
);
|
||||
|
||||
$inset = max(1, (int) round(min($width, $height) * self::EDGE_INSET_RATIO));
|
||||
|
||||
$canvas->overlay(
|
||||
$mark,
|
||||
$position->anchor(),
|
||||
$opacity / 100,
|
||||
$inset,
|
||||
$inset,
|
||||
// Offsets measured inward from whichever edge the anchor names,
|
||||
// so one inset value works for all eight edge positions instead
|
||||
// of needing its sign flipped per corner. Centre ignores them.
|
||||
calculateOffsetFromEdge: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding\Watermark;
|
||||
|
||||
/**
|
||||
* Where the watermark sits on a thumbnail: the four corners, the
|
||||
* midpoint of each of the four edges, and the centre.
|
||||
*
|
||||
* The stored values are this module's own vocabulary, deliberately not
|
||||
* SimpleImage's anchor strings — `anchor()` translates. SimpleImage
|
||||
* decides an anchor by substring-matching 'top'/'bottom'/'left'/'right',
|
||||
* so 'center' means "neither" on an axis and the two vocabularies happen
|
||||
* to overlap today; storing its spelling in our database would make that
|
||||
* coincidence a schema commitment.
|
||||
*/
|
||||
enum WatermarkPosition: string
|
||||
{
|
||||
case TopLeft = 'top-left';
|
||||
case TopCenter = 'top-center';
|
||||
case TopRight = 'top-right';
|
||||
case MiddleLeft = 'middle-left';
|
||||
case Center = 'center';
|
||||
case MiddleRight = 'middle-right';
|
||||
case BottomLeft = 'bottom-left';
|
||||
case BottomCenter = 'bottom-center';
|
||||
case BottomRight = 'bottom-right';
|
||||
|
||||
public function anchor(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TopLeft => 'top left',
|
||||
self::TopCenter => 'top',
|
||||
self::TopRight => 'top right',
|
||||
self::MiddleLeft => 'left',
|
||||
self::Center => 'center',
|
||||
self::MiddleRight => 'right',
|
||||
self::BottomLeft => 'bottom left',
|
||||
self::BottomCenter => 'bottom',
|
||||
self::BottomRight => 'bottom right',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(fn (self $case): string => $case->value, self::cases());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Branding\Watermark;
|
||||
|
||||
use claviska\SimpleImage;
|
||||
|
||||
/**
|
||||
* The stand-in photograph the settings screen draws the mark on, so an
|
||||
* administrator can judge placement, scale and opacity without going to
|
||||
* find a client account and a real file.
|
||||
*
|
||||
* Drawn rather than shipped as an asset: a stock photograph would be a
|
||||
* licensing question and a binary in a git repository, and would only
|
||||
* ever exercise whatever tones that one picture happens to contain. This
|
||||
* is built to answer the question the sample exists for — "will my mark
|
||||
* still read?" — with a full dark-to-light ramp under it, plus a couple
|
||||
* of hard edges, so a too-transparent or too-small mark is obvious
|
||||
* against at least one part of it.
|
||||
*/
|
||||
class WatermarkSample
|
||||
{
|
||||
/**
|
||||
* Roughly the proportions of a landscape photograph, and about the
|
||||
* size the settings screen shows it at — big enough to judge, small
|
||||
* enough to re-render on every keystroke.
|
||||
*/
|
||||
private const WIDTH = 480;
|
||||
|
||||
private const HEIGHT = 300;
|
||||
|
||||
public function __construct(
|
||||
private readonly WatermarkPainter $painter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string $markPath an absolute local path to the artwork
|
||||
*/
|
||||
public function render(string $markPath, WatermarkPosition $position, int $size, int $opacity): SimpleImage
|
||||
{
|
||||
$canvas = $this->backdrop();
|
||||
|
||||
$this->painter->paint($canvas, $markPath, $position, $size, $opacity);
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
private function backdrop(): SimpleImage
|
||||
{
|
||||
$canvas = (new SimpleImage())->fromNew(self::WIDTH, self::HEIGHT, '#1f2937');
|
||||
|
||||
// A left-to-right ramp, one column at a time — GD has no gradient
|
||||
// primitive, and 480 lines is imperceptible next to the encode
|
||||
// that follows.
|
||||
for ($x = 0; $x < self::WIDTH; $x++) {
|
||||
$shade = (int) round(24 + ($x / self::WIDTH) * 210);
|
||||
|
||||
// alpha 1 is *opaque* in SimpleImage's vocabulary — 0 is the
|
||||
// fully transparent one ('transparent' normalizes to alpha 0).
|
||||
// Getting that backwards draws the whole ramp invisibly, which
|
||||
// no assertion about the mark itself would ever have caught.
|
||||
$canvas->line($x, 0, $x, self::HEIGHT, [
|
||||
'red' => $shade, 'green' => $shade, 'blue' => $shade, 'alpha' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
// Two blocks at the extremes of the ramp, so every corner and edge
|
||||
// the position picker offers has both a light and a dark
|
||||
// neighbourhood somewhere near it.
|
||||
$this->fill($canvas, 0, 0, (int) (self::WIDTH * 0.28), (int) (self::HEIGHT * 0.34), '#f8fafc');
|
||||
$this->fill($canvas, (int) (self::WIDTH * 0.68), (int) (self::HEIGHT * 0.62), self::WIDTH, self::HEIGHT, '#0b1120');
|
||||
|
||||
return $canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* A filled rectangle, drawn as a run of vertical lines.
|
||||
*
|
||||
* `rectangle(..., 'filled')` does exist and would be the obvious call,
|
||||
* but SimpleImage's own docblock types that parameter `integer|array`,
|
||||
* so passing its documented magic string fails static analysis. Lines
|
||||
* cost nothing here and keep the analyser honest instead of teaching
|
||||
* it to ignore a whole category of argument-type error in this file.
|
||||
*
|
||||
* @param string|array<string, int> $color
|
||||
*/
|
||||
private function fill(SimpleImage $canvas, int $x1, int $y1, int $x2, int $y2, string|array $color): void
|
||||
{
|
||||
for ($x = $x1; $x <= $x2; $x++) {
|
||||
$canvas->line($x, $y1, $x, $y2, $color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Platform\Branding\Http\Controllers\Api\BrandingController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Branding — module API routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Mounted by the host at /api/v1/modules/branding, inside the API's auth
|
||||
| stack (bearer token, active staff account) and behind
|
||||
| `capability:branding.customize`. None of that is restated here — the host
|
||||
| applies it, which is why these are plain relative paths.
|
||||
|
|
||||
| `token-can:` names a permission from the *host's* vocabulary. A module
|
||||
| cannot invent ability strings: they have to reach the token-issuance UI
|
||||
| and the reserved-namespace invariant, so they belong in the host's
|
||||
| Permission enum. `edit_settings` is the same key the web branding routes
|
||||
| use, so the API boundary mirrors the web one rather than inventing a
|
||||
| second answer to "who may see the logo".
|
||||
|
|
||||
*/
|
||||
|
||||
Route::get('logo', [BrandingController::class, 'show'])
|
||||
->middleware('token-can:edit_settings')
|
||||
->name('logo.show');
|
||||
|
||||
Route::get('watermark', [BrandingController::class, 'watermark'])
|
||||
->middleware('token-can:edit_settings')
|
||||
->name('watermark.show');
|
||||
@@ -46,10 +46,30 @@ enum Capability: string
|
||||
// cloud-modules below.
|
||||
case CustomAssets = 'custom_assets.manage';
|
||||
|
||||
// Cloud-only — code lives in the private projectsend/cloud-modules
|
||||
// package (github.com/projectsend/cloud-modules), never in this repo.
|
||||
// Both editions. An installation dressing itself in its own logo, and
|
||||
// watermarking what its clients and visitors see, is not a hosted
|
||||
// concern -- it was Cloud-only because the code happened to live in
|
||||
// the private package, which is a fact about where somebody typed it
|
||||
// rather than about who should have it. Moved into core 2026-08-28.
|
||||
//
|
||||
// What a *plan* withholds is a different question from what an
|
||||
// edition has, and it is answered by subtracting this key from an
|
||||
// instance's environment rather than by moving it back. See
|
||||
// CapabilityRegistry.
|
||||
case Branding = 'branding.customize';
|
||||
|
||||
// Cloud-only, and deliberately not part of Branding above: taking
|
||||
// ProjectSend's name off the pages somebody's own visitors see is the
|
||||
// white-label half, and white-labelling is one of the things a hosted
|
||||
// customer pays for.
|
||||
//
|
||||
// The gate is not this key. It is that the only code able to answer
|
||||
// "hide it" ships in the private package, so an installation without
|
||||
// that package has no listener to run and flipping an edition
|
||||
// variable buys nothing. This key exists so a screen knows whether to
|
||||
// offer the switch at all. See ResolvingAttribution.
|
||||
case AttributionHide = 'attribution.hide';
|
||||
|
||||
// Cloud-only — the storage backend is ours, supplied by the
|
||||
// environment when the instance is provisioned and not the customer's
|
||||
// to see or change. The counterpart of StorageConfigure above rather
|
||||
@@ -108,9 +128,10 @@ enum Capability: string
|
||||
self::SchedulerMonitoring,
|
||||
self::CustomAssets => [Edition::Community],
|
||||
|
||||
self::UsersManage => [Edition::Community, Edition::Cloud],
|
||||
self::UsersManage,
|
||||
self::Branding => [Edition::Community, Edition::Cloud],
|
||||
|
||||
self::Branding,
|
||||
self::AttributionHide,
|
||||
self::StorageManaged,
|
||||
self::CaptchaManagedKeys,
|
||||
self::PlatformManaged,
|
||||
|
||||
@@ -4,11 +4,63 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Capabilities;
|
||||
|
||||
/**
|
||||
* What this installation may do.
|
||||
*
|
||||
* An edition grants a set of capabilities; an operator may take some of
|
||||
* them away. Those are different questions and the asymmetry between them
|
||||
* is the whole design:
|
||||
*
|
||||
* **Subtraction only.** `PROJECTSEND_CAPABILITIES_DISABLED` can remove a
|
||||
* key the edition grants. Nothing can add one. An environment variable
|
||||
* that could grant a capability would put the proprietary screens of the
|
||||
* hosted edition one line of `.env` away on every self-hosted install,
|
||||
* which is not a gate at all — so the list is read, intersected with what
|
||||
* the edition already allows, and can only ever make the answer smaller.
|
||||
*
|
||||
* **Why it exists.** A plan is not an edition. There are no billing tiers
|
||||
* in this application to key off, and inventing one here would be a claim
|
||||
* the rest of the codebase cannot back up — the same objection
|
||||
* config/api.php makes about installation-level rate limits. This is not
|
||||
* that: it is the operator telling the installation a fact about itself,
|
||||
* exactly as PROJECTSEND_PLATFORM_MAX_STAFF_USERS does for seats. The
|
||||
* platform knows what it sold; the installation is told, and enforces.
|
||||
*
|
||||
* **Unknown keys are ignored, not fatal.** A variable outlives the plan
|
||||
* that wrote it and the release that named the key. An instance that
|
||||
* refuses to boot because it was told to disable something that no longer
|
||||
* exists would be a self-inflicted outage on upgrade day.
|
||||
*/
|
||||
class CapabilityRegistry
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private readonly array $disabled;
|
||||
|
||||
/**
|
||||
* @param list<string>|string|null $disabled keys this installation
|
||||
* has been told it may not
|
||||
* use; a comma-separated
|
||||
* string is what the
|
||||
* environment supplies
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Edition $edition,
|
||||
) {}
|
||||
array|string|null $disabled = [],
|
||||
) {
|
||||
// Parsed here rather than read from config(), so the registry stays
|
||||
// a value object that can be constructed with nothing but its two
|
||||
// facts -- which is what lets it be unit-tested without booting an
|
||||
// application, and what stops the edition and the subtraction being
|
||||
// read from two different places at two different times.
|
||||
$this->disabled = is_array($disabled)
|
||||
? $disabled
|
||||
: array_values(array_filter(
|
||||
array_map(trim(...), explode(',', (string) $disabled)),
|
||||
fn (string $key): bool => $key !== '',
|
||||
));
|
||||
}
|
||||
|
||||
public function edition(): Edition
|
||||
{
|
||||
@@ -17,7 +69,8 @@ class CapabilityRegistry
|
||||
|
||||
public function has(Capability $capability): bool
|
||||
{
|
||||
return $capability->availableIn($this->edition);
|
||||
return $capability->availableIn($this->edition)
|
||||
&& ! in_array($capability->value, $this->disabled, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -162,8 +162,9 @@ class EmailOAuthController extends Controller
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'account_email' => null,
|
||||
'last_error' => null,
|
||||
])->save();
|
||||
]);
|
||||
$connection->clearFailure();
|
||||
$connection->save();
|
||||
|
||||
$this->activateConnection();
|
||||
|
||||
|
||||
@@ -185,8 +185,8 @@ class EmailSettingsController extends Controller
|
||||
'refresh_token' => null,
|
||||
'token_expires_at' => null,
|
||||
'account_email' => null,
|
||||
'last_error' => null,
|
||||
]);
|
||||
$connection->clearFailure();
|
||||
}
|
||||
|
||||
$connection->save();
|
||||
|
||||
@@ -37,7 +37,10 @@ class PrivacySettingsController extends Controller
|
||||
'account_erasure_grace_days' => $this->settings->get(Setting::AccountErasureGraceDays),
|
||||
'account_erasure_content_action' => $this->settings->get(Setting::AccountErasureContentAction),
|
||||
'account_erasure_reassign_to' => $this->settings->get(Setting::AccountErasureReassignTo),
|
||||
'reassign_candidates' => $this->accountDeletion->candidates(),
|
||||
// Installation-wide on purpose: this is the default every
|
||||
// erasure will use, stored once for everybody, and the page is
|
||||
// already behind edit_settings.
|
||||
'reassign_candidates' => $this->accountDeletion->candidates(null),
|
||||
'api_request_log_retention_days' => $this->settings->get(Setting::ApiRequestLogRetentionDays),
|
||||
'discourage_search_indexing' => $this->settings->get(Setting::DiscourageSearchIndexing),
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace App\Modules\Platform\Http\Middleware;
|
||||
use App\Modules\Platform\Capabilities\Capability;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Capabilities\CapabilityUnavailable;
|
||||
use App\Support\ApiSurface;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -17,6 +18,12 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* API requests get a machine-readable 403; web requests get a 404 so
|
||||
* unavailable features are absent, not teased.
|
||||
*
|
||||
* Which of the two a request is comes from the route (see ApiSurface), not
|
||||
* from its Accept header. Whether an endpoint exists in this edition is a
|
||||
* property of the installation; deciding it from what the caller is
|
||||
* willing to parse answered the same API route 403 or 404 depending on
|
||||
* nothing but a header, and routes/api.php promises the 403.
|
||||
*
|
||||
* The API half throws CapabilityUnavailable rather than returning a body,
|
||||
* so the refusal goes through ProblemDetails like every other API error
|
||||
* instead of being the one response shaped differently from the rest.
|
||||
@@ -35,7 +42,7 @@ class EnsureCapability
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
if (ApiSurface::matches($request)) {
|
||||
throw new CapabilityUnavailable($capability, $this->capabilities->edition());
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Modules\Identity\TwoFactor\TwoFactorEnforcement;
|
||||
use App\Modules\Identity\UserType;
|
||||
use App\Modules\Platform\Capabilities\CapabilityRegistry;
|
||||
use App\Modules\Platform\Installation\Events\ResolvingInstallationStatus;
|
||||
use App\Modules\Platform\Scheduling\ScheduledTaskRun;
|
||||
use App\Modules\Platform\Scheduling\TaskRunStatus;
|
||||
use App\Modules\Platform\Seats\SeatAllowance;
|
||||
use App\Modules\Platform\Settings\Setting;
|
||||
use App\Modules\Platform\Settings\Settings;
|
||||
@@ -65,6 +67,15 @@ use Throwable;
|
||||
* survives on purpose — see AccountEraser), so the answer does not change
|
||||
* when the person who gave it is forgotten.
|
||||
*
|
||||
* **That "never pruned" is now somebody's safety argument.** The hosted
|
||||
* platform warns, pauses and finally removes a free instance nobody has
|
||||
* signed in to, and this field is what it counts from. Retention or
|
||||
* pruning added to `activity_log` would not break anything here — it
|
||||
* would quietly make old installations look dormant, and the thing that
|
||||
* acts on that reading deletes them. Anyone adding it needs to give this
|
||||
* field another source first, not merely check that the tests still
|
||||
* pass.
|
||||
*
|
||||
* ### Storage is the application's number, not the disk's
|
||||
*
|
||||
* `storage.bytes` is what this installation holds, summed from the rows
|
||||
@@ -94,9 +105,103 @@ use Throwable;
|
||||
* `modules` is filled by whatever packages are installed, through
|
||||
* ResolvingInstallationStatus. A platform that provisioned a bucket knows
|
||||
* what it asked for; only the installation knows what loaded.
|
||||
*
|
||||
* ### `capabilities` is compared, not displayed
|
||||
*
|
||||
* The control plane reads this list against the plan it wrote for the
|
||||
* tenant — "this instance is on the free plan and still grants branding"
|
||||
* is a comparison, not a glance. So the *keys* and their order are a
|
||||
* contract: renaming one, or reordering the enum they come from, breaks
|
||||
* that comparison while every test here keeps passing. A key that changes
|
||||
* meaning needs a new key, not an edit.
|
||||
*
|
||||
* ### `usage` and `health.scheduler` are charted, so their keys are a promise
|
||||
*
|
||||
* The hosted platform's customer dashboard plots these over time. That
|
||||
* makes the key names a contract in the same way `capabilities` is one,
|
||||
* and it fails in a nastier way: a renamed capability key breaks a
|
||||
* comparison that somebody is watching, while a renamed `usage` key
|
||||
* produces a chart that is silently *empty* rather than an error. Nobody
|
||||
* gets paged for a flat line.
|
||||
*
|
||||
* So: add keys freely, and never rename or repurpose one. A key whose
|
||||
* meaning changes needs a new key, not a new value — "downloads" that
|
||||
* quietly starts excluding staff is worse than "downloads" disappearing,
|
||||
* because the second is noticed.
|
||||
*
|
||||
* `usage` is a **rolling window, deliberately, and has no lifetime
|
||||
* totals**. Not a presentation choice: `activity_log` is never pruned
|
||||
* (see above), so a lifetime count over it gets slower every day of the
|
||||
* installation's life, while a windowed one stays flat forever. The
|
||||
* window is stated in the document as `window_days` rather than assumed
|
||||
* by the reader, so changing it is visible to whoever is plotting it.
|
||||
*
|
||||
* Every count here rides one of the two composite indexes added for it —
|
||||
* see the migration adding them, which also records why they have to
|
||||
* ship as a pair.
|
||||
*
|
||||
* ### The scheduler is the one thing nothing else can see
|
||||
*
|
||||
* `health.queues` catches a dead worker. Nothing catches a dead
|
||||
* *scheduler*, and its symptom is not a stalled feature: expired files
|
||||
* stop being purged, so content that was supposed to become unreachable
|
||||
* stays reachable, and orphans and stale uploads accumulate against a
|
||||
* quota nobody is watching. The installation looks completely healthy
|
||||
* while it happens, to its operator and to its administrator alike.
|
||||
*
|
||||
* ### A version is a decision, a commit is a fact
|
||||
*
|
||||
* `build` says which commit this installation was built from. A version
|
||||
* string is chosen by somebody and stamped; two images can carry the same
|
||||
* one and different code — an image built from the tag, and one built
|
||||
* from the branch that tag sits on. A fleet spent a day reporting "2.2.0"
|
||||
* from images that were not the released 2.2.0, and nothing inside any of
|
||||
* them could have said so.
|
||||
*
|
||||
* Null on a source checkout, all four fields, because `config/build.php`
|
||||
* is written by build-release.sh and a checkout is not a build. That is
|
||||
* the honest answer rather than a missing one: "I was not built" and "I
|
||||
* will not say" are different, and only the first is true here.
|
||||
*/
|
||||
class StatusCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The rolling window every `usage` count is measured over.
|
||||
*
|
||||
* Emitted in the document as `window_days` rather than left for the
|
||||
* reader to know, because a number that is charted and a number that
|
||||
* is assumed diverge exactly once and silently.
|
||||
*/
|
||||
private const USAGE_WINDOW_DAYS = 30;
|
||||
|
||||
/**
|
||||
* The actions `usage.actions` counts, and the whole of it.
|
||||
*
|
||||
* An allowlist rather than a `group by action`, for two reasons that
|
||||
* happen to agree. Privacy: this document leaves the installation, and
|
||||
* cases land in Action most weeks — an open group-by would start
|
||||
* shipping new action names outward with nobody having decided that
|
||||
* they should go, and some of them (`account.erased`,
|
||||
* `two_factor.reset`, `password.updated`) are somebody's compliance
|
||||
* event rather than a business metric. Cost: five keyed counts measure
|
||||
* ~30x cheaper than one `group by action` over the same window,
|
||||
* because each rides (action, created_at) while the group-by starts
|
||||
* from created_at and reads rows.
|
||||
*
|
||||
* These five answer "is my library growing, are people being added, is
|
||||
* anything being shared" and nothing else. Uploads and downloads are
|
||||
* their own fields; none of these names a person.
|
||||
*
|
||||
* @var list<Action>
|
||||
*/
|
||||
private const USAGE_ACTIONS = [
|
||||
Action::UserCreated,
|
||||
Action::ClientSelfRegistered,
|
||||
Action::FileAssigned,
|
||||
Action::ShareLinkCreated,
|
||||
Action::GroupCreated,
|
||||
];
|
||||
|
||||
protected $signature = 'projectsend:status {--json : Emit machine-readable JSON on stdout}';
|
||||
|
||||
protected $description = 'Report this installation\'s version, edition, capabilities and seat usage';
|
||||
@@ -127,9 +232,16 @@ class StatusCommand extends Command
|
||||
// an unlimited seat count is: a watcher has to be able to
|
||||
// tell that apart from "we got no answer". Collapsing the
|
||||
// two is how a broken probe reads as a dormant fleet.
|
||||
'last_staff_login_at' => $this->lastStaffLoginAt(),
|
||||
'last_staff_login_at' => $this->lastLoginAt(UserType::Staff),
|
||||
// The staff timestamp says the administrator still shows
|
||||
// up. This one says their customers do, which is a
|
||||
// different question and the more interesting half: an
|
||||
// installation whose only visitor is the person paying
|
||||
// for it is one nobody is getting value from.
|
||||
'last_client_login_at' => $this->lastLoginAt(UserType::Client),
|
||||
],
|
||||
'storage' => $this->storage(),
|
||||
'usage' => $this->usage(),
|
||||
'health' => $this->health(),
|
||||
'settings' => [
|
||||
// Echoed back rather than assumed: an operator writes the
|
||||
@@ -146,6 +258,16 @@ class StatusCommand extends Command
|
||||
// reader unmarshalling a map breaks on the day it happens to
|
||||
// be empty rather than on the day it is written.
|
||||
'modules' => (object) $this->modules(),
|
||||
'build' => [
|
||||
// `channel` is 'release' or 'dev'. An internal build names
|
||||
// itself after its commit and can never be published, so a
|
||||
// fleet reading 'dev' is looking at something deliberate
|
||||
// rather than at a mistake.
|
||||
'commit' => $this->buildFact('commit'),
|
||||
'ref' => $this->buildFact('ref'),
|
||||
'channel' => $this->buildFact('channel'),
|
||||
'built_at' => $this->buildFact('built_at'),
|
||||
],
|
||||
];
|
||||
|
||||
if ($this->option('json')) {
|
||||
@@ -160,13 +282,27 @@ class StatusCommand extends Command
|
||||
$this->line('Clients: '.$this->seatLine($status['seats']['clients']));
|
||||
$this->line('Last staff login: '.($status['activity']['last_staff_login_at'] ?? 'never'));
|
||||
$this->line('Storage: '.number_format($status['storage']['bytes']).' bytes in '.$status['storage']['files'].' files');
|
||||
$this->line('Build: '.($status['build']['ref'] ?? 'not a build')
|
||||
.($status['build']['channel'] === 'dev' ? ' (dev)' : ''));
|
||||
$this->line('Health: '.$status['health']['pending_migrations'].' migrations pending, '
|
||||
.$status['health']['failed_jobs'].' failed jobs, '
|
||||
.array_sum(array_filter($status['health']['queues'], 'is_int')).' queued');
|
||||
$this->line('Scheduler: '.($status['health']['scheduler']['last_run_at'] ?? 'never run')
|
||||
.' ('.$status['health']['scheduler']['failing'].' failing)');
|
||||
$this->line('Last '.self::USAGE_WINDOW_DAYS.'d: '
|
||||
.array_sum($status['usage']['downloads']).' downloads, '
|
||||
.$status['usage']['uploads'].' uploads');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function buildFact(string $key): ?string
|
||||
{
|
||||
$value = config("build.$key");
|
||||
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
private function enforcement(Settings $settings): string
|
||||
{
|
||||
$value = $settings->get(Setting::TwoFactorEnforcement);
|
||||
@@ -208,13 +344,14 @@ class StatusCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{pending_migrations: int, failed_jobs: int, queues: array<string, int|null>}
|
||||
* @return array{pending_migrations: int, failed_jobs: int, failed_jobs_latest_at: string|null, queues: array<string, int|null>, scheduler: array{last_run_at: string|null, failing: int}}
|
||||
*/
|
||||
private function health(): array
|
||||
{
|
||||
return [
|
||||
'pending_migrations' => $this->pendingMigrations(),
|
||||
'failed_jobs' => $this->failedJobs(),
|
||||
'failed_jobs_latest_at' => $this->latestFailureAt(),
|
||||
// The two this application actually runs workers for. A depth
|
||||
// is not a fault on its own -- a busy installation has one --
|
||||
// but a depth that only ever grows is a worker that died, and
|
||||
@@ -223,9 +360,158 @@ class StatusCommand extends Command
|
||||
'default' => $this->queueDepth('default'),
|
||||
'zips' => $this->queueDepth('zips'),
|
||||
],
|
||||
'scheduler' => $this->scheduler(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the scheduler is running, and whether what it runs works.
|
||||
*
|
||||
* One row per known command, upserted on every run, so this is a
|
||||
* dozen rows however old the installation is.
|
||||
*
|
||||
* `last_run_at` is null when nothing has ever run — a brand new
|
||||
* installation, or one whose scheduler has never been wired up at
|
||||
* all — and those are different from "ran, a long time ago", which
|
||||
* is a timestamp. The reader decides what counts as too old; every
|
||||
* task in routes/console.php is daily, so anything past about a day
|
||||
* means nobody is running it. Deliberately not judged here: a
|
||||
* threshold belongs to whoever is watching, and baking one in would
|
||||
* make the answer wrong for anyone whose schedule is not ours.
|
||||
*
|
||||
* The failure *message* is deliberately not reported. This document
|
||||
* leaves the installation, and a task's error text is the one field
|
||||
* here that can carry a filesystem path, a hostname or an exception
|
||||
* from somebody's storage backend. A count says "go and look",
|
||||
* which is all a watcher needs and all it is owed.
|
||||
*
|
||||
* `failing` counts commands whose *most recent* run failed, not
|
||||
* failures over time — the row is upserted, so a task that failed
|
||||
* last night and succeeded this morning is not failing. A task that
|
||||
* has never run is not counted here either; it is absent from the
|
||||
* table, which is what `last_run_at` is for.
|
||||
*
|
||||
* @return array{last_run_at: string|null, failing: int}
|
||||
*/
|
||||
private function scheduler(): array
|
||||
{
|
||||
$lastRun = ScheduledTaskRun::query()->max('ran_at');
|
||||
|
||||
return [
|
||||
'last_run_at' => $lastRun === null ? null : Carbon::parse($lastRun)->toIso8601String(),
|
||||
'failing' => ScheduledTaskRun::query()
|
||||
->where('status', TaskRunStatus::Failed)
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* What has been happening here lately.
|
||||
*
|
||||
* Every figure is a count over the same rolling window and there are
|
||||
* no lifetime totals — see the class docblock for why that is a
|
||||
* correctness decision rather than a presentational one.
|
||||
*
|
||||
* Downloads are split the way the installation's own dashboard
|
||||
* splits them (DashboardController::transferSeries), on purpose: the
|
||||
* administrator and whatever is reading this document have to be able
|
||||
* to agree about a number they can both see. Staff downloads are
|
||||
* reported rather than dropped so a reader can choose, but they are
|
||||
* their own key precisely because they are not audience traffic — an
|
||||
* administrator opening their own upload to check it is not somebody
|
||||
* receiving a file.
|
||||
*
|
||||
* @return array{window_days: int, downloads: array{staff: int, clients: int, anonymous: int}, uploads: int, actions: object}
|
||||
*/
|
||||
private function usage(): array
|
||||
{
|
||||
$since = now()->subDays(self::USAGE_WINDOW_DAYS);
|
||||
|
||||
$downloads = [
|
||||
Action::FileDownloaded->value,
|
||||
Action::ShareLinkDownloaded->value,
|
||||
Action::PublicFileDownloaded->value,
|
||||
];
|
||||
|
||||
return [
|
||||
'window_days' => self::USAGE_WINDOW_DAYS,
|
||||
'downloads' => [
|
||||
'staff' => $this->countActionsByActor($downloads, $since, UserType::Staff->value),
|
||||
'clients' => $this->countActionsByActor($downloads, $since, UserType::Client->value),
|
||||
// Null actor_type is the anonymous case: a share link or
|
||||
// the public listing, served to somebody with no account
|
||||
// at all. It is the traffic an administrator has no other
|
||||
// way to see.
|
||||
'anonymous' => $this->countActionsByActor($downloads, $since, null),
|
||||
],
|
||||
'uploads' => $this->countActions([Action::FileUploaded->value], $since),
|
||||
// Cast for the reason `modules` is: an empty PHP array
|
||||
// encodes as a list, and a reader unmarshalling a map breaks
|
||||
// on the day it happens to be empty rather than on the day it
|
||||
// is written. It cannot be empty today, but the allowlist is
|
||||
// meant to be edited.
|
||||
'actions' => (object) $this->usageActions($since),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function usageActions(Carbon $since): array
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
// One keyed count each rather than a single grouped query: this
|
||||
// is both the cheaper shape (each rides (action, created_at);
|
||||
// a group-by starts from created_at and reads rows) and the one
|
||||
// that can only ever emit keys somebody chose. See USAGE_ACTIONS.
|
||||
foreach (self::USAGE_ACTIONS as $action) {
|
||||
$counts[$action->value] = $this->countActions([$action->value], $since);
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of these actions happened in the window, by anyone.
|
||||
*
|
||||
* @param list<string> $actions
|
||||
*/
|
||||
private function countActions(array $actions, Carbon $since): int
|
||||
{
|
||||
return ActivityLog::query()
|
||||
->whereIn('action', $actions)
|
||||
->where('created_at', '>=', $since)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* The same count, narrowed to one kind of actor.
|
||||
*
|
||||
* Separate from countActions() rather than an optional argument on
|
||||
* it, because the argument would have to carry three states — staff,
|
||||
* client, and *nobody at all* — and null already means the third.
|
||||
* An optional `?string $actorType = null` reads as "no filter" at
|
||||
* every call site and would have silently reported the installation's
|
||||
* whole download total in the anonymous column.
|
||||
*
|
||||
* @param list<string> $actions
|
||||
* @param string|null $actorType null is the anonymous case: a share
|
||||
* link or the public listing, served
|
||||
* to somebody with no account
|
||||
*/
|
||||
private function countActionsByActor(array $actions, Carbon $since, ?string $actorType): int
|
||||
{
|
||||
$query = ActivityLog::query()
|
||||
->whereIn('action', $actions)
|
||||
->where('created_at', '>=', $since);
|
||||
|
||||
return ($actorType === null
|
||||
? $query->whereNull('actor_type')
|
||||
: $query->where('actor_type', $actorType)
|
||||
)->count();
|
||||
}
|
||||
|
||||
private function pendingMigrations(): int
|
||||
{
|
||||
/** @var Migrator $migrator */
|
||||
@@ -250,6 +536,46 @@ class StatusCommand extends Command
|
||||
return DB::table($table)->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the most recent job failed, or null if none has.
|
||||
*
|
||||
* `failed_jobs` on its own cannot answer whether anything is wrong
|
||||
* *now*, and reading it as though it could is a category error rather
|
||||
* than a threshold that needs tuning. It is a history: the table is
|
||||
* swept daily by projectsend:purge-failed-jobs, so the count spans a
|
||||
* retention window — one whose length the installation chooses on the
|
||||
* Scheduler Monitoring screen, and which can be set to 0 for "keep
|
||||
* forever" by somebody who treats a failed job as evidence rather
|
||||
* than as debris.
|
||||
*
|
||||
* So the same number means different things on two identical
|
||||
* installations, and on a keep-forever one it grows without bound
|
||||
* until any fixed threshold trips. A fleet comparing tenants on the
|
||||
* count alone is comparing their retention settings.
|
||||
*
|
||||
* This is the field that answers the question actually being asked —
|
||||
* "has anything failed lately" — because a timestamp is independent
|
||||
* of how long the rows are kept. A count of 27 whose newest entry is
|
||||
* three weeks old is an installation that has been healthy for three
|
||||
* weeks and has not been swept yet.
|
||||
*
|
||||
* The exception text stays out, for the reason the scheduler's
|
||||
* message does: it carries paths, hostnames and stack traces, and
|
||||
* this document leaves the installation.
|
||||
*/
|
||||
private function latestFailureAt(): ?string
|
||||
{
|
||||
$table = config('queue.failed.table');
|
||||
|
||||
if (! is_string($table) || $table === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$latest = DB::table($table)->max('failed_at');
|
||||
|
||||
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
|
||||
}
|
||||
|
||||
/**
|
||||
* Null rather than a crash when the queue cannot be reached, and null
|
||||
* rather than zero: an unreachable Redis is not an empty queue, and a
|
||||
@@ -282,18 +608,21 @@ class StatusCommand extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent interactive staff sign-in, or null if there has
|
||||
* never been one.
|
||||
* The most recent interactive sign-in by this kind of account, or
|
||||
* null if there has never been one.
|
||||
*/
|
||||
private function lastStaffLoginAt(): ?string
|
||||
private function lastLoginAt(UserType $type): ?string
|
||||
{
|
||||
$latest = ActivityLog::query()
|
||||
->where('action', Action::Login->value)
|
||||
->where('actor_type', UserType::Staff->value)
|
||||
->where('actor_type', $type->value)
|
||||
->max('created_at');
|
||||
|
||||
// `action` and `actor_type` carry an index each, so this narrows
|
||||
// on one of them rather than reading the log.
|
||||
// Answered out of (action, actor_type, created_at) without
|
||||
// reading a row: the two equalities are that index's prefix and
|
||||
// the MAX is the last entry under them. Before that index existed
|
||||
// this was a scan of every login the installation had ever
|
||||
// recorded, with a primary-key lookup per row to check the actor.
|
||||
return $latest === null ? null : Carbon::parse($latest)->toIso8601String();
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,18 @@ class RefreshMailOAuthTokensCommand extends Command
|
||||
$hadError = $connection->last_error !== null;
|
||||
|
||||
try {
|
||||
$brokers->for($connection->provider)->refresh($connection);
|
||||
// Serialised against sends: refresh() on its own is the
|
||||
// other half of the race freshAccessToken()'s lock is
|
||||
// there to stop.
|
||||
$refreshed = $brokers->for($connection->provider)->refreshSerially($connection);
|
||||
|
||||
$this->info("Refreshed {$connection->provider->value} ({$connection->account_email}).");
|
||||
// Standing aside is a healthy outcome, not a silent one:
|
||||
// somebody else is refreshing this very connection, which
|
||||
// slides the window just as well. Saying "Refreshed" for
|
||||
// it would describe a token request that never happened.
|
||||
$this->info($refreshed
|
||||
? "Refreshed {$connection->provider->value} ({$connection->account_email})."
|
||||
: "Skipped {$connection->provider->value} ({$connection->account_email}): a refresh is already in progress.");
|
||||
|
||||
// Back from the dead (an admin fixed things upstream
|
||||
// without reconnecting): the applier may have been
|
||||
@@ -71,7 +80,17 @@ class RefreshMailOAuthTokensCommand extends Command
|
||||
// notification would otherwise repeat daily for as long
|
||||
// as nobody reconnects, and a nagging alert trains
|
||||
// people to ignore the one that matters.
|
||||
if (! $hadError) {
|
||||
//
|
||||
// Asked of broken_notified_at, not of last_error. The
|
||||
// question is "have the admins been told", and last_error
|
||||
// cannot answer it: the send path writes that column too
|
||||
// (OAuthCodeFlowBroker::refresh, reached from
|
||||
// freshAccessToken) and notifies nobody. On an
|
||||
// installation that actually sends mail, that write lands
|
||||
// first — so reading it as "already told them" left this
|
||||
// silent for good, on exactly the installations whose
|
||||
// password-reset mail rides on the connection.
|
||||
if ($connection->broken_notified_at === null) {
|
||||
$recipients = array_values(User::query()->where('type', UserType::Staff)->get()
|
||||
->filter(fn (User $staff): bool => $permissions->allows($staff, Permission::EditSettings))
|
||||
->all());
|
||||
@@ -80,6 +99,9 @@ class RefreshMailOAuthTokensCommand extends Command
|
||||
'provider' => $connection->provider->label(),
|
||||
'account' => (string) $connection->account_email,
|
||||
]);
|
||||
|
||||
$connection->broken_notified_at = now();
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
$mailConfig->flush();
|
||||
|
||||
@@ -37,6 +37,19 @@ interface MailOAuthBroker
|
||||
*/
|
||||
public function refresh(MailOAuthConnection $connection): void;
|
||||
|
||||
/**
|
||||
* A refresh that is not racing a send: the scheduled health check's
|
||||
* way in, serialised against freshAccessToken() on the same
|
||||
* connection.
|
||||
*
|
||||
* False when it stood aside because somebody else holds the lock, so
|
||||
* a caller reporting to a human can say that rather than claim a
|
||||
* refresh it did not do.
|
||||
*
|
||||
* @throws MailOAuthException
|
||||
*/
|
||||
public function refreshSerially(MailOAuthConnection $connection): bool;
|
||||
|
||||
/**
|
||||
* An access token currently valid for at least a small safety margin,
|
||||
* refreshing first when needed — what transports call at send time.
|
||||
|
||||
@@ -35,6 +35,7 @@ use Illuminate\Support\Carbon;
|
||||
* @property Carbon|null $token_expires_at
|
||||
* @property Carbon|null $last_refreshed_at
|
||||
* @property string|null $last_error
|
||||
* @property Carbon|null $broken_notified_at
|
||||
*/
|
||||
class MailOAuthConnection extends Model
|
||||
{
|
||||
@@ -51,9 +52,27 @@ class MailOAuthConnection extends Model
|
||||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
'last_refreshed_at' => 'datetime',
|
||||
'broken_notified_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure is over: the error and the record of having alarmed
|
||||
* about it go together, because they describe one state.
|
||||
*
|
||||
* One method rather than two nulls at each call site. The three
|
||||
* places that end a failure — a successful refresh, a disconnect, a
|
||||
* changed client id — must never clear one and keep the other: a
|
||||
* connection that is healthy but still marked "already told them"
|
||||
* would go quiet the next time it dies, which is the shape of the
|
||||
* bug this column was added to close.
|
||||
*/
|
||||
public function clearFailure(): void
|
||||
{
|
||||
$this->last_error = null;
|
||||
$this->broken_notified_at = null;
|
||||
}
|
||||
|
||||
public static function for(MailProvider $provider): self
|
||||
{
|
||||
return static::query()->firstOrNew(['provider' => $provider->value]);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Platform\Mail;
|
||||
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
@@ -84,6 +85,53 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
||||
$this->storeTokens($connection, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheduled refresh, holding the same lock a send would.
|
||||
*
|
||||
* freshAccessToken() takes that lock because a refresh token is good
|
||||
* for exactly one use, and it names this command as one of the racers:
|
||||
* "a worker racing the nightly refresh command means the slower one
|
||||
* spends a token the faster one has already replaced", which the
|
||||
* provider answers with an invalid_grant indistinguishable from a
|
||||
* revoked grant. The command was doing its refresh outside the lock,
|
||||
* so it was the other half of that race rather than a party to it.
|
||||
*
|
||||
* Unlike freshAccessToken() this refreshes a token that is still
|
||||
* usable, which is the point of the daily run: a delegated refresh
|
||||
* token dies of disuse, and the refresh keeps the window sliding.
|
||||
*
|
||||
* Taken rather than waited for, unlike the send path: nobody is
|
||||
* standing at a screen here, and a held lock means somebody is
|
||||
* refreshing this very connection right now — which slides the window
|
||||
* and establishes its health just as well as doing it again would.
|
||||
* Spending the token behind them is the false alarm the lock exists to
|
||||
* prevent.
|
||||
*
|
||||
* Returns false in that case, so the scheduled command can report
|
||||
* standing aside instead of announcing a refresh that never happened.
|
||||
*/
|
||||
public function refreshSerially(MailOAuthConnection $connection): bool
|
||||
{
|
||||
$lock = $this->refreshLock($connection);
|
||||
|
||||
if (! $lock->get()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-read first: the winner may have stored tokens while this
|
||||
// call was waiting, and refreshing the copy walked in with
|
||||
// would spend a refresh token that is no longer current.
|
||||
$connection->refresh();
|
||||
|
||||
$this->refresh($connection);
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
public function freshAccessToken(MailOAuthConnection $connection): string
|
||||
{
|
||||
if ($this->stillUsable($connection)) {
|
||||
@@ -104,7 +152,7 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
||||
// re-read the row instead of trusting the copy it walked in with: by
|
||||
// the time the lock is theirs, the winner has already stored a token
|
||||
// they can just use.
|
||||
$lock = Cache::lock('mail-oauth-refresh:'.$connection->provider->value, 30);
|
||||
$lock = $this->refreshLock($connection);
|
||||
|
||||
try {
|
||||
$lock->block(15);
|
||||
@@ -135,6 +183,16 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
||||
}
|
||||
|
||||
/** Whether the stored access token has enough life left to send with. */
|
||||
/**
|
||||
* One refresh at a time per connection, whoever is asking. The TTL
|
||||
* outlives a token request and releases the claim if the holder dies
|
||||
* mid-flight.
|
||||
*/
|
||||
private function refreshLock(MailOAuthConnection $connection): Lock
|
||||
{
|
||||
return Cache::lock('mail-oauth-refresh:'.$connection->provider->value, 30);
|
||||
}
|
||||
|
||||
private function stillUsable(MailOAuthConnection $connection): bool
|
||||
{
|
||||
$token = $connection->access_token;
|
||||
@@ -172,7 +230,7 @@ abstract class OAuthCodeFlowBroker implements MailOAuthBroker
|
||||
}
|
||||
|
||||
$connection->last_refreshed_at = now();
|
||||
$connection->last_error = null;
|
||||
$connection->clearFailure();
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ class PlatformServiceProvider extends ServiceProvider
|
||||
|
||||
return new CapabilityRegistry(
|
||||
$edition instanceof Edition ? $edition : Edition::from($edition),
|
||||
// Read on every resolve rather than once, for the same
|
||||
// reason the edition is: a test that sets it expects the
|
||||
// next resolve to honour it.
|
||||
config('projectsend.capabilities_disabled'),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ use PDOException;
|
||||
* EmailTemplateResolver). A database failure there should stay loud: those
|
||||
* run long after the install, where "quietly fell back to defaults" hides a
|
||||
* real outage instead of enabling a legitimate first run.
|
||||
*
|
||||
* Two entry points, same rule. rememberForever() is for values worth
|
||||
* keeping; read() is for the ones that must not be kept — a credential
|
||||
* read on the boot path needs the identical "the database may not answer
|
||||
* yet" guarantee, and stating it twice is how the two drift apart.
|
||||
*/
|
||||
final class BootSettingsCache
|
||||
{
|
||||
@@ -60,6 +65,33 @@ final class BootSettingsCache
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same protection for a value that is deliberately *not* cached.
|
||||
*
|
||||
* A credential must not sit in the cache store, so it is read on each
|
||||
* boot that actually needs it — but that read lands on the same path
|
||||
* as the cached ones and must survive the same missing database. The
|
||||
* caller has already been handed a cached array saying the feature is
|
||||
* configured; that array can be warm while the database is, right now,
|
||||
* unreachable.
|
||||
*
|
||||
* @template TValue
|
||||
*
|
||||
* @param Closure(): TValue $read Reads the real value from the database.
|
||||
* @param TValue $whenUnavailable Returned as-is when the database cannot answer.
|
||||
* @return TValue
|
||||
*/
|
||||
public static function read(Closure $read, mixed $whenUnavailable = null): mixed
|
||||
{
|
||||
try {
|
||||
return $read();
|
||||
} catch (PDOException $e) {
|
||||
self::warnOnce('(uncached credential read)', $e);
|
||||
|
||||
return $whenUnavailable;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Once per process: an install that has not been migrated yet would
|
||||
* otherwise log this on every artisan command, and a genuine database
|
||||
|
||||
@@ -44,7 +44,13 @@ class ExternalStorageConfigApplier
|
||||
// Bumped on any shape change to the resolved array below — a stale
|
||||
// rememberForever value under an old key would otherwise crash every
|
||||
// boot with "Undefined array key" (apply() calls resolve() unconditionally).
|
||||
private const CACHE_KEY = 'platform.external_storage_settings.v2';
|
||||
// v3: the S3 secret and the GCS key file left the shape. The cache
|
||||
// store encrypts nothing and rememberForever never expires, so on the
|
||||
// documented CACHE_STORE=database they sat in clear — the service
|
||||
// account's private key included — in the same database whose dump
|
||||
// the `encrypted` cast exists to survive. Both are now read straight
|
||||
// from the row, by the provider branch that uses them.
|
||||
private const CACHE_KEY = 'platform.external_storage_settings.v3';
|
||||
|
||||
public function __construct(
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
@@ -67,7 +73,9 @@ class ExternalStorageConfigApplier
|
||||
|
||||
match ($provider) {
|
||||
StorageProvider::S3 => $this->applyS3($resolved),
|
||||
StorageProvider::Gcs => $this->applyGcs($resolved),
|
||||
// No $resolved: everything GCS needs from the row is the key
|
||||
// file, and that is a credential the cache no longer holds.
|
||||
StorageProvider::Gcs => $this->applyGcs(),
|
||||
};
|
||||
|
||||
if ($resolved['root'] !== null) {
|
||||
@@ -86,22 +94,22 @@ class ExternalStorageConfigApplier
|
||||
private function applyS3(array $resolved): void
|
||||
{
|
||||
Config::set('filesystems.disks.files_external.key', $resolved['key']);
|
||||
Config::set('filesystems.disks.files_external.secret', $resolved['secret']);
|
||||
Config::set('filesystems.disks.files_external.secret', $this->credential('secret'));
|
||||
Config::set('filesystems.disks.files_external.region', $resolved['region']);
|
||||
Config::set('filesystems.disks.files_external.endpoint', $resolved['endpoint']);
|
||||
Config::set('filesystems.disks.files_external.use_path_style_endpoint', $resolved['use_path_style']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $resolved
|
||||
*/
|
||||
private function applyGcs(array $resolved): void
|
||||
private function applyGcs(): void
|
||||
{
|
||||
// Decoded here rather than stored decoded: the column holds the
|
||||
// key file verbatim, exactly as Google issued it, so that what an
|
||||
// administrator pasted is what can be handed back to them and
|
||||
// compared against the console.
|
||||
$keyFile = json_decode((string) $resolved['key_file'], true);
|
||||
//
|
||||
// Read from the row rather than from $resolved: it is a private
|
||||
// key, and the cached array no longer carries one.
|
||||
$keyFile = json_decode((string) $this->credential('key_file'), true);
|
||||
|
||||
Config::set('filesystems.disks.files_external.key_file', is_array($keyFile) ? $keyFile : null);
|
||||
|
||||
@@ -118,6 +126,28 @@ class ExternalStorageConfigApplier
|
||||
Cache::forget(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* One credential column, read from the row rather than from the cache.
|
||||
*
|
||||
* The same rule MailOAuthConnection states for tokens — "must never
|
||||
* travel through the boot-config cache" — applied to the two columns
|
||||
* on this row that are credentials: the S3 secret access key and the
|
||||
* GCS service account key file. Reached only from the provider branch
|
||||
* that uses one, and only when isActive() has already said the disk is
|
||||
* configured and permitted, so nothing is read on an installation that
|
||||
* stores files locally.
|
||||
*
|
||||
* Guarded like the cached read beside it: resolve() can hand back a
|
||||
* warm "configured" from a database that has since stopped answering,
|
||||
* and booting must survive that.
|
||||
*/
|
||||
private function credential(string $column): ?string
|
||||
{
|
||||
return BootSettingsCache::read(
|
||||
fn (): ?string => ExternalStorageSettings::current()->{$column},
|
||||
);
|
||||
}
|
||||
|
||||
public function resolveDisk(ResolvingUploadDisk $event): void
|
||||
{
|
||||
if ($this->isActive()) {
|
||||
@@ -142,14 +172,14 @@ class ExternalStorageConfigApplier
|
||||
* filled in and active, nothing more. Callers AND the capability check
|
||||
* live and uncached — see class docblock.
|
||||
*
|
||||
* @return array{configured: bool, provider: string, key: string|null, secret: string|null, key_file: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
||||
* @return array{configured: bool, provider: string, key: string|null, region: string|null, bucket: string|null, endpoint: string|null, use_path_style: bool, root: string|null}
|
||||
*/
|
||||
private function resolve(): array
|
||||
{
|
||||
$blank = [
|
||||
'configured' => false,
|
||||
'provider' => StorageProvider::S3->value,
|
||||
'key' => null, 'secret' => null, 'key_file' => null,
|
||||
'key' => null,
|
||||
'region' => null, 'bucket' => null,
|
||||
'endpoint' => null, 'use_path_style' => false, 'root' => null,
|
||||
];
|
||||
@@ -173,8 +203,6 @@ class ExternalStorageConfigApplier
|
||||
'configured' => true,
|
||||
'provider' => $settings->provider->value,
|
||||
'key' => $settings->key,
|
||||
'secret' => $settings->secret,
|
||||
'key_file' => $settings->key_file,
|
||||
'region' => $settings->region,
|
||||
'bucket' => $settings->bucket,
|
||||
'endpoint' => $settings->endpoint,
|
||||
|
||||
@@ -42,7 +42,13 @@ class MailConfigApplier
|
||||
// connection row at send time — only readiness and the connected
|
||||
// address are cheap enough to be worth caching, and neither is a
|
||||
// credential.
|
||||
private const CACHE_KEY = 'platform.mail_provider_settings.v3';
|
||||
// v4: the SMTP password left for the same reason the tokens never
|
||||
// arrived. The cache store encrypts nothing and rememberForever never
|
||||
// expires, so on the documented CACHE_STORE=database it wrote the
|
||||
// password in clear into the same database whose dump the `encrypted`
|
||||
// cast exists to survive. It is now read straight from the row, and
|
||||
// only on the boot that actually configures an SMTP transport.
|
||||
private const CACHE_KEY = 'platform.mail_provider_settings.v4';
|
||||
|
||||
public function __construct(
|
||||
private readonly CapabilityRegistry $capabilities,
|
||||
@@ -67,7 +73,7 @@ class MailConfigApplier
|
||||
Config::set('mail.mailers.smtp.host', $resolved['host']);
|
||||
Config::set('mail.mailers.smtp.port', $resolved['port']);
|
||||
Config::set('mail.mailers.smtp.username', $resolved['username']);
|
||||
Config::set('mail.mailers.smtp.password', $resolved['password']);
|
||||
Config::set('mail.mailers.smtp.password', $this->password());
|
||||
Config::set('mail.mailers.smtp.encryption', $resolved['encryption']);
|
||||
}
|
||||
|
||||
@@ -86,13 +92,33 @@ class MailConfigApplier
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{transport_configured: bool, host: string|null, port: int|null, username: string|null, password: string|null, encryption: string|null, from_address: string|null, from_name: string|null, oauth_mailer: string|null, oauth_ready: bool, oauth_account: string|null}
|
||||
* The SMTP password, read from the row rather than from the cache.
|
||||
*
|
||||
* The same rule MailOAuthConnection states for tokens — "must never
|
||||
* travel through the boot-config cache" — applied to the credential
|
||||
* this class configures itself. Reached only from the SMTP branch of
|
||||
* apply(), so an installation using OAuth, or one that has never
|
||||
* opened the Email screen, still boots without touching the table.
|
||||
*
|
||||
* Guarded like the cached read beside it: resolve() can hand back a
|
||||
* warm "transport_configured" from a database that has since stopped
|
||||
* answering, and booting must survive that.
|
||||
*/
|
||||
private function password(): ?string
|
||||
{
|
||||
return BootSettingsCache::read(
|
||||
fn (): ?string => MailProviderSettings::current()->password,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{transport_configured: bool, host: string|null, port: int|null, username: string|null, encryption: string|null, from_address: string|null, from_name: string|null, oauth_mailer: string|null, oauth_ready: bool, oauth_account: string|null}
|
||||
*/
|
||||
private function resolve(): array
|
||||
{
|
||||
$blank = [
|
||||
'transport_configured' => false,
|
||||
'host' => null, 'port' => null, 'username' => null, 'password' => null, 'encryption' => null,
|
||||
'host' => null, 'port' => null, 'username' => null, 'encryption' => null,
|
||||
'from_address' => null, 'from_name' => null,
|
||||
'oauth_mailer' => null, 'oauth_ready' => false, 'oauth_account' => null,
|
||||
];
|
||||
@@ -130,7 +156,6 @@ class MailConfigApplier
|
||||
'host' => $settings->host,
|
||||
'port' => $settings->port,
|
||||
'username' => $settings->username,
|
||||
'password' => $settings->password,
|
||||
'encryption' => $settings->encryption === 'none' ? null : $settings->encryption,
|
||||
'from_address' => $settings->from_address,
|
||||
'from_name' => $settings->from_name,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Whether a request is on the API rather than on the web site.
|
||||
*
|
||||
* Two questions used to answer this, and both answer something else. A
|
||||
* path test alone (`api/*`) is wrong because two staff *pages* live under
|
||||
* that prefix — the API dashboard at /api and the OpenAPI reference at
|
||||
* /api/docs, both registered in routes/web.php — and their errors belong
|
||||
* to the web site: a signed-out visitor to /api/docs wants the login
|
||||
* redirect, not a 401 telling them to send a Bearer token. An
|
||||
* `expectsJson()` test is wrong because the Accept header is the caller's
|
||||
* preference, not a property of the route: whether an endpoint exists in
|
||||
* this edition cannot depend on what the caller is willing to parse.
|
||||
*
|
||||
* So: under the API prefix, and not part of the `web` middleware group.
|
||||
* The group is what actually separates the two — sessions, cookies and
|
||||
* CSRF on one side, tokens on the other — and it stays right for a future
|
||||
* /api/v2 without this being edited.
|
||||
*
|
||||
* An unmatched path has no route to ask, and that is the API's answer:
|
||||
* a request to a URL under the API prefix that resolves to nothing is a
|
||||
* 404 the API should describe in its own error format.
|
||||
*/
|
||||
class ApiSurface
|
||||
{
|
||||
public static function matches(Request $request): bool
|
||||
{
|
||||
if (! $request->is('api/*')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! in_array('web', $request->route()?->middleware() ?? [], true);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,33 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
// Every credential this application stores encrypted, named again
|
||||
// here so a failed validation does not write it back out in clear.
|
||||
//
|
||||
// A ValidationException flashes the request's input into the
|
||||
// session so the form can be repopulated, minus this list. The
|
||||
// framework's own three cover the login and password forms; none
|
||||
// of the settings screens' credentials were on it, and
|
||||
// config/session.php stores sessions in the database by default
|
||||
// with `encrypt => false`. So a mistyped storage form put the
|
||||
// secret access key in clear into the same database whose dump the
|
||||
// `encrypted` cast exists to survive — and a service account key
|
||||
// file, which is most likely to fail validation exactly when it
|
||||
// was pasted incompletely, put a private key there.
|
||||
//
|
||||
// Merged with the framework's defaults rather than replacing them.
|
||||
// The cost is that these fields come back blank after a failed
|
||||
// save, which is what every one of these screens already does on a
|
||||
// successful one: they are write-only, and a blank means "keep
|
||||
// what is stored".
|
||||
$exceptions->dontFlash([
|
||||
'secret', // ExternalStorageSettingsController (S3)
|
||||
'key_file', // ExternalStorageSettingsController (GCS)
|
||||
'bind_password', // LdapSettingsController
|
||||
'client_secret', // SocialLoginSettingsController, EmailSettingsController
|
||||
'secret_key', // CaptchaSettingsController
|
||||
]);
|
||||
|
||||
// RFC 7807 for /api/* only. Everything else — web pages, Inertia
|
||||
// requests, the public share links — keeps Laravel's own handling
|
||||
// untouched, which is why this is scoped by path rather than by
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Modules\Files\FilesServiceProvider;
|
||||
use App\Modules\Groups\GroupsServiceProvider;
|
||||
use App\Modules\Identity\IdentityServiceProvider;
|
||||
use App\Modules\Notifications\NotificationsServiceProvider;
|
||||
use App\Modules\Platform\Branding\BrandingServiceProvider;
|
||||
use App\Modules\Platform\PlatformServiceProvider;
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
@@ -19,5 +20,6 @@ return [
|
||||
IdentityServiceProvider::class,
|
||||
NotificationsServiceProvider::class,
|
||||
PlatformServiceProvider::class,
|
||||
BrandingServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
|
||||
+16
-1
@@ -62,12 +62,27 @@ return [
|
||||
// umask 0077 still produces 0700 and still cannot be
|
||||
// traversed; INSTALL.md covers fixing that, because it cannot
|
||||
// be fixed from this file.
|
||||
//
|
||||
// Both directory keys, because which one Flysystem reads is
|
||||
// decided elsewhere: FilesystemManager passes
|
||||
// `directory_visibility ?? visibility ?? private` as the
|
||||
// default visibility for directories, so with `visibility`
|
||||
// public just below, it reads `dir.public` and never looks at
|
||||
// `dir.private`. Naming only the private one asked for 0755
|
||||
// from a key nobody consults, and got 0755 anyway because that
|
||||
// is Flysystem's default for a public directory — the right
|
||||
// answer from the wrong place, which is the kind that stops
|
||||
// being right quietly. Adding `directory_visibility` here, or
|
||||
// a change to that default, would have been enough.
|
||||
// Spread rather than two ternaries so that leaving the flag
|
||||
// off is not merely equivalent to the old configuration but
|
||||
// literally it — no install that does not need this sees its
|
||||
// file modes change.
|
||||
...(env('FILES_WEB_SERVER_READABLE', false)
|
||||
? ['visibility' => 'public', 'permissions' => ['dir' => ['private' => 0755]]]
|
||||
? [
|
||||
'visibility' => 'public',
|
||||
'permissions' => ['dir' => ['public' => 0755, 'private' => 0755]],
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
|
||||
+44
-1
@@ -49,6 +49,27 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Capabilities this installation has been told it may not use
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Comma-separated capability keys, subtracted from what the edition
|
||||
| grants. Only ever subtracted: nothing here can switch a capability on,
|
||||
| because a variable that could would put the hosted edition's screens
|
||||
| one line of .env away on every self-hosted install.
|
||||
|
|
||||
| For a managed installation whose plan does not include something its
|
||||
| edition otherwise has -- branding.customize on a free plan is the case
|
||||
| this was built for. Unknown keys are ignored rather than fatal: the
|
||||
| variable outlives both the plan that wrote it and the release that
|
||||
| named the key, and an instance refusing to boot over a stale one would
|
||||
| be an outage on upgrade day.
|
||||
|
|
||||
*/
|
||||
|
||||
'capabilities_disabled' => env('PROJECTSEND_CAPABILITIES_DISABLED'),
|
||||
|
||||
'platform' => [
|
||||
'max_staff_users' => env('PROJECTSEND_PLATFORM_MAX_STAFF_USERS'),
|
||||
'max_clients' => env('PROJECTSEND_PLATFORM_MAX_CLIENTS'),
|
||||
@@ -65,6 +86,28 @@ return [
|
||||
'parts_path' => env('UPLOAD_PARTS_PATH'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| How downloads leave the server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Uploads live outside the web root, so PHP authorizes every download
|
||||
| before any byte moves. What differs is what happens next: PHP can
|
||||
| stream the file itself, or hand the web server a header naming the
|
||||
| file and let it do the work. The header is faster and each server
|
||||
| spells it differently — a server that does not recognise the one it
|
||||
| is sent serves the empty body instead, which is a 0-byte download.
|
||||
|
|
||||
| 'auto' (the default) uses nginx's X-Accel-Redirect when the server
|
||||
| says it is nginx, and PHP streaming otherwise. 'nginx', 'xsendfile'
|
||||
| (Apache with mod_xsendfile, or LiteSpeed) and 'php' state it
|
||||
| outright. Read here rather than through env() elsewhere, so that
|
||||
| `php artisan config:cache` does not silently blank it.
|
||||
|
|
||||
*/
|
||||
|
||||
'file_delivery' => env('PROJECTSEND_FILE_DELIVERY', 'auto'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Chunked upload part size (MB)
|
||||
@@ -118,7 +161,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => '2.2.1',
|
||||
'version' => '2.3.0',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// A single-row table: one logo per install. Under the planned
|
||||
// DB-per-tenant model each cloud tenant has its own database, so
|
||||
// "one row" already means "one per tenant" — no tenant/owner
|
||||
// column needed.
|
||||
Schema::create('branding_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('logo_path')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('branding_settings');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Watermarking rides on the same single row as the sidebar logo:
|
||||
// it is part of the same Cloud-exclusive Branding capability, and
|
||||
// an install either brands itself or does not. Its image is stored
|
||||
// separately from `logo_path` on purpose — a logo drawn on a light
|
||||
// sidebar and a mark stamped over arbitrary photographs are
|
||||
// different artwork, and installs that want both want two files.
|
||||
Schema::table('branding_settings', function (Blueprint $table) {
|
||||
$table->boolean('watermark_enabled')->default(false);
|
||||
$table->string('watermark_path')->nullable();
|
||||
$table->string('watermark_position', 20)->default('bottom-right');
|
||||
|
||||
// Both percentages, not pixels: a thumbnail is bounded to 300px
|
||||
// on its longest side but its actual size depends on the
|
||||
// original's aspect ratio, so an absolute width would land
|
||||
// differently on a portrait than on a landscape.
|
||||
$table->unsignedTinyInteger('watermark_size')->default(30);
|
||||
$table->unsignedTinyInteger('watermark_opacity')->default(60);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('branding_settings', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'watermark_enabled',
|
||||
'watermark_path',
|
||||
'watermark_position',
|
||||
'watermark_size',
|
||||
'watermark_opacity',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// The last piece of white-labelling: replacing the logo but
|
||||
// leaving "Powered by ProjectSend" under every client's file
|
||||
// list only half-does the job this capability sells. Rides on
|
||||
// the same single row as the logo and the watermark for the
|
||||
// same reason they ride together — an install either brands
|
||||
// itself or does not.
|
||||
//
|
||||
// Phrased as "hide" rather than "show" so the default is false
|
||||
// and every existing row keeps naming ProjectSend without a
|
||||
// backfill.
|
||||
Schema::table('branding_settings', function (Blueprint $table) {
|
||||
$table->boolean('hide_attribution')->default(false);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('branding_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('hide_attribution');
|
||||
});
|
||||
}
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// `last_error` was answering two questions at once — the table's
|
||||
// own comment says so: "what the settings page's warning and the
|
||||
// admin notification read". The warning wants "is this connection
|
||||
// broken", and any writer may answer it; the notification wants
|
||||
// "have the admins been told", which only the notifier can.
|
||||
//
|
||||
// They came apart because the send path writes last_error too
|
||||
// (OAuthCodeFlowBroker::refresh, reached from freshAccessToken).
|
||||
// On an installation that actually sends mail, that write lands
|
||||
// first, and the daily command then read it as "already notified"
|
||||
// and stayed silent forever.
|
||||
//
|
||||
// Cleared wherever last_error is cleared, and only there:
|
||||
// a successful refresh, a disconnect, and a changed client id.
|
||||
Schema::table('mail_oauth_connections', function (Blueprint $table) {
|
||||
$table->timestamp('broken_notified_at')->nullable()->after('last_error');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mail_oauth_connections', function (Blueprint $table) {
|
||||
$table->dropColumn('broken_notified_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* The two indexes every windowed count over `activity_log` needs.
|
||||
*
|
||||
* **They ship as a pair, and adding only the second one is a
|
||||
* regression.** That is the whole reason this comment is long.
|
||||
*
|
||||
* Measured on 2.1M rows (MySQL 8.4, two years of history, ~800k of them
|
||||
* downloads), which is a mid-size installation and not a stress test:
|
||||
*
|
||||
* | query | before | after |
|
||||
* |-----------------------------------------|--------|---------|
|
||||
* | last staff/client sign-in | 0.63s | 0.0004s |
|
||||
* | downloads in the last 30 days | 1.07s | 0.022s |
|
||||
* | uploads in the last 30 days | 0.42s | 0.005s |
|
||||
*
|
||||
* ### Why a date window was *slower* than no date window
|
||||
*
|
||||
* Counting every download ever took 0.46s; counting the last 30 days of
|
||||
* them took 1.07s. Not a mistake: with only the single-column indexes,
|
||||
* the planner picks `created_at`, reaches the window, and then has to do
|
||||
* a primary-key lookup on each row to read `action`. The unbounded count
|
||||
* stays inside the `action` index and never touches a row. So the naive
|
||||
* "just add a date filter" made the query cost more, which is the
|
||||
* opposite of what anybody writing it expects.
|
||||
*
|
||||
* ### Why (action, created_at) alone is not the fix
|
||||
*
|
||||
* It fixes the windowed counts and breaks the query
|
||||
* `projectsend:status` already runs on every tenant, every hour:
|
||||
* `last_staff_login_at` goes from **0.63s to 7.7s**, reproduced. The
|
||||
* planner switches to the new index, still needs `actor_type` — which is
|
||||
* not in it — and does a primary-key lookup per row; and because the
|
||||
* scan is now ordered by `created_at` rather than by id, those lookups
|
||||
* are scattered instead of sequential.
|
||||
*
|
||||
* That is why (action, actor_type, created_at) is here too, and why
|
||||
* dropping it as "redundant, the two-column one covers it" is exactly
|
||||
* the change that would put the regression back. It is not redundant:
|
||||
* it is the only one of the two that answers a question filtered by
|
||||
* actor without reading rows.
|
||||
*
|
||||
* ### Cost
|
||||
*
|
||||
* About 170 MB of index at 2.1M rows, against a 228 MB primary key.
|
||||
* Real, and bought with a 1500x improvement on a query that was already
|
||||
* running hourly before any of the new reporting existed.
|
||||
*
|
||||
* On MySQL both are added in place, so an existing installation stays
|
||||
* readable and writable while it happens — but on a large `activity_log`
|
||||
* it is minutes, not seconds, and it is the slowest part of the upgrade
|
||||
* that carries it.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('activity_log', function (Blueprint $table) {
|
||||
// "What did this kind of account do, and when did they last
|
||||
// do it" — both sign-in timestamps, and the download split.
|
||||
$table->index(['action', 'actor_type', 'created_at'], 'activity_log_action_actor_created_index');
|
||||
|
||||
// "How many of these happened in the window", for actions
|
||||
// nobody is narrowing by actor.
|
||||
$table->index(['action', 'created_at'], 'activity_log_action_created_index');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('activity_log', function (Blueprint $table) {
|
||||
$table->dropIndex('activity_log_action_actor_created_index');
|
||||
$table->dropIndex('activity_log_action_created_index');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -147,6 +147,43 @@ VOLUME ["/var/www/html/storage"]
|
||||
# an environment variable survives that.
|
||||
ENV PROJECTSEND_IMAGE=1
|
||||
|
||||
# This is a production image, so it says so itself.
|
||||
#
|
||||
# The entrypoint seeds storage/.env from .env.example on first boot when no
|
||||
# .env exists yet, and .env.example is the development template:
|
||||
# APP_ENV=local, APP_DEBUG=true. compose.example.yaml sets both correctly,
|
||||
# so the documented way to run this was never affected -- but `docker run`
|
||||
# with nothing but a database address, a Portainer/unRAID/TrueNAS template,
|
||||
# or a Kubernetes manifest naming only DB/Redis/APP_URL, all quietly got a
|
||||
# debug build.
|
||||
#
|
||||
# Two consequences an operator would not expect and cannot see from the
|
||||
# outside:
|
||||
#
|
||||
# - APP_DEBUG=true renders Laravel's exception page -- stack trace,
|
||||
# file, surrounding source -- to whoever triggered the 500, signed in
|
||||
# or not. php.ini's display_errors=Off does not prevent it: Laravel
|
||||
# renders that page itself.
|
||||
# - PasswordPolicy appends ->uncompromised() only when
|
||||
# app()->isProduction(), so on APP_ENV=local an administrator's
|
||||
# "reject known-breached passwords" never ran, while descriptor() went
|
||||
# on advertising it and the security settings screen went on showing
|
||||
# it as active.
|
||||
#
|
||||
# Set here rather than in the seeded .env so that an installation already
|
||||
# running on a stale .env is fixed by pulling the image, not only a fresh
|
||||
# one.
|
||||
#
|
||||
# What this does and does not outrank. Laravel builds its env repository
|
||||
# immutable (Illuminate\Support\Env), so a real environment variable wins
|
||||
# over the .env file. `docker run -e`, compose `environment:` and a
|
||||
# Kubernetes `env:` all set real environment variables and therefore still
|
||||
# win over these -- an operator who asks for something explicitly gets it.
|
||||
# Editing APP_ENV or APP_DEBUG *inside* storage/.env no longer takes
|
||||
# effect, because these are real environment variables and that file is
|
||||
# not; `-e APP_DEBUG=true` is the way to turn debug on deliberately.
|
||||
ENV APP_ENV=production APP_DEBUG=false
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Laravel's health route (bootstrap/app.php: health: '/up'). Hitting it
|
||||
|
||||
@@ -17,7 +17,23 @@ services:
|
||||
# Put a TLS-terminating proxy in front of this in any real install.
|
||||
# ProjectSend issues download links and password-reset emails using
|
||||
# APP_URL, so that value — not this port — is what users must reach.
|
||||
- "8080:80"
|
||||
#
|
||||
# Bound to the loopback address, not to every interface, because
|
||||
# TRUSTED_PROXIES below is "*". That setting tells the application to
|
||||
# believe the X-Forwarded-For header of whoever connects to it, which
|
||||
# is correct behind a proxy and catastrophic when anybody can connect
|
||||
# directly: a visitor who reaches this port themselves is then the
|
||||
# "proxy", and can hand the application any client IP they like —
|
||||
# which is enough to walk straight through the login lockout, every
|
||||
# named rate limit, and the address recorded in the download log.
|
||||
#
|
||||
# Publishing on the loopback address keeps the proxy (on this host,
|
||||
# or in this compose file) able to reach it while nothing off the
|
||||
# machine can. If you move the proxy to another host, publish on the
|
||||
# interface it comes from and narrow TRUSTED_PROXIES to that address
|
||||
# or subnet at the same time — the two settings only make sense
|
||||
# together.
|
||||
- "127.0.0.1:8080:80"
|
||||
environment:
|
||||
APP_URL: https://files.example.com
|
||||
APP_ENV: production
|
||||
@@ -55,6 +71,10 @@ services:
|
||||
# Without it every visitor appears to come from the proxy: the login
|
||||
# rate limiter treats all of your users as one attacker, and the
|
||||
# download log records the proxy's address.
|
||||
#
|
||||
# "*" means "trust whoever connects to me", which is only safe when
|
||||
# nothing but the proxy can — which is what the loopback binding
|
||||
# above is for. Change one and you have to change the other.
|
||||
TRUSTED_PROXIES: "*"
|
||||
|
||||
# Optional: uncomment these — with a password of your own — to create
|
||||
|
||||
@@ -40,7 +40,10 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Put a TLS-terminating proxy in front of this in any real install.
|
||||
- "8080:80"
|
||||
# Bound to loopback because TRUSTED_PROXIES below is "*": the
|
||||
# application then believes the X-Forwarded-For of whoever connects,
|
||||
# so nobody but the proxy may be able to.
|
||||
- "127.0.0.1:8080:80"
|
||||
environment:
|
||||
APP_URL: https://files.example.com
|
||||
APP_ENV: production
|
||||
@@ -58,6 +61,8 @@ services:
|
||||
|
||||
# Required whenever anything sits between your visitors and this
|
||||
# container — including the reverse proxy you should be running.
|
||||
# "*" trusts whoever connects, so it goes together with the loopback
|
||||
# binding above: change one and you have to change the other.
|
||||
TRUSTED_PROXIES: "*"
|
||||
|
||||
# Optional: uncomment these — with a password of your own — to create
|
||||
|
||||
@@ -1068,7 +1068,7 @@
|
||||
"/comments/pending": {
|
||||
"get": {
|
||||
"operationId": "comments.pending",
|
||||
"description": "Scoped by the same library boundary as everything else: a\nclient-scoped token sees pending comments only on files its owner\ncould already open. Oldest first, so working through the list means\nworking through the backlog.\n\nRequires a token with the ability: `moderate_comments`.",
|
||||
"description": "Scoped by the same file boundary as everything else \u2014 the whole of\nit, not just its library half: a client-scoped token sees pending\ncomments only on files its owner could already open, and a token\nwhose owner holds no file key at all sees none. Oldest first, so\nworking through the list means working through the backlog.\n\nRequires a token with the ability: `moderate_comments`.",
|
||||
"summary": "List comments awaiting approval",
|
||||
"tags": [
|
||||
"CommentModeration"
|
||||
@@ -2076,7 +2076,7 @@
|
||||
},
|
||||
"patch": {
|
||||
"operationId": "files.update",
|
||||
"description": "Only the fields present in the request are changed; omitting one\nleaves it as it was.\n\nSome fields need a permission of their own \u2014 `expires_at` needs\n`set_file_expiration_date`, `public` needs `upload_public`, and\n`categories` needs `set_file_categories`. Sending one of those\nwithout the matching permission leaves that field untouched rather\nthan failing the whole request, which mirrors the web interface.\n\n`commentable` only has an effect while the installation's comment\nsetting is \"only files marked as commentable\"; under any other\nsetting it is ignored, again rather than failing.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.",
|
||||
"description": "Only the fields present in the request are changed; omitting one\nleaves it as it was.\n\nSome fields need a permission of their own \u2014 `expires_at` needs\n`set_file_expiration_date`, `public` needs `upload_public`, and\n`categories` needs `set_file_categories`. Sending one of those\nwithout the matching permission leaves that field untouched rather\nthan failing the whole request, which mirrors the web interface.\n\n`expires_at` accepts either a calendar day (`2026-09-12`) or a full\ntimestamp. A day means the end of that day in the caller's timezone,\nwhich is what the same value means on the web and what the file's\nown `expires_at` reads back as; a timestamp is taken as the instant\nit names.\n\n`commentable` only has an effect while the installation's comment\nsetting is \"only files marked as commentable\"; under any other\nsetting it is ignored, again rather than failing.\n\nRequires a token with any of these abilities: `edit_files`, `edit_others_files`.",
|
||||
"summary": "Update a file's metadata",
|
||||
"tags": [
|
||||
"Files"
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "S'ha creat un compte de client nou: :name (:email).",
|
||||
"A new client has registered": "S'ha registrat un client nou",
|
||||
"A new ProjectSend version is available": "Hi ha disponible una versió nova del ProjectSend",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "S'ha enviat un enllaç de verificació nou a l'adreça de correu que vas indicar en registrar-te.",
|
||||
"A new verification link has been sent to your email address.": "S'ha enviat un enllaç de verificació nou a la teva adreça de correu.",
|
||||
"A public link was created": "S'ha creat un enllaç públic",
|
||||
"A public link was revoked": "S'ha revocat un enllaç públic",
|
||||
"A role was created": "S'ha creat un rol",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Trieu Imatge",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Tria quines notificacions també t'arriben per correu. Tot apareix sempre a la campana de notificacions.",
|
||||
"Clear": "Neteja",
|
||||
"Click here to re-send the verification email.": "Fes clic aquí per tornar a enviar el correu de verificació.",
|
||||
"Click to copy": "Feu clic per copiar",
|
||||
"Client": "Client",
|
||||
"Client account approved": "Compte de client aprovat",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Comenta",
|
||||
"Confirm": "Confirmar",
|
||||
"Confirm password": "Confirma la contrasenya",
|
||||
"Confirm your password": "Confirma la teva contrasenya",
|
||||
"Conflict": "Conflicte",
|
||||
"Connect": "Connecta't",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Connecta un bucket extern — compatible amb S3 (AWS S3, MinIO, Backblaze) o Google Cloud Storage — com a emmagatzematge de les càrregues noves.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "Ha creat la carpeta \":subject\"",
|
||||
"Created the group \":subject\"": "Ha creat el grup \":subject\"",
|
||||
"Created the role \":subject\"": "Ha creat el rol \":subject\"",
|
||||
"Current password": "Contrasenya actual",
|
||||
"Custom": "Personalitzat",
|
||||
"Custom assets": "Recursos personalitzats",
|
||||
"Custom fields": "Camps personalitzats",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Edició",
|
||||
"Email": "Correu",
|
||||
"Email address": "Adreça de correu",
|
||||
"Email password reset link": "Envia l'enllaç per restablir la contrasenya",
|
||||
"Email settings": "Configuració del correu",
|
||||
"Email template reset to default.": "Plantilla de correu restablerta al text per defecte.",
|
||||
"Email template saved.": "Plantilla de correu desada.",
|
||||
"Email templates": "Plantilles de correu",
|
||||
"Email verification": "Verificació del correu",
|
||||
"Empty file": "Fitxer buit",
|
||||
"Enable": "Habilitar",
|
||||
"Enable the public directory page": "Activa la pàgina de directori públic",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "El fitxer d'entorn xifrat ja existeix.",
|
||||
"Encrypted environment file not found.": "No s'ha trobat el fitxer d'entorn xifrat.",
|
||||
"Encryption": "Xifratge",
|
||||
"Ensure your account is using a long, random password to stay secure": "Assegura't que el teu compte utilitza una contrasenya llarga i aleatòria per mantenir-lo segur",
|
||||
"Enter one of your recovery codes.": "Introdueix un dels teus codis de recuperació.",
|
||||
"Enter the six-digit code from your authenticator app.": "Introdueix el codi de sis xifres de la teva aplicació d'autenticació.",
|
||||
"Enter your email and password below to log in": "Introdueix a sota el teu correu i la teva contrasenya per iniciar la sessió",
|
||||
"Enter your email to receive a password reset link": "Introdueix el teu correu per rebre un enllaç per restablir la contrasenya",
|
||||
"Environment": "Entorn",
|
||||
"Environment file already exists.": "El fitxer d'entorn ja existeix.",
|
||||
"Environment file not found.": "No s'ha trobat el fitxer d'entorn.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Les carpetes només es poden compartir amb clients o grups.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Les carpetes no es poden imbricar més de :max nivells.",
|
||||
"Forbidden": "Prohibit",
|
||||
"Forgot password": "Has oblidat la contrasenya",
|
||||
"Forgot password?": "Has oblidat la contrasenya?",
|
||||
"Found": "Trobat",
|
||||
"From": "De",
|
||||
"From address": "Adreça del remitent",
|
||||
"From name": "Nom del remitent",
|
||||
"Full name": "Nom complet",
|
||||
"Gateway Timeout": "Temps d'espera de la passarel·la",
|
||||
"General": "General",
|
||||
"General settings": "Configuració general",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Tancat",
|
||||
"Log in": "Inicia la sessió",
|
||||
"Log In": "Entrar",
|
||||
"log in": "l'inici de sessió",
|
||||
"Log in to your account": "Inicia la sessió al teu compte",
|
||||
"Log out": "Tanca la sessió",
|
||||
"Log Out": "Sortir",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Obre el llistat públic",
|
||||
"Options": "Opcions",
|
||||
"Or enter this key manually:": "O bé introdueix aquesta clau manualment:",
|
||||
"Or, return to": "O torna a",
|
||||
"Orange": "Taronja",
|
||||
"Origin Is Unreachable": "L'origen és inabastable",
|
||||
"Outgoing email": "Correu de sortida",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Contingut parcial",
|
||||
"Password": "Contrasenya",
|
||||
"Password reset": "Restabliment de la contrasenya",
|
||||
"Password settings": "Configuració de la contrasenya",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Enganxa el fitxer de clau JSON d'un compte de servei amb accés de lectura i escriptura als objectes del bucket. Es desa xifrat i no es torna a mostrar.",
|
||||
"Path": "Camí",
|
||||
"Payload Too Large": "Càrrega útil massa gran",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Rosa",
|
||||
"Platform": "Plataforma",
|
||||
"Please click the button below to verify your email address.": "Si us plau, feu clic al botó inferior per verificar la vostra adreça electrònica.",
|
||||
"Please enter your new password below": "Introdueix la teva contrasenya nova a continuació",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Verifica la teva adreça de correu fent clic a l'enllaç que t'acabem d'enviar.",
|
||||
"Port": "Port",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Funciona amb ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Privat",
|
||||
"Processing": "Processament",
|
||||
"Profile": "Perfil",
|
||||
"Profile information": "Informació del perfil",
|
||||
"Profile information was updated": "S'ha actualitzat la informació del perfil",
|
||||
"Profile settings": "Configuració del perfil",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "El ProjectSend :latestVersion està disponible (tu tens la :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend és programari lliure i de codi obert per enviar fitxers als teus clients. Es publica sota la :license, cosa que significa que ets lliure d'usar-lo, estudiar-lo, modificar-lo i compartir-lo.",
|
||||
"ProjectSend is ready": "El ProjectSend està a punt",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Les adhesions sol·licitades esperen l'aprovació a la cua de sol·licituds d'adhesió.",
|
||||
"Require two-factor authentication": "Exigeix l'autenticació de dos factors",
|
||||
"Required": "Obligatori",
|
||||
"Resend verification email": "Torna a enviar el correu de verificació",
|
||||
"Reset Content": "Restablir contingut",
|
||||
"Reset Password": "Restablir contrasenya",
|
||||
"Reset password": "Restableix la contrasenya",
|
||||
"Reset Password Notification": "Notificació de restabliment de contrasenya",
|
||||
"Reset this email to its default wording?": "Vols restablir el text per defecte d'aquest correu?",
|
||||
"Reset to default": "Restableix els valors per defecte",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": "Estalvia :name",
|
||||
"Save & Close": "Desa i tanca",
|
||||
"Save & Return": "Guarda i torna",
|
||||
"Save password": "Desa la contrasenya",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Desa els canvis per publicar-ho; fins llavors, aquests botons apunten a una pàgina que encara no s'ha publicat.",
|
||||
"Saved": "Desat",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Escaneja aquest codi QR amb la teva aplicació d'autenticació i introdueix el codi de sis xifres per confirmar-ho.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Tema actualitzat.",
|
||||
"Theming": "Temes",
|
||||
"Theming settings": "Configuració dels temes",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "Aquest compte inicia la sessió mitjançant el teu directori, així que la contrasenya no es configura aquí. Consulta un administrador si no pots iniciar la sessió.",
|
||||
"This action is unauthorized.": "Aquesta acció no està autoritzada.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Aquest fitxer supera la mida màxima permesa de :max MB.",
|
||||
"This folder is empty.": "Aquesta carpeta és buida.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "Aquest és un rol integrat; el seu abast no es pot canviar.",
|
||||
"This is a preview of the :theme email theme.": "Aquesta és una vista prèvia del tema de correu :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "Aquest és un correu d'exemple; no s'ha enviat cap notificació de debò.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Aquesta és una àrea segura de l'aplicació. Confirma la teva contrasenya abans de continuar.",
|
||||
"This is a test email from :site.": "Aquest és un correu de prova de :site.",
|
||||
"This is the last active administrator account.": "Aquest és l'últim compte d'administrador actiu.",
|
||||
"This link cannot match the file's own public URL slug.": "Aquest enllaç no pot coincidir amb el sufix d'URL pública del mateix fitxer.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Fins a :max MB per fitxer. Les pujades es poden posar en pausa i es reprenen automàticament després d'una interrupció.",
|
||||
"Update": "Actualització",
|
||||
"Update :name": "Actualització :name",
|
||||
"Update password": "Actualitza la contrasenya",
|
||||
"Update your name and email address": "Actualitza el teu nom i la teva adreça de correu",
|
||||
"Updated the account \":subject\"": "Ha actualitzat el compte \":subject\"",
|
||||
"Updated the file \":subject\"": "Ha actualitzat el fitxer \":subject\"",
|
||||
"Updated the group \":subject\"": "Ha actualitzat el grup \":subject\"",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Usuaris",
|
||||
"Username": "Nom d'usuari",
|
||||
"Variant Also Negotiates": "La variant també es negocia",
|
||||
"Verify email": "Verifica el correu",
|
||||
"Verify Email Address": "Confirmeu la vostra adreça electrònica",
|
||||
"Version": "Versió",
|
||||
"Version :version": "Versió :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "El teu administrador exigeix l'autenticació de dos factors en aquest compte. Configura-la a sota per continuar.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "L'assumpte i el cos personalitzats es descartaran i se substituiran immediatament pel text per defecte; aquesta acció no es pot desfer.",
|
||||
"Your download should start automatically.": "La teva descàrrega hauria de començar automàticament.",
|
||||
"Your email address is unverified.": "La teva adreça de correu no està verificada.",
|
||||
"Your existing recovery codes will stop working immediately.": "Els teus codis de recuperació actuals deixaran de funcionar immediatament.",
|
||||
"Your group membership request was approved": "La teva sol·licitud d'adhesió al grup s'ha aprovat",
|
||||
"Your group membership request was denied": "La teva sol·licitud d'adhesió al grup s'ha denegat",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest ha comentat el fitxer \":subject\"",
|
||||
":name — files": ":name — fitxers",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "S'han actualitzat :updated dels :requested fitxers seleccionats. La resta s'han omès perquè no tens permís per editar-los.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": "S'han actualitzat :updated dels :requested fitxers seleccionats. La resta s'han omès perquè no tens permís per fer aquests canvis.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Una capçalera contundent construïda al voltant del teu logotip; combina amb l'estil Gallery per a una safata d'entrada polida i fidel a la teva marca.",
|
||||
"Access": "Accés",
|
||||
"Access key": "Clau d'accés",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Els clients d'aquesta instal·lació estan limitats a :count. Elimina'n un o demana un pla més gran.",
|
||||
":used of :limit staff seats used": "Usats :used de :limit comptes de sistema",
|
||||
":used of :limit client accounts used": "Usats :used de :limit comptes de client",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Aquesta carpeta no es pot eliminar: conté :count fitxer que no pots eliminar.|Aquesta carpeta no es pot eliminar: conté :count fitxers que no pots eliminar."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Aquesta carpeta no es pot eliminar: conté :count fitxer que no pots eliminar.|Aquesta carpeta no es pot eliminar: conté :count fitxers que no pots eliminar.",
|
||||
"A PNG with a transparent background works best.": "Un PNG amb fons transparent funciona millor.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "S'aplica a les miniatures i les previsualitzacions. Les que ja existeixen es regeneren el proper cop que es vegin.",
|
||||
"A sample image with the watermark applied": "Una imatge d'exemple amb la marca d'aigua aplicada",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Una imatge de mostra, no un fitxer teu. En triar una marca d'aigua a dalt, això s'actualitza quan desis.",
|
||||
"Attribution": "Atribució",
|
||||
"Bottom centre": "A baix al centre",
|
||||
"Bottom left": "A baix a l'esquerra",
|
||||
"Bottom right": "A baix a la dreta",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "Per defecte apareix una petita línia «Powered by ProjectSend» a les pàgines públiques, al portal de clients i al peu del correu de sortida. Desactiva-la per no deixar cap rastre del programari que fan servir els teus clients.",
|
||||
"Centre": "Centre",
|
||||
"Choose a file only to replace the image above.": "Tria un fitxer només si vols substituir la imatge de dalt.",
|
||||
"Choose the image to use as the watermark.": "Tria la imatge que s'usarà com a marca d'aigua.",
|
||||
"Hide \"Powered by ProjectSend\"": "Amaga «Powered by ProjectSend»",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo eliminat.",
|
||||
"Logo updated.": "Logo actualitzat.",
|
||||
"Middle left": "Al mig a l'esquerra",
|
||||
"Middle right": "Al mig a la dreta",
|
||||
"No custom logo set — the default icon is shown.": "No hi ha cap logo propi — es mostra la icona per defecte.",
|
||||
"Opacity (%)": "Opacitat (%)",
|
||||
"Position": "Posició",
|
||||
"Remove logo": "Treu el logo",
|
||||
"Remove watermark": "Treu la marca d'aigua",
|
||||
"Save attribution settings": "Desa els ajustos d'atribució",
|
||||
"Saved.": "Desat.",
|
||||
"Save watermark settings": "Desa els ajustos de la marca d'aigua",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Mostra el teu propi logo a la barra lateral en lloc de la icona per defecte.",
|
||||
"Size (% of the image)": "Mida (% de la imatge)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Estampa una imatge sobre les miniatures i les previsualitzacions que veuen els clients i els visitants. El que veu el teu equip al gestor de fitxers queda sense marcar, i els fitxers originals — incloent-hi cada descàrrega — no es modifiquen mai.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "La marca d'aigua s'escala per cabre dins d'aquesta proporció d'allò on es dibuixa, mantenint-ne les proporcions, així que una miniatura i una previsualització es veuen com el mateix disseny.",
|
||||
"Top centre": "A dalt al centre",
|
||||
"Top left": "A dalt a l'esquerra",
|
||||
"Top right": "A dalt a la dreta",
|
||||
"Upload a watermark image below to see a preview here.": "Puja una imatge de marca d'aigua a sota per veure'n aquí una previsualització.",
|
||||
"Upload logo": "Puja un logo",
|
||||
"Watermark": "Marca d'aigua",
|
||||
"Watermark image": "Imatge de la marca d'aigua",
|
||||
"Watermark removed.": "Marca d'aigua eliminada.",
|
||||
"Watermark settings saved.": "Ajustos de la marca d'aigua desats.",
|
||||
"Watermark what clients and visitors see": "Marca d'aigua en el que veuen clients i visitants",
|
||||
"What clients will see": "El que veuran els clients",
|
||||
"Your own artwork on this installation.": "La teva pròpia imatge en aquesta instal·lació.",
|
||||
"Your own staff still see the version and licence on the About screen.": "El teu equip continua veient la versió i la llicència a la pantalla Quant a.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Detectat a partir del servidor web. Defineix PROJECTSEND_FILE_DELIVERY al teu fitxer .env per triar-ho explícitament.",
|
||||
"Downloads are being sent by PHP": "Les descàrregues les envia el PHP",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "Les descàrregues les envia el PHP — per què no és l'opció òptima",
|
||||
"Downloads are not being handed to the web server": "Les descàrregues no s'estan lliurant al servidor web",
|
||||
"Downloads sent by": "Descàrregues enviades per",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Tot funciona. Això té a veure amb quanta càrrega aguanta el teu servidor, no amb que hi hagi res trencat.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "Els fitxers es lliuren a nginx, que els envia sense mantenir ocupat un procés de PHP. És l'opció més ràpida i no cal res més.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "Els fitxers es lliuren al teu servidor web amb la capçalera X-Sendfile, que els envia sense mantenir ocupat un procés de PHP.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Lliurar un fitxer requereix una capçalera de resposta, i cada servidor web en llegeix una de diferent. ProjectSend només l'envia quan sap que el servidor hi actuarà, perquè un servidor que la ignora envia una resposta buida, que arriba com un fitxer de 0 bytes.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Aquí és el mateix PHP qui envia els fitxers perquè PROJECTSEND_FILE_DELIVERY està definit com a php.",
|
||||
"How downloads are sent": "Com s'envien les descàrregues",
|
||||
"How to change it": "Com canviar-ho",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "No ha reconegut el servidor que té al davant, així que ha recorregut a l'opció que funciona a tot arreu.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "O bé, a l'Apache, instal·la mod_xsendfile i autoritza el teu directori d'emmagatzematge amb XSendFilePath; LiteSpeed no necessita cap mòdul. Després defineix PROJECTSEND_FILE_DELIVERY=xsendfile.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "O desa els fitxers en emmagatzematge compatible amb S3 o a Google Cloud, perquè les descàrregues no passin pel teu servidor.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "El PHP llegeix cada fitxer i l'envia. Això funciona a qualsevol servidor, però ocupa un procés de treball del PHP durant tota la descàrrega.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Executa ProjectSend darrere de nginx, amb el bloc location d'INSTALL.md. No cal res més: es detecta automàticament.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Definit explícitament per PROJECTSEND_FILE_DELIVERY al teu fitxer .env.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "Aquest avís es manté mentre el PHP enviï els fitxers, fins i tot si s'ha triat expressament.",
|
||||
"Web server (nginx)": "Servidor web (nginx)",
|
||||
"Web server (X-Sendfile)": "Servidor web (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Els servidors web ho fan molt millor: fan servir la crida del sistema pensada per a això, gestionen la represa i l'avanç dels vídeos, i un sol procés atén moltes transferències alhora.",
|
||||
"What is happening": "Què està passant",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Mentre el PHP envia un fitxer, un procés de treball queda ocupat durant tota la descàrrega. Unes quantes descàrregues grans alhora poden ocupar tots els processos que tens, i el lloc deixa de respondre —fins i tot per a qui només vol iniciar sessió—, amb el processador aturat.",
|
||||
"Why it is set this way": "Per què està configurat així",
|
||||
"Why that is worth changing": "Per què val la pena canviar-ho",
|
||||
"Why this matters, and how to change it": "Per què és important i com canviar-ho",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Els teus fitxers es desen fora de l'arrel web, així que cada descàrrega passa primer per ProjectSend, que comprova que qui la demana hi tingui dret. Després d'aquesta comprovació, el PHP obre el fitxer i l'envia. L'alternativa és que el PHP digui al teu servidor web «envia aquest fitxer» i acabi immediatament."
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "Byl vytvořen nový klientský účet: :name (:email).",
|
||||
"A new client has registered": "Zaregistroval se nový klient",
|
||||
"A new ProjectSend version is available": "Je k dispozici nová verze ProjectSend",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Na e-mailovou adresu, kterou jste zadali při registraci, jsme odeslali nový ověřovací odkaz.",
|
||||
"A new verification link has been sent to your email address.": "Na vaši e-mailovou adresu jsme odeslali nový ověřovací odkaz.",
|
||||
"A public link was created": "Byl vytvořen veřejný odkaz",
|
||||
"A public link was revoked": "Veřejný odkaz byl zneplatněn",
|
||||
"A role was created": "Role byla vytvořena",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Vyberte Obrázek",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Vyberte, která upozornění vám mají chodit i e-mailem. Vše se tak jako tak vždy objeví ve zvonku upozornění.",
|
||||
"Clear": "Vymazat",
|
||||
"Click here to re-send the verification email.": "Kliknutím sem odešlete ověřovací e-mail znovu.",
|
||||
"Click to copy": "Kliknutím zkopírujete",
|
||||
"Client": "Klient",
|
||||
"Client account approved": "Klientský účet schválen",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Komentář",
|
||||
"Confirm": "Potvrdit",
|
||||
"Confirm password": "Potvrzení hesla",
|
||||
"Confirm your password": "Potvrďte své heslo",
|
||||
"Conflict": "Konflikt",
|
||||
"Connect": "Připojit",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Připojte externí bucket — kompatibilní s S3 (AWS S3, MinIO, Backblaze) nebo Google Cloud Storage — jako úložiště pro nová nahrání.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "Vytvořil(a) složku \":subject\"",
|
||||
"Created the group \":subject\"": "Vytvořil(a) skupinu \":subject\"",
|
||||
"Created the role \":subject\"": "Vytvořil(a) roli \":subject\"",
|
||||
"Current password": "Současné heslo",
|
||||
"Custom": "Vlastní",
|
||||
"Custom assets": "Vlastní prvky",
|
||||
"Custom fields": "Vlastní pole",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Edice",
|
||||
"Email": "E-mail",
|
||||
"Email address": "E-mailová adresa",
|
||||
"Email password reset link": "Odeslat odkaz pro obnovení hesla",
|
||||
"Email settings": "Nastavení e-mailu",
|
||||
"Email template reset to default.": "E-mailová šablona vrácena na výchozí znění.",
|
||||
"Email template saved.": "E-mailová šablona uložena.",
|
||||
"Email templates": "E-mailové šablony",
|
||||
"Email verification": "Ověření e-mailu",
|
||||
"Empty file": "Prázdný soubor",
|
||||
"Enable": "Povolit",
|
||||
"Enable the public directory page": "Zapnout stránku veřejného rozcestníku",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "Soubor zašifrovaného prostředí již existuje.",
|
||||
"Encrypted environment file not found.": "Soubor zašifrovaného prostředí nebyl nalezen.",
|
||||
"Encryption": "Šifrování",
|
||||
"Ensure your account is using a long, random password to stay secure": "Zajistěte, aby váš účet používal dlouhé a náhodné heslo",
|
||||
"Enter one of your recovery codes.": "Zadejte jeden ze svých záložních kódů.",
|
||||
"Enter the six-digit code from your authenticator app.": "Zadejte šestimístný kód z ověřovací aplikace.",
|
||||
"Enter your email and password below to log in": "Pro přihlášení zadejte níže svůj e-mail a heslo",
|
||||
"Enter your email to receive a password reset link": "Zadejte svůj e-mail a pošleme vám odkaz pro obnovení hesla",
|
||||
"Environment": "Prostředí",
|
||||
"Environment file already exists.": "Soubor prostředí již existuje.",
|
||||
"Environment file not found.": "Soubor prostředí nebyl nalezen.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Složky lze sdílet pouze s klienty nebo skupinami.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Maximální hloubka vnoření složek: :max.",
|
||||
"Forbidden": "Zakázáno",
|
||||
"Forgot password": "Zapomenuté heslo",
|
||||
"Forgot password?": "Zapomněli jste heslo?",
|
||||
"Found": "Nalezeno",
|
||||
"From": "Od",
|
||||
"From address": "Adresa odesílatele",
|
||||
"From name": "Jméno odesílatele",
|
||||
"Full name": "Celé jméno",
|
||||
"Gateway Timeout": "Časový limit brány",
|
||||
"General": "Obecné",
|
||||
"General settings": "Obecná nastavení",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Zamčeno",
|
||||
"Log in": "Přihlásit se",
|
||||
"Log In": "Přihlásit se",
|
||||
"log in": "přihlášení",
|
||||
"Log in to your account": "Přihlaste se ke svému účtu",
|
||||
"Log out": "Odhlásit se",
|
||||
"Log Out": "Odhlásit",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Otevřít veřejný seznam",
|
||||
"Options": "Možnosti",
|
||||
"Or enter this key manually:": "Nebo zadejte tento klíč ručně:",
|
||||
"Or, return to": "Nebo se vraťte na",
|
||||
"Orange": "Oranžová",
|
||||
"Origin Is Unreachable": "Původ je nedosažitelný",
|
||||
"Outgoing email": "Odchozí e-mail",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Částečný obsah",
|
||||
"Password": "Heslo",
|
||||
"Password reset": "Obnova hesla",
|
||||
"Password settings": "Nastavení hesla",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Vložte soubor s klíčem JSON servisního účtu s právem číst a zapisovat objekty v bucketu. Ukládá se zašifrovaný a už se nikdy nezobrazí.",
|
||||
"Path": "Cesta",
|
||||
"Payload Too Large": "Příliš velké užitečné zatížení",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Růžová",
|
||||
"Platform": "Platforma",
|
||||
"Please click the button below to verify your email address.": "Klepnutím na tlačítko níže ověřte svou e-mailovou adresu.",
|
||||
"Please enter your new password below": "Zadejte prosím níže své nové heslo",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Ověřte prosím svou e-mailovou adresu kliknutím na odkaz, který jsme vám právě poslali.",
|
||||
"Port": "Port",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Používá ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Soukromé",
|
||||
"Processing": "zpracovává se",
|
||||
"Profile": "Profil",
|
||||
"Profile information": "Informace o profilu",
|
||||
"Profile information was updated": "Údaje profilu byly aktualizovány",
|
||||
"Profile settings": "Nastavení profilu",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "Je k dispozici ProjectSend :latestVersion (máte :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend je svobodný software s otevřeným zdrojovým kódem pro posílání souborů vašim klientům. Vychází pod licencí :license, což znamená, že jej můžete volně používat, studovat, měnit a sdílet.",
|
||||
"ProjectSend is ready": "ProjectSend je připraven",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Požadovaná členství čekají na schválení ve frontě žádostí o členství.",
|
||||
"Require two-factor authentication": "Vyžadovat dvoufázové ověření",
|
||||
"Required": "Povinné",
|
||||
"Resend verification email": "Odeslat ověřovací e-mail znovu",
|
||||
"Reset Content": "Obnovit obsah",
|
||||
"Reset Password": "Obnovit heslo",
|
||||
"Reset password": "Obnovit heslo",
|
||||
"Reset Password Notification": "Požadavek na obnovení hesla",
|
||||
"Reset this email to its default wording?": "Vrátit tento e-mail na výchozí znění?",
|
||||
"Reset to default": "Obnovit výchozí",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": "Uložit :name",
|
||||
"Save & Close": "Uložit a zavřít",
|
||||
"Save & Return": "Uložit a vrátit",
|
||||
"Save password": "Uložit heslo",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Uložte změny, aby se to zveřejnilo — do té doby tato tlačítka míří na stránku, která ještě není zveřejněná.",
|
||||
"Saved": "Uloženo",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Naskenujte tento QR kód ověřovací aplikací a poté pro potvrzení zadejte šestimístný kód.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Motiv aktualizován.",
|
||||
"Theming": "Motivy",
|
||||
"Theming settings": "Nastavení motivů",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "Tento účet se přihlašuje přes váš adresář, takže se jeho heslo nenastavuje zde. Pokud se nemůžete přihlásit, obraťte se na správce.",
|
||||
"This action is unauthorized.": "Tato akce je neoprávněná.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Tento soubor překračuje maximální povolenou velikost :max MB.",
|
||||
"This folder is empty.": "Tato složka je prázdná.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "Toto je vestavěná role; její rozsah nelze změnit.",
|
||||
"This is a preview of the :theme email theme.": "Toto je náhled e-mailového motivu :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "Toto je ukázkový e-mail — žádné upozornění nebylo doopravdy odesláno.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Toto je zabezpečená část aplikace. Než budete pokračovat, potvrďte prosím své heslo.",
|
||||
"This is a test email from :site.": "Toto je zkušební e-mail ze :site.",
|
||||
"This is the last active administrator account.": "Toto je poslední aktivní účet administrátora.",
|
||||
"This link cannot match the file's own public URL slug.": "Tento odkaz nesmí být shodný s vlastním veřejným URL identifikátorem souboru.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Až :max MB na soubor. Nahrávání lze pozastavit a po přerušení pokračuje automaticky.",
|
||||
"Update": "Aktualizace",
|
||||
"Update :name": "Aktualizace :name",
|
||||
"Update password": "Změnit heslo",
|
||||
"Update your name and email address": "Aktualizujte své jméno a e-mailovou adresu",
|
||||
"Updated the account \":subject\"": "Aktualizoval(a) účet \":subject\"",
|
||||
"Updated the file \":subject\"": "Aktualizoval(a) soubor \":subject\"",
|
||||
"Updated the group \":subject\"": "Aktualizoval(a) skupinu \":subject\"",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Uživatelé",
|
||||
"Username": "Uživatelské jméno",
|
||||
"Variant Also Negotiates": "Varianta také vyjednává",
|
||||
"Verify email": "Ověřit e-mail",
|
||||
"Verify Email Address": "Ověřte e-mailovou adresu",
|
||||
"Version": "Verze",
|
||||
"Version :version": "Verze :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Váš administrátor u tohoto účtu vyžaduje dvoufázové ověření. Nastavte si je níže a můžete pokračovat.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Váš upravený předmět a text budou okamžitě zahozeny a nahrazeny výchozím zněním — tuto akci nelze vrátit zpět.",
|
||||
"Your download should start automatically.": "Stahování by mělo začít automaticky.",
|
||||
"Your email address is unverified.": "Vaše e-mailová adresa není ověřená.",
|
||||
"Your existing recovery codes will stop working immediately.": "Vaše dosavadní záložní kódy okamžitě přestanou fungovat.",
|
||||
"Your group membership request was approved": "Vaše žádost o členství ve skupině byla schválena",
|
||||
"Your group membership request was denied": "Vaše žádost o členství ve skupině byla zamítnuta",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest okomentoval(a) soubor \":subject\"",
|
||||
":name — files": ":name — soubory",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Aktualizováno :updated z :requested vybraných souborů. Zbytek byl přeskočen, protože k jejich úpravě nemáte oprávnění.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": "Aktualizováno :updated z :requested vybraných souborů. Zbytek byl přeskočen, protože k provedení těchto změn nemáte oprávnění.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Výrazné záhlaví postavené kolem vašeho loga — ve dvojici se vzhledem Gallery dává vybroušenou schránku věrnou vaší značce.",
|
||||
"Access": "Přístup",
|
||||
"Access key": "Přístupový klíč",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Počet klientů v této instalaci je omezen na :count. Odeber jednoho nebo si vyžádej větší tarif.",
|
||||
":used of :limit staff seats used": "Využito :used z :limit systémových účtů",
|
||||
":used of :limit client accounts used": "Využito :used z :limit klientských účtů",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Tuto složku nelze smazat: obsahuje :count soubor, který nemůžeš smazat.|Tuto složku nelze smazat: obsahuje :count soubory, které nemůžeš smazat.|Tuto složku nelze smazat: obsahuje :count souborů, které nemůžeš smazat."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Tuto složku nelze smazat: obsahuje :count soubor, který nemůžeš smazat.|Tuto složku nelze smazat: obsahuje :count soubory, které nemůžeš smazat.|Tuto složku nelze smazat: obsahuje :count souborů, které nemůžeš smazat.",
|
||||
"A PNG with a transparent background works best.": "Nejlépe funguje PNG s průhledným pozadím.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "Platí pro náhledy a ukázky. Ty stávající se znovu vytvoří při příštím zobrazení.",
|
||||
"A sample image with the watermark applied": "Ukázkový obrázek s použitým vodoznakem",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Zástupný obrázek, ne váš soubor. Když výše zvolíte nový vodoznak, po uložení se to promítne sem.",
|
||||
"Attribution": "Uvedení autora",
|
||||
"Bottom centre": "Dole uprostřed",
|
||||
"Bottom left": "Dole vlevo",
|
||||
"Bottom right": "Dole vpravo",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "Ve výchozím stavu se na veřejných stránkách, v klientském portálu a v patičce odchozích e-mailů objevuje malý řádek „Powered by ProjectSend“. Vypněte jej, aby po softwaru, který vaši klienti používají, nezůstala žádná stopa.",
|
||||
"Centre": "Uprostřed",
|
||||
"Choose a file only to replace the image above.": "Soubor vybírejte jen tehdy, když chcete nahradit obrázek výše.",
|
||||
"Choose the image to use as the watermark.": "Vyberte obrázek, který se použije jako vodoznak.",
|
||||
"Hide \"Powered by ProjectSend\"": "Skrýt „Powered by ProjectSend“",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo odstraněno.",
|
||||
"Logo updated.": "Logo aktualizováno.",
|
||||
"Middle left": "Uprostřed vlevo",
|
||||
"Middle right": "Uprostřed vpravo",
|
||||
"No custom logo set — the default icon is shown.": "Vlastní logo není nastaveno — zobrazuje se výchozí ikona.",
|
||||
"Opacity (%)": "Krytí (%)",
|
||||
"Position": "Pozice",
|
||||
"Remove logo": "Odstranit logo",
|
||||
"Remove watermark": "Odstranit vodoznak",
|
||||
"Save attribution settings": "Uložit nastavení uvedení autora",
|
||||
"Saved.": "Uloženo.",
|
||||
"Save watermark settings": "Uložit nastavení vodoznaku",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Zobrazte v postranním panelu vlastní logo místo výchozí ikony.",
|
||||
"Size (% of the image)": "Velikost (% obrázku)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Otiskněte obrázek přes náhledy a ukázky, které vidí klienti a návštěvníci. To, co vidí váš tým ve správci souborů, zůstává bez označení a původní soubory — včetně každého stažení — se nikdy nemění.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "Vodoznak se zmenší tak, aby se vešel do této části toho, na co je kreslen, a zachová si poměry stran — náhled i ukázka tak vypadají jako stejný návrh.",
|
||||
"Top centre": "Nahoře uprostřed",
|
||||
"Top left": "Nahoře vlevo",
|
||||
"Top right": "Nahoře vpravo",
|
||||
"Upload a watermark image below to see a preview here.": "Nahrajte níže obrázek vodoznaku a uvidíte tu náhled.",
|
||||
"Upload logo": "Nahrát logo",
|
||||
"Watermark": "Vodoznak",
|
||||
"Watermark image": "Obrázek vodoznaku",
|
||||
"Watermark removed.": "Vodoznak odstraněn.",
|
||||
"Watermark settings saved.": "Nastavení vodoznaku uloženo.",
|
||||
"Watermark what clients and visitors see": "Vodoznak na tom, co vidí klienti a návštěvníci",
|
||||
"What clients will see": "Co uvidí klienti",
|
||||
"Your own artwork on this installation.": "Vaše vlastní grafika v této instalaci.",
|
||||
"Your own staff still see the version and licence on the About screen.": "Váš tým stále vidí verzi a licenci na obrazovce O aplikaci.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Zjištěno podle webového serveru. Nastav PROJECTSEND_FILE_DELIVERY v souboru .env, pokud chceš volbu určit výslovně.",
|
||||
"Downloads are being sent by PHP": "Stahování odesílá PHP",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "Stahování odesílá PHP — proč to není optimální",
|
||||
"Downloads are not being handed to the web server": "Stahování se nepředává webovému serveru",
|
||||
"Downloads sent by": "Stahování odesílá",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Všechno funguje. Jde o to, jakou zátěž server unese, ne o to, že by bylo něco rozbité.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "Soubory se předávají nginxu, který je odešle, aniž by blokoval proces PHP. Je to nejrychlejší varianta a nic dalšího nevyžaduje.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "Soubory se předávají webovému serveru hlavičkou X-Sendfile, který je odešle, aniž by blokoval proces PHP.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Předání souboru vyžaduje hlavičku odpovědi a každý webový server čte jinou. ProjectSend ji posílá jen tehdy, když ví, že na ni server zareaguje — server, který ji ignoruje, totiž odešle prázdnou odpověď, jež dorazí jako soubor o velikosti 0 bajtů.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Tady soubory odesílá samo PHP, protože PROJECTSEND_FILE_DELIVERY je nastaveno na php.",
|
||||
"How downloads are sent": "Jak se stahování odesílají",
|
||||
"How to change it": "Jak to změnit",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "Server před ním nebyl rozpoznán, proto se použila varianta, která funguje všude.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "Nebo na Apachi nainstaluj mod_xsendfile a povol svůj adresář úložiště direktivou XSendFilePath; LiteSpeed žádný modul nepotřebuje. Potom nastav PROJECTSEND_FILE_DELIVERY=xsendfile.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "Nebo ukládej soubory do úložiště kompatibilního s S3 nebo do Google Cloud, aby stahování tvůj server vůbec nezatěžovalo.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP čte každý soubor a odesílá ho. Funguje to na každém serveru, ale po celou dobu stahování to zabere jeden pracovní proces PHP.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Provozuj ProjectSend za nginxem, s blokem location z INSTALL.md. Nic dalšího se nenastavuje — rozpozná se sám.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Nastaveno výslovně proměnnou PROJECTSEND_FILE_DELIVERY v souboru .env.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "Toto upozornění zůstává, dokud soubory odesílá PHP — i když to byla záměrná volba.",
|
||||
"Web server (nginx)": "Webový server (nginx)",
|
||||
"Web server (X-Sendfile)": "Webový server (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Webové servery to zvládají mnohem lépe: používají systémové volání určené právě k tomu, umí navazovat přerušené přenosy i posun ve videu a jeden proces obslouží mnoho přenosů najednou.",
|
||||
"What is happening": "Co se děje",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Zatímco PHP odesílá soubor, je jeden pracovní proces po celou dobu stahování obsazený. Několik velkých stahování najednou dokáže obsadit všechny procesy a web přestane odpovídat — i lidem, kteří se chtějí jen přihlásit — zatímco procesor zahálí.",
|
||||
"Why it is set this way": "Proč je to nastavené takto",
|
||||
"Why that is worth changing": "Proč se to vyplatí změnit",
|
||||
"Why this matters, and how to change it": "Proč na tom záleží a jak to změnit",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tvoje soubory jsou uložené mimo webový kořen, takže každé stahování projde nejdřív ProjectSendem, který ověří, že na soubor má daný člověk nárok. Po této kontrole PHP soubor otevře a odešle. Druhá možnost je, že PHP řekne webovému serveru „pošli tenhle soubor“ a hned skončí."
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "Ein neues Kundenkonto wurde erstellt: :name (:email).",
|
||||
"A new client has registered": "Ein neuer Kunde hat sich registriert",
|
||||
"A new ProjectSend version is available": "Eine neue ProjectSend-Version ist verfügbar",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Ein neuer Bestätigungslink wurde an die E-Mail-Adresse gesendet, die Sie bei der Registrierung angegeben haben.",
|
||||
"A new verification link has been sent to your email address.": "Ein neuer Bestätigungslink wurde an Ihre E-Mail-Adresse gesendet.",
|
||||
"A public link was created": "Ein öffentlicher Link wurde erstellt",
|
||||
"A public link was revoked": "Ein öffentlicher Link wurde widerrufen",
|
||||
"A role was created": "Eine Rolle wurde erstellt",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Bild wählen",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Wählen Sie, welche Benachrichtigungen Ihnen zusätzlich per E-Mail zugehen. Alles erscheint ohnehin immer in der Benachrichtigungsglocke.",
|
||||
"Clear": "Zurücksetzen",
|
||||
"Click here to re-send the verification email.": "Klicken Sie hier, um die Bestätigungs-E-Mail erneut zu senden.",
|
||||
"Click to copy": "Klicken Sie zum Kopieren",
|
||||
"Client": "Kunde",
|
||||
"Client account approved": "Kundenkonto genehmigt",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Kommentar",
|
||||
"Confirm": "Bestätigen",
|
||||
"Confirm password": "Passwort bestätigen",
|
||||
"Confirm your password": "Bestätigen Sie Ihr Passwort",
|
||||
"Conflict": "Konflikt",
|
||||
"Connect": "Verbinden",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Verbinden Sie einen externen Bucket — S3-kompatibel (AWS S3, MinIO, Backblaze) oder Google Cloud Storage — als Speicher für neue Uploads.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "Hat den Ordner \":subject\" erstellt",
|
||||
"Created the group \":subject\"": "Hat die Gruppe \":subject\" erstellt",
|
||||
"Created the role \":subject\"": "Hat die Rolle \":subject\" erstellt",
|
||||
"Current password": "Aktuelles Passwort",
|
||||
"Custom": "Benutzerdefiniert",
|
||||
"Custom assets": "Eigene Assets",
|
||||
"Custom fields": "Benutzerdefinierte Felder",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Edition",
|
||||
"Email": "E-Mail",
|
||||
"Email address": "E-Mail-Adresse",
|
||||
"Email password reset link": "Link zum Zurücksetzen des Passworts senden",
|
||||
"Email settings": "E-Mail-Einstellungen",
|
||||
"Email template reset to default.": "E-Mail-Vorlage auf den Standard zurückgesetzt.",
|
||||
"Email template saved.": "E-Mail-Vorlage gespeichert.",
|
||||
"Email templates": "E-Mail-Vorlagen",
|
||||
"Email verification": "E-Mail-Bestätigung",
|
||||
"Empty file": "Leere Datei",
|
||||
"Enable": "Aktivieren",
|
||||
"Enable the public directory page": "Öffentliche Verzeichnisseite aktivieren",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "Verschlüsselte Umgebungsdatei ist bereits vorhanden.",
|
||||
"Encrypted environment file not found.": "Verschlüsselte Umgebungsdatei nicht gefunden.",
|
||||
"Encryption": "Verschlüsselung",
|
||||
"Ensure your account is using a long, random password to stay secure": "Stellen Sie sicher, dass Ihr Konto ein langes, zufälliges Passwort verwendet",
|
||||
"Enter one of your recovery codes.": "Geben Sie einen Ihrer Wiederherstellungscodes ein.",
|
||||
"Enter the six-digit code from your authenticator app.": "Geben Sie den sechsstelligen Code aus Ihrer Authenticator-App ein.",
|
||||
"Enter your email and password below to log in": "Geben Sie unten Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden",
|
||||
"Enter your email to receive a password reset link": "Geben Sie Ihre E-Mail-Adresse ein, um einen Link zum Zurücksetzen des Passworts zu erhalten",
|
||||
"Environment": "Umgebung",
|
||||
"Environment file already exists.": "Umgebungsdatei ist bereits vorhanden.",
|
||||
"Environment file not found.": "Umgebungsdatei nicht gefunden.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Ordner können nur mit Kunden oder Gruppen geteilt werden.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Ordner können höchstens :max Ebenen tief verschachtelt werden.",
|
||||
"Forbidden": "Verboten",
|
||||
"Forgot password": "Passwort vergessen",
|
||||
"Forgot password?": "Passwort vergessen?",
|
||||
"Found": "Gefunden",
|
||||
"From": "Von",
|
||||
"From address": "Absenderadresse",
|
||||
"From name": "Absendername",
|
||||
"Full name": "Vollständiger Name",
|
||||
"Gateway Timeout": "Gateway-Zeitüberschreitung",
|
||||
"General": "Allgemein",
|
||||
"General settings": "Allgemeine Einstellungen",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Gesperrt",
|
||||
"Log in": "Anmelden",
|
||||
"Log In": "Einloggen",
|
||||
"log in": "Anmeldung",
|
||||
"Log in to your account": "Bei Ihrem Konto anmelden",
|
||||
"Log out": "Abmelden",
|
||||
"Log Out": "Abmelden",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Öffentliches Verzeichnis öffnen",
|
||||
"Options": "Optionen",
|
||||
"Or enter this key manually:": "Oder geben Sie diesen Schlüssel manuell ein:",
|
||||
"Or, return to": "Oder zurück zur",
|
||||
"Orange": "Orange",
|
||||
"Origin Is Unreachable": "Quelle ist nicht erreichbar",
|
||||
"Outgoing email": "Ausgehende E-Mail",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Teilinhalt",
|
||||
"Password": "Passwort",
|
||||
"Password reset": "Passwort zurücksetzen",
|
||||
"Password settings": "Passworteinstellungen",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Fügen Sie die JSON-Schlüsseldatei eines Dienstkontos mit Lese- und Schreibzugriff auf die Objekte des Buckets ein. Sie wird verschlüsselt gespeichert und nie wieder angezeigt.",
|
||||
"Path": "Pfad",
|
||||
"Payload Too Large": "Nutzlast zu groß",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Rosa",
|
||||
"Platform": "Plattform",
|
||||
"Please click the button below to verify your email address.": "Bitte klicken Sie auf die Schaltfläche, um Ihre E-Mail-Adresse zu bestätigen.",
|
||||
"Please enter your new password below": "Bitte geben Sie unten Ihr neues Passwort ein",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Bitte bestätigen Sie Ihre E-Mail-Adresse über den Link, den wir Ihnen soeben gesendet haben.",
|
||||
"Port": "Port",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Bereitgestellt von ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Privat",
|
||||
"Processing": "In Bearbeitung",
|
||||
"Profile": "Profil",
|
||||
"Profile information": "Profilinformationen",
|
||||
"Profile information was updated": "Profilinformationen wurden aktualisiert",
|
||||
"Profile settings": "Profileinstellungen",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion ist verfügbar (Sie haben :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend ist freie Open-Source-Software, um Dateien an Ihre Kunden zu senden. Sie steht unter der :license, das heißt, Sie dürfen sie nutzen, untersuchen, verändern und weitergeben.",
|
||||
"ProjectSend is ready": "ProjectSend ist startklar",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Angefragte Mitgliedschaften warten in der Warteschlange auf Freigabe.",
|
||||
"Require two-factor authentication": "Zwei-Faktor-Authentifizierung verlangen",
|
||||
"Required": "Erforderlich",
|
||||
"Resend verification email": "Bestätigungs-E-Mail erneut senden",
|
||||
"Reset Content": "Inhalt zurücksetzen",
|
||||
"Reset Password": "Passwort zurücksetzen",
|
||||
"Reset password": "Passwort zurücksetzen",
|
||||
"Reset Password Notification": "Benachrichtigung zum Zurücksetzen des Passworts",
|
||||
"Reset this email to its default wording?": "Diese E-Mail auf den Standardwortlaut zurücksetzen?",
|
||||
"Reset to default": "Auf Standard zurücksetzen",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": "Sparen Sie :name",
|
||||
"Save & Close": "Speichern und schließen",
|
||||
"Save & Return": "Speichern und zurückgeben",
|
||||
"Save password": "Passwort speichern",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Speichern Sie Ihre Änderungen, um dies live zu schalten — bis dahin verweisen diese Schaltflächen auf eine noch nicht veröffentlichte Seite.",
|
||||
"Saved": "Gespeichert",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App und geben Sie zur Bestätigung den sechsstelligen Code ein.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Design aktualisiert.",
|
||||
"Theming": "Design",
|
||||
"Theming settings": "Design-Einstellungen",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "Dieses Konto meldet sich über Ihr Verzeichnis an, daher wird sein Passwort nicht hier festgelegt. Wenden Sie sich an einen Administrator, falls Sie sich nicht anmelden können.",
|
||||
"This action is unauthorized.": "Diese Aktion ist nicht autorisiert.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Diese Datei überschreitet die maximal zulässige Größe von :max MB.",
|
||||
"This folder is empty.": "Dieser Ordner ist leer.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "Dies ist eine integrierte Rolle; ihr Geltungsbereich lässt sich nicht ändern.",
|
||||
"This is a preview of the :theme email theme.": "Dies ist eine Vorschau des E-Mail-Designs :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "Dies ist eine Beispiel-E-Mail — es wurde keine Benachrichtigung tatsächlich versendet.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Dies ist ein geschützter Bereich der Anwendung. Bitte bestätigen Sie Ihr Passwort, bevor Sie fortfahren.",
|
||||
"This is a test email from :site.": "Dies ist eine Test-E-Mail von :site.",
|
||||
"This is the last active administrator account.": "Dies ist das letzte aktive Administratorkonto.",
|
||||
"This link cannot match the file's own public URL slug.": "Dieser Link darf nicht mit dem eigenen öffentlichen URL-Slug der Datei übereinstimmen.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Bis zu :max MB pro Datei. Uploads können pausiert werden und laufen nach Unterbrechungen automatisch weiter.",
|
||||
"Update": "Aktualisieren",
|
||||
"Update :name": ":name aktualisieren",
|
||||
"Update password": "Passwort ändern",
|
||||
"Update your name and email address": "Aktualisieren Sie Ihren Namen und Ihre E-Mail-Adresse",
|
||||
"Updated the account \":subject\"": "Hat das Konto \":subject\" aktualisiert",
|
||||
"Updated the file \":subject\"": "Hat die Datei \":subject\" aktualisiert",
|
||||
"Updated the group \":subject\"": "Hat die Gruppe \":subject\" aktualisiert",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Benutzer",
|
||||
"Username": "Benutzername",
|
||||
"Variant Also Negotiates": "Variante verhandelt ebenfalls",
|
||||
"Verify email": "E-Mail bestätigen",
|
||||
"Verify Email Address": "E-Mail-Adresse bestätigen",
|
||||
"Version": "Version",
|
||||
"Version :version": "Version :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Ihr Administrator verlangt für dieses Konto die Zwei-Faktor-Authentifizierung. Richten Sie sie unten ein, um fortzufahren.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Ihr angepasster Betreff und Inhalt werden sofort verworfen und durch den Standardwortlaut ersetzt — das lässt sich nicht rückgängig machen.",
|
||||
"Your download should start automatically.": "Ihr Download sollte automatisch starten.",
|
||||
"Your email address is unverified.": "Ihre E-Mail-Adresse ist nicht bestätigt.",
|
||||
"Your existing recovery codes will stop working immediately.": "Ihre bisherigen Wiederherstellungscodes funktionieren ab sofort nicht mehr.",
|
||||
"Your group membership request was approved": "Ihre Anfrage auf Gruppenmitgliedschaft wurde genehmigt",
|
||||
"Your group membership request was denied": "Ihre Anfrage auf Gruppenmitgliedschaft wurde abgelehnt",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest hat die Datei \":subject\" kommentiert",
|
||||
":name — files": ":name — Dateien",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated von :requested ausgewählten Dateien wurden aktualisiert. Der Rest wurde übersprungen, weil Ihnen die Berechtigung zum Bearbeiten fehlt.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": ":updated von :requested ausgewählten Dateien wurden aktualisiert. Der Rest wurde übersprungen, weil Ihnen die Berechtigung für diese Änderungen fehlt.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Ein markanter Kopfbereich rund um Ihr Logo — passt zum Gallery-Look für einen gepflegten, markengerechten Posteingang.",
|
||||
"Access": "Zugriff",
|
||||
"Access key": "Zugriffsschlüssel",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Die Kunden dieser Installation sind auf :count begrenzt. Entfernen Sie einen oder fragen Sie nach einem größeren Tarif.",
|
||||
":used of :limit staff seats used": "Belegt: :used von :limit Systemkonten",
|
||||
":used of :limit client accounts used": "Belegt: :used von :limit Kundenkonten",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Dieser Ordner kann nicht gelöscht werden: Er enthält :count Datei, die Sie nicht löschen dürfen.|Dieser Ordner kann nicht gelöscht werden: Er enthält :count Dateien, die Sie nicht löschen dürfen."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Dieser Ordner kann nicht gelöscht werden: Er enthält :count Datei, die Sie nicht löschen dürfen.|Dieser Ordner kann nicht gelöscht werden: Er enthält :count Dateien, die Sie nicht löschen dürfen.",
|
||||
"A PNG with a transparent background works best.": "Ein PNG mit transparentem Hintergrund funktioniert am besten.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "Gilt für Vorschaubilder und Vorschauen. Vorhandene werden beim nächsten Aufruf neu erzeugt.",
|
||||
"A sample image with the watermark applied": "Ein Beispielbild mit angewendetem Wasserzeichen",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Ein Platzhalterbild, keine Ihrer Dateien. Ein neues Wasserzeichen oben aktualisiert es, sobald Sie speichern.",
|
||||
"Attribution": "Namensnennung",
|
||||
"Bottom centre": "Unten mittig",
|
||||
"Bottom left": "Unten links",
|
||||
"Bottom right": "Unten rechts",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "Standardmäßig erscheint eine kleine Zeile „Powered by ProjectSend“ auf den öffentlichen Seiten, im Kundenportal und am Fuß ausgehender E-Mails. Schalten Sie sie ab, um keine Spur der Software zu hinterlassen, die Ihre Kunden benutzen.",
|
||||
"Centre": "Mittig",
|
||||
"Choose a file only to replace the image above.": "Wählen Sie nur dann eine Datei, wenn Sie das Bild oben ersetzen möchten.",
|
||||
"Choose the image to use as the watermark.": "Wählen Sie das Bild, das als Wasserzeichen dienen soll.",
|
||||
"Hide \"Powered by ProjectSend\"": "„Powered by ProjectSend“ ausblenden",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo entfernt.",
|
||||
"Logo updated.": "Logo aktualisiert.",
|
||||
"Middle left": "Mitte links",
|
||||
"Middle right": "Mitte rechts",
|
||||
"No custom logo set — the default icon is shown.": "Kein eigenes Logo hinterlegt — es wird das Standardsymbol gezeigt.",
|
||||
"Opacity (%)": "Deckkraft (%)",
|
||||
"Position": "Position",
|
||||
"Remove logo": "Logo entfernen",
|
||||
"Remove watermark": "Wasserzeichen entfernen",
|
||||
"Save attribution settings": "Einstellungen zur Namensnennung speichern",
|
||||
"Saved.": "Gespeichert.",
|
||||
"Save watermark settings": "Wasserzeichen-Einstellungen speichern",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Zeigen Sie Ihr eigenes Logo in der Seitenleiste statt des Standardsymbols.",
|
||||
"Size (% of the image)": "Größe (% des Bildes)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Legen Sie ein Bild über die Vorschaubilder und Vorschauen, die Kunden und Besucher sehen. Was Ihr Team im Dateimanager sieht, bleibt unmarkiert, und die Originaldateien — einschließlich jedes Downloads — werden nie verändert.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "Das Wasserzeichen wird proportional so skaliert, dass es in diesen Anteil dessen passt, worauf es gezeichnet wird — so wirken Vorschaubild und Vorschau wie dasselbe Design.",
|
||||
"Top centre": "Oben mittig",
|
||||
"Top left": "Oben links",
|
||||
"Top right": "Oben rechts",
|
||||
"Upload a watermark image below to see a preview here.": "Laden Sie unten ein Wasserzeichenbild hoch, um hier eine Vorschau zu sehen.",
|
||||
"Upload logo": "Logo hochladen",
|
||||
"Watermark": "Wasserzeichen",
|
||||
"Watermark image": "Wasserzeichenbild",
|
||||
"Watermark removed.": "Wasserzeichen entfernt.",
|
||||
"Watermark settings saved.": "Wasserzeichen-Einstellungen gespeichert.",
|
||||
"Watermark what clients and visitors see": "Wasserzeichen auf dem, was Kunden und Besucher sehen",
|
||||
"What clients will see": "Was Kunden sehen werden",
|
||||
"Your own artwork on this installation.": "Ihre eigene Gestaltung auf dieser Installation.",
|
||||
"Your own staff still see the version and licence on the About screen.": "Ihr eigenes Team sieht Version und Lizenz weiterhin auf der Über-Seite.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Vom Webserver erkannt. Setze PROJECTSEND_FILE_DELIVERY in deiner .env-Datei, um es ausdrücklich festzulegen.",
|
||||
"Downloads are being sent by PHP": "Downloads werden von PHP ausgeliefert",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "Downloads werden von PHP ausgeliefert – warum das nicht optimal ist",
|
||||
"Downloads are not being handed to the web server": "Downloads werden nicht an den Webserver übergeben",
|
||||
"Downloads sent by": "Downloads ausgeliefert von",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Alles funktioniert. Es geht darum, wie viel Last dein Server verträgt, nicht darum, dass etwas kaputt wäre.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "Die Dateien werden an nginx übergeben, der sie ausliefert, ohne einen PHP-Prozess zu blockieren. Das ist die schnellste Option und erfordert nichts weiter.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "Die Dateien werden mit dem X-Sendfile-Header an deinen Webserver übergeben, der sie ausliefert, ohne einen PHP-Prozess zu blockieren.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Eine Datei zu übergeben erfordert einen Response-Header, und jeder Webserver liest einen anderen. ProjectSend sendet ihn nur, wenn es weiß, dass der Server darauf reagiert – denn ein Server, der ihn ignoriert, sendet stattdessen eine leere Antwort, die als 0-Byte-Datei ankommt.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Hier liefert PHP die Dateien selbst aus, weil PROJECTSEND_FILE_DELIVERY auf php gesetzt ist.",
|
||||
"How downloads are sent": "Wie Downloads ausgeliefert werden",
|
||||
"How to change it": "So änderst du es",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "Der Server davor wurde nicht erkannt, also wurde auf die Option zurückgegriffen, die überall funktioniert.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "Oder installiere unter Apache mod_xsendfile und gib dein Speicherverzeichnis mit XSendFilePath frei; LiteSpeed braucht kein Modul. Setze dann PROJECTSEND_FILE_DELIVERY=xsendfile.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "Oder speichere die Dateien in S3-kompatiblem oder Google-Cloud-Speicher, damit Downloads deinen Server gar nicht erst berühren.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP liest jede Datei und sendet sie selbst. Das funktioniert auf jedem Server, belegt aber für die gesamte Dauer jedes Downloads einen PHP-Worker-Prozess.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Betreibe ProjectSend hinter nginx, mit dem location-Block aus INSTALL.md. Sonst ist nichts einzustellen – es wird erkannt.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Ausdrücklich gesetzt über PROJECTSEND_FILE_DELIVERY in deiner .env-Datei.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "Dieser Hinweis bleibt, solange PHP die Dateien ausliefert – auch wenn das bewusst so gewählt wurde.",
|
||||
"Web server (nginx)": "Webserver (nginx)",
|
||||
"Web server (X-Sendfile)": "Webserver (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Webserver können das deutlich besser: Sie nutzen den dafür vorgesehenen Systemaufruf, beherrschen Fortsetzen und das Spulen in Videos, und ein Prozess bedient viele Übertragungen gleichzeitig.",
|
||||
"What is happening": "Was gerade passiert",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Während PHP eine Datei sendet, ist ein Worker-Prozess für den gesamten Download belegt. Ein paar große Downloads gleichzeitig können alle vorhandenen Worker belegen, und die Seite antwortet nicht mehr – auch für Leute, die sich nur anmelden wollen, während der Prozessor untätig bleibt.",
|
||||
"Why it is set this way": "Warum es so eingestellt ist",
|
||||
"Why that is worth changing": "Warum sich eine Änderung lohnt",
|
||||
"Why this matters, and how to change it": "Warum das wichtig ist und wie du es änderst",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Deine Dateien liegen außerhalb des Web-Roots, also läuft jeder Download zuerst über ProjectSend, das prüft, ob die anfragende Person sie haben darf. Nach dieser Prüfung öffnet PHP die Datei und sendet sie. Die Alternative ist, dass PHP deinem Webserver sagt „sende diese Datei“ und sofort fertig ist."
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "Se creó una nueva cuenta de cliente: :name (:email).",
|
||||
"A new client has registered": "Un nuevo cliente se ha registrado",
|
||||
"A new ProjectSend version is available": "Hay una nueva versión de ProjectSend disponible",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Hemos enviado un nuevo enlace de verificación al correo electrónico que indicaste al registrarte.",
|
||||
"A new verification link has been sent to your email address.": "Hemos enviado un nuevo enlace de verificación a tu correo electrónico.",
|
||||
"A public link was created": "Se creó un enlace público",
|
||||
"A public link was revoked": "Se revocó un enlace público",
|
||||
"A role was created": "Se creó un rol",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Elegir Imagen",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Elige qué notificaciones también te envían un correo. Todo aparece siempre en la campana de notificaciones.",
|
||||
"Clear": "Limpiar",
|
||||
"Click here to re-send the verification email.": "Haz clic aquí para volver a enviar el correo de verificación.",
|
||||
"Click to copy": "Haga clic para copiar",
|
||||
"Client": "Cliente",
|
||||
"Client account approved": "Cuenta de cliente aprobada",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Comentar",
|
||||
"Confirm": "Confirmar",
|
||||
"Confirm password": "Confirmar contraseña",
|
||||
"Confirm your password": "Confirma tu contraseña",
|
||||
"Conflict": "Conflicto",
|
||||
"Connect": "Conectar",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Conecta un bucket externo — compatible con S3 (AWS S3, MinIO, Backblaze) o Google Cloud Storage — como almacenamiento para las subidas nuevas.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "Creó la carpeta \":subject\"",
|
||||
"Created the group \":subject\"": "Creó el grupo \":subject\"",
|
||||
"Created the role \":subject\"": "Creó el rol \":subject\"",
|
||||
"Current password": "Contraseña actual",
|
||||
"Custom": "Personalizado",
|
||||
"Custom assets": "Recursos personalizados",
|
||||
"Custom fields": "Campos personalizados",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Edición",
|
||||
"Email": "Correo electrónico",
|
||||
"Email address": "Correo electrónico",
|
||||
"Email password reset link": "Enviar enlace para restablecer la contraseña",
|
||||
"Email settings": "Configuración de correo electrónico",
|
||||
"Email template reset to default.": "Plantilla de correo restablecida al valor predeterminado.",
|
||||
"Email template saved.": "Plantilla de correo guardada.",
|
||||
"Email templates": "Plantillas de correo",
|
||||
"Email verification": "Verificación de correo electrónico",
|
||||
"Empty file": "Archivo vacío",
|
||||
"Enable": "Habilitar",
|
||||
"Enable the public directory page": "Habilitar la página de directorio público",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "El archivo de entorno cifrado ya existe.",
|
||||
"Encrypted environment file not found.": "No se encontró el archivo de entorno cifrado.",
|
||||
"Encryption": "Cifrado",
|
||||
"Ensure your account is using a long, random password to stay secure": "Asegúrate de que tu cuenta usa una contraseña larga y aleatoria para mantenerla segura",
|
||||
"Enter one of your recovery codes.": "Ingresa uno de tus códigos de recuperación.",
|
||||
"Enter the six-digit code from your authenticator app.": "Ingresa el código de seis dígitos de tu aplicación de autenticación.",
|
||||
"Enter your email and password below to log in": "Ingresa tu correo electrónico y contraseña para iniciar sesión",
|
||||
"Enter your email to receive a password reset link": "Introduce tu correo electrónico para recibir un enlace de restablecimiento",
|
||||
"Environment": "Entorno",
|
||||
"Environment file already exists.": "El archivo de entorno ya existe.",
|
||||
"Environment file not found.": "Archivo de entorno no encontrado.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Las carpetas solo pueden compartirse con clientes o grupos.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Las carpetas no pueden anidarse más de :max niveles.",
|
||||
"Forbidden": "Prohibido",
|
||||
"Forgot password": "Olvidaste tu contraseña",
|
||||
"Forgot password?": "¿Olvidaste tu contraseña?",
|
||||
"Found": "Encontrado",
|
||||
"From": "Desde",
|
||||
"From address": "Dirección de origen",
|
||||
"From name": "Nombre de origen",
|
||||
"Full name": "Nombre completo",
|
||||
"Gateway Timeout": "Tiempo de espera de puerta de enlace",
|
||||
"General": "General",
|
||||
"General settings": "Configuración general",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Bloqueado",
|
||||
"Log in": "Iniciar sesión",
|
||||
"Log In": "Iniciar sesión",
|
||||
"log in": "iniciar sesión",
|
||||
"Log in to your account": "Inicia sesión en tu cuenta",
|
||||
"Log out": "Cerrar sesión",
|
||||
"Log Out": "Finalizar sesión",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Abrir listado público",
|
||||
"Options": "Opciones",
|
||||
"Or enter this key manually:": "O ingresa esta clave manualmente:",
|
||||
"Or, return to": "O vuelve a",
|
||||
"Orange": "Naranja",
|
||||
"Origin Is Unreachable": "El origen es inalcanzable",
|
||||
"Outgoing email": "Correo saliente",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Contenido parcial",
|
||||
"Password": "Contraseña",
|
||||
"Password reset": "Restablecimiento de contraseña",
|
||||
"Password settings": "Ajustes de contraseña",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Pega el archivo de clave JSON de una cuenta de servicio con permiso de lectura y escritura de objetos en el bucket. Se guarda cifrado y no se vuelve a mostrar.",
|
||||
"Path": "Ruta",
|
||||
"Payload Too Large": "Solicitud demasiado grande",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Rosa",
|
||||
"Platform": "Plataforma",
|
||||
"Please click the button below to verify your email address.": "Por favor, haga clic en el botón de abajo para verificar su dirección de correo electrónico.",
|
||||
"Please enter your new password below": "Introduce tu nueva contraseña a continuación",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Verifica tu correo electrónico haciendo clic en el enlace que acabamos de enviarte.",
|
||||
"Port": "Puerto",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Con tecnología de ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Privado",
|
||||
"Processing": "Procesando",
|
||||
"Profile": "Perfil",
|
||||
"Profile information": "Información del perfil",
|
||||
"Profile information was updated": "Se actualizó la información del perfil",
|
||||
"Profile settings": "Ajustes del perfil",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion está disponible (tienes :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend es software libre y de código abierto para enviar archivos a tus clientes. Se publica bajo la :license, lo que significa que eres libre de usarlo, estudiarlo, modificarlo y compartirlo.",
|
||||
"ProjectSend is ready": "ProjectSend está listo",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Las membresías solicitadas esperan aprobación en la cola de solicitudes de membresía.",
|
||||
"Require two-factor authentication": "Exigir autenticación de dos factores",
|
||||
"Required": "Obligatorio",
|
||||
"Resend verification email": "Reenviar correo de verificación",
|
||||
"Reset Content": "Restablecer contenido",
|
||||
"Reset Password": "Restablecer contraseña",
|
||||
"Reset password": "Restablecer contraseña",
|
||||
"Reset Password Notification": "Notificación de restablecimiento de contraseña",
|
||||
"Reset this email to its default wording?": "¿Restablecer este correo a su redacción predeterminada?",
|
||||
"Reset to default": "Restablecer al valor predeterminado",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": "Guardar :name",
|
||||
"Save & Close": "Guardar y cerrar",
|
||||
"Save & Return": "Guardar y volver",
|
||||
"Save password": "Guardar contraseña",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Guarda tus cambios para que esto esté disponible; hasta entonces, estos botones apuntan a una página que aún no está publicada.",
|
||||
"Saved": "Guardado",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Escanea este código QR con tu aplicación de autenticación y luego ingresa el código de seis dígitos para confirmar.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Tema actualizado.",
|
||||
"Theming": "Temas",
|
||||
"Theming settings": "Configuración de temas",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "Esta cuenta inicia sesión a través de tu directorio, así que su contraseña no se configura aquí. Consulta con un administrador si no puedes iniciar sesión.",
|
||||
"This action is unauthorized.": "Esta acción no está autorizada.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Este archivo supera el tamaño máximo permitido de :max MB.",
|
||||
"This folder is empty.": "Esta carpeta está vacía.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "Este es un rol predefinido; su alcance no se puede cambiar.",
|
||||
"This is a preview of the :theme email theme.": "Esta es una vista previa del tema de correo :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "Este es un correo de muestra — no se envió ninguna notificación real.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Esta es un área segura de la aplicación. Confirma tu contraseña antes de continuar.",
|
||||
"This is a test email from :site.": "Este es un correo de prueba de :site.",
|
||||
"This is the last active administrator account.": "Esta es la última cuenta de administrador activa.",
|
||||
"This link cannot match the file's own public URL slug.": "Este enlace no puede coincidir con el slug de la URL pública del archivo.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Hasta :max MB por archivo. Las subidas pueden pausarse y se reanudan automáticamente tras interrupciones.",
|
||||
"Update": "Actualizar",
|
||||
"Update :name": "Actualizar :name",
|
||||
"Update password": "Actualizar contraseña",
|
||||
"Update your name and email address": "Actualiza tu nombre y tu correo electrónico",
|
||||
"Updated the account \":subject\"": "Actualizó la cuenta \":subject\"",
|
||||
"Updated the file \":subject\"": "Actualizó el archivo \":subject\"",
|
||||
"Updated the group \":subject\"": "Actualizó el grupo \":subject\"",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Usuarios",
|
||||
"Username": "Usuario",
|
||||
"Variant Also Negotiates": "Variante También Negocia",
|
||||
"Verify email": "Verificar correo electrónico",
|
||||
"Verify Email Address": "Confirme su correo electrónico",
|
||||
"Version": "Versión",
|
||||
"Version :version": "Versión :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Tu administrador exige autenticación de dos factores en esta cuenta. Configúrala a continuación para continuar.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Tu asunto y cuerpo personalizados se descartarán y se reemplazarán inmediatamente por la redacción predeterminada; esto no se puede deshacer.",
|
||||
"Your download should start automatically.": "Tu descarga debería comenzar automáticamente.",
|
||||
"Your email address is unverified.": "Tu correo electrónico no está verificado.",
|
||||
"Your existing recovery codes will stop working immediately.": "Tus códigos de recuperación actuales dejarán de funcionar inmediatamente.",
|
||||
"Your group membership request was approved": "Tu solicitud de membresía a un grupo fue aprobada",
|
||||
"Your group membership request was denied": "Tu solicitud de membresía a un grupo fue rechazada",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest comentó en el archivo «:subject»",
|
||||
":name — files": ":name — archivos",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Se actualizaron :updated de los :requested archivos seleccionados. El resto se omitió porque no tienes permiso para editarlos.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": "Se actualizaron :updated de los :requested archivos seleccionados. El resto se omitió porque no tienes permiso para hacer esos cambios.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Una cabecera contundente construida alrededor de tu logo: combina con el estilo Galería para una bandeja de entrada cuidada y con tu marca.",
|
||||
"Access": "Acceso",
|
||||
"Access key": "Clave de acceso",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Los clientes de esta instalación están limitados a :count. Elimina uno o pide un plan más grande.",
|
||||
":used of :limit staff seats used": "Usadas :used de :limit cuentas de sistema",
|
||||
":used of :limit client accounts used": "Usadas :used de :limit cuentas de cliente",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Esta carpeta no se puede eliminar: contiene :count archivo que no puedes eliminar.|Esta carpeta no se puede eliminar: contiene :count archivos que no puedes eliminar."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Esta carpeta no se puede eliminar: contiene :count archivo que no puedes eliminar.|Esta carpeta no se puede eliminar: contiene :count archivos que no puedes eliminar.",
|
||||
"A PNG with a transparent background works best.": "Un PNG con fondo transparente funciona mejor.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "Se aplica a las miniaturas y las vistas previas. Las que ya existen se regeneran la próxima vez que se vean.",
|
||||
"A sample image with the watermark applied": "Una imagen de ejemplo con la marca de agua aplicada",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Una imagen de muestra, no un archivo tuyo. Al elegir una marca de agua arriba, esto se actualiza cuando guardes.",
|
||||
"Attribution": "Atribución",
|
||||
"Bottom centre": "Abajo al centro",
|
||||
"Bottom left": "Abajo a la izquierda",
|
||||
"Bottom right": "Abajo a la derecha",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "De forma predeterminada aparece una pequeña línea «Powered by ProjectSend» en las páginas públicas, en el portal de clientes y al pie del correo saliente. Desactívala para no dejar rastro del software que usan tus clientes.",
|
||||
"Centre": "Centro",
|
||||
"Choose a file only to replace the image above.": "Elige un archivo solo si quieres reemplazar la imagen de arriba.",
|
||||
"Choose the image to use as the watermark.": "Elige la imagen que se usará como marca de agua.",
|
||||
"Hide \"Powered by ProjectSend\"": "Ocultar «Powered by ProjectSend»",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo eliminado.",
|
||||
"Logo updated.": "Logo actualizado.",
|
||||
"Middle left": "En medio a la izquierda",
|
||||
"Middle right": "En medio a la derecha",
|
||||
"No custom logo set — the default icon is shown.": "No hay ningún logo propio — se muestra el icono predeterminado.",
|
||||
"Opacity (%)": "Opacidad (%)",
|
||||
"Position": "Posición",
|
||||
"Remove logo": "Quitar el logo",
|
||||
"Remove watermark": "Quitar la marca de agua",
|
||||
"Save attribution settings": "Guardar los ajustes de atribución",
|
||||
"Saved.": "Guardado.",
|
||||
"Save watermark settings": "Guardar los ajustes de la marca de agua",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Muestra tu propio logo en la barra lateral en lugar del icono predeterminado.",
|
||||
"Size (% of the image)": "Tamaño (% de la imagen)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Estampa una imagen sobre las miniaturas y las vistas previas que ven los clientes y los visitantes. Lo que ve tu personal en el gestor de archivos queda sin marcar, y los archivos originales — incluida cada descarga — nunca se modifican.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "La marca de agua se escala para caber dentro de esta proporción de aquello sobre lo que se dibuja, manteniendo sus proporciones, así que una miniatura y una vista previa se ven como el mismo diseño.",
|
||||
"Top centre": "Arriba al centro",
|
||||
"Top left": "Arriba a la izquierda",
|
||||
"Top right": "Arriba a la derecha",
|
||||
"Upload a watermark image below to see a preview here.": "Sube una imagen de marca de agua abajo para ver aquí una vista previa.",
|
||||
"Upload logo": "Subir un logo",
|
||||
"Watermark": "Marca de agua",
|
||||
"Watermark image": "Imagen de la marca de agua",
|
||||
"Watermark removed.": "Marca de agua eliminada.",
|
||||
"Watermark settings saved.": "Ajustes de la marca de agua guardados.",
|
||||
"Watermark what clients and visitors see": "Marca de agua en lo que ven clientes y visitantes",
|
||||
"What clients will see": "Lo que verán los clientes",
|
||||
"Your own artwork on this installation.": "Tu propia imagen en esta instalación.",
|
||||
"Your own staff still see the version and licence on the About screen.": "Tu personal sigue viendo la versión y la licencia en la pantalla Acerca de.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Detectado a partir del servidor web. Define PROJECTSEND_FILE_DELIVERY en tu archivo .env para elegirlo explícitamente.",
|
||||
"Downloads are being sent by PHP": "PHP está enviando las descargas",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "PHP está enviando las descargas: por qué no es lo óptimo",
|
||||
"Downloads are not being handed to the web server": "Las descargas no se están entregando al servidor web",
|
||||
"Downloads sent by": "Descargas enviadas por",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Todo funciona. Esto trata de cuánta carga aguanta tu servidor, no de que algo esté roto.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "Los archivos se entregan a nginx, que los envía sin mantener ocupado un proceso de PHP. Es la opción más rápida y no necesita nada más.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "Los archivos se entregan a tu servidor web con la cabecera X-Sendfile, que los envía sin mantener ocupado un proceso de PHP.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Entregar un archivo requiere una cabecera de respuesta, y cada servidor web lee una distinta. ProjectSend solo la envía cuando sabe que el servidor va a actuar sobre ella, porque un servidor que la ignora envía una respuesta vacía en su lugar, que llega como un archivo de 0 bytes.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Aquí está enviando los archivos por sí mismo porque PROJECTSEND_FILE_DELIVERY está configurado como php.",
|
||||
"How downloads are sent": "Cómo se envían las descargas",
|
||||
"How to change it": "Cómo cambiarlo",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "No reconoció el servidor que tiene delante, así que recurrió a la opción que funciona en todas partes.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "O bien, en Apache, instala mod_xsendfile y autoriza tu directorio de almacenamiento con XSendFilePath; LiteSpeed no necesita ningún módulo. Después define PROJECTSEND_FILE_DELIVERY=xsendfile.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "O guarda los archivos en almacenamiento compatible con S3 o en Google Cloud, para que las descargas no pasen por tu servidor en absoluto.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP está leyendo cada archivo y enviándolo. Eso funciona en cualquier servidor, pero ocupa un proceso de trabajo de PHP durante toda la descarga.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Ejecuta ProjectSend detrás de nginx, con el bloque location de INSTALL.md. No hay nada más que configurar: se detecta solo.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Definido explícitamente por PROJECTSEND_FILE_DELIVERY en tu archivo .env.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "Este aviso se mantiene mientras PHP esté enviando los archivos, incluso si se eligió así a propósito.",
|
||||
"Web server (nginx)": "Servidor web (nginx)",
|
||||
"Web server (X-Sendfile)": "Servidor web (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Los servidores web lo hacen mucho mejor: usan la llamada del sistema pensada para ello, gestionan la reanudación y el avance en vídeos, y un solo proceso atiende muchas transferencias a la vez.",
|
||||
"What is happening": "Qué está pasando",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Mientras PHP envía un archivo, un proceso de trabajo queda ocupado durante toda la descarga. Unas pocas descargas grandes a la vez pueden ocupar todos los procesos que tienes, y el sitio deja de responder, incluso para quien solo intenta iniciar sesión, con el procesador sin hacer nada.",
|
||||
"Why it is set this way": "Por qué está configurado así",
|
||||
"Why that is worth changing": "Por qué vale la pena cambiarlo",
|
||||
"Why this matters, and how to change it": "Por qué importa y cómo cambiarlo",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tus archivos se guardan fuera de la raíz web, así que cada descarga pasa primero por ProjectSend para comprobar que quien la pide tiene permiso. Después de esa comprobación, PHP abre el archivo y lo envía. La alternativa es que PHP le diga a tu servidor web «envía este archivo» y termine de inmediato."
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "Un nouveau compte client a été créé : :name (:email).",
|
||||
"A new client has registered": "Un nouveau client s'est inscrit",
|
||||
"A new ProjectSend version is available": "Une nouvelle version de ProjectSend est disponible",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Un nouveau lien de vérification a été envoyé à l'adresse e-mail que vous avez indiquée lors de votre inscription.",
|
||||
"A new verification link has been sent to your email address.": "Un nouveau lien de vérification a été envoyé à votre adresse e-mail.",
|
||||
"A public link was created": "Un lien public a été créé",
|
||||
"A public link was revoked": "Un lien public a été révoqué",
|
||||
"A role was created": "Un rôle a été créé",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Choisir une image",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Choisissez les notifications qui vous sont également envoyées par e-mail. Tout apparaît de toute façon dans la cloche de notifications.",
|
||||
"Clear": "Effacer",
|
||||
"Click here to re-send the verification email.": "Cliquez ici pour renvoyer l'e-mail de vérification.",
|
||||
"Click to copy": "Cliquer pour copier",
|
||||
"Client": "Client",
|
||||
"Client account approved": "Compte client approuvé",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Commentaire",
|
||||
"Confirm": "Confirmer",
|
||||
"Confirm password": "Confirmer le mot de passe",
|
||||
"Confirm your password": "Confirmez votre mot de passe",
|
||||
"Conflict": "Conflit",
|
||||
"Connect": "Connecter",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Connectez un bucket externe — compatible S3 (AWS S3, MinIO, Backblaze) ou Google Cloud Storage — comme espace de stockage des nouveaux envois.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "A créé le dossier \":subject\"",
|
||||
"Created the group \":subject\"": "A créé le groupe \":subject\"",
|
||||
"Created the role \":subject\"": "A créé le rôle \":subject\"",
|
||||
"Current password": "Mot de passe actuel",
|
||||
"Custom": "Personnalisé",
|
||||
"Custom assets": "Ressources personnalisées",
|
||||
"Custom fields": "Champs personnalisés",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Édition",
|
||||
"Email": "E-mail",
|
||||
"Email address": "Adresse e-mail",
|
||||
"Email password reset link": "Envoyer le lien de réinitialisation",
|
||||
"Email settings": "Paramètres d'e-mail",
|
||||
"Email template reset to default.": "Modèle d'e-mail réinitialisé aux valeurs par défaut.",
|
||||
"Email template saved.": "Modèle d'e-mail enregistré.",
|
||||
"Email templates": "Modèles d'e-mail",
|
||||
"Email verification": "Vérification de l'e-mail",
|
||||
"Empty file": "Fichier vide",
|
||||
"Enable": "Activer",
|
||||
"Enable the public directory page": "Activer la page d'annuaire public",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "Le fichier d'environnement chiffré existe déjà.",
|
||||
"Encrypted environment file not found.": "Fichier d'environnement chiffré introuvable.",
|
||||
"Encryption": "Chiffrement",
|
||||
"Ensure your account is using a long, random password to stay secure": "Assurez-vous que votre compte utilise un mot de passe long et aléatoire pour rester protégé",
|
||||
"Enter one of your recovery codes.": "Saisissez l'un de vos codes de récupération.",
|
||||
"Enter the six-digit code from your authenticator app.": "Saisissez le code à six chiffres de votre application d'authentification.",
|
||||
"Enter your email and password below to log in": "Saisissez ci-dessous votre adresse e-mail et votre mot de passe pour vous connecter",
|
||||
"Enter your email to receive a password reset link": "Saisissez votre adresse e-mail pour recevoir un lien de réinitialisation",
|
||||
"Environment": "Environnement",
|
||||
"Environment file already exists.": "Le fichier d'environnement existe déjà.",
|
||||
"Environment file not found.": "Fichier d'environnement introuvable.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Les dossiers ne peuvent être partagés qu'avec des clients ou des groupes.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Les dossiers ne peuvent pas être imbriqués sur plus de :max niveaux.",
|
||||
"Forbidden": "Interdit",
|
||||
"Forgot password": "Mot de passe oublié",
|
||||
"Forgot password?": "Mot de passe oublié ?",
|
||||
"Found": "Trouvé",
|
||||
"From": "De",
|
||||
"From address": "Adresse d'expéditeur",
|
||||
"From name": "Nom d'expéditeur",
|
||||
"Full name": "Nom complet",
|
||||
"Gateway Timeout": "Temps d'attente de la passerelle dépassé",
|
||||
"General": "Général",
|
||||
"General settings": "Paramètres généraux",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Verrouillé",
|
||||
"Log in": "Se connecter",
|
||||
"Log In": "Se connecter",
|
||||
"log in": "la connexion",
|
||||
"Log in to your account": "Connectez-vous à votre compte",
|
||||
"Log out": "Se déconnecter",
|
||||
"Log Out": "Se déconnecter",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Ouvrir l'annuaire public",
|
||||
"Options": "Options",
|
||||
"Or enter this key manually:": "Ou saisissez cette clé manuellement :",
|
||||
"Or, return to": "Ou revenez à",
|
||||
"Orange": "Orange",
|
||||
"Origin Is Unreachable": "L'origine est inaccessible",
|
||||
"Outgoing email": "E-mails sortants",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Contenu partiel",
|
||||
"Password": "Mot de passe",
|
||||
"Password reset": "Réinitialisation du mot de passe",
|
||||
"Password settings": "Paramètres du mot de passe",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Collez le fichier de clé JSON d'un compte de service disposant d'un accès en lecture et écriture aux objets du bucket. Il est stocké chiffré et n'est plus jamais affiché.",
|
||||
"Path": "Chemin",
|
||||
"Payload Too Large": "Charge utile trop grande",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Rose",
|
||||
"Platform": "Plateforme",
|
||||
"Please click the button below to verify your email address.": "Veuillez cliquer sur le bouton ci-dessous pour vérifier votre adresse e-mail :",
|
||||
"Please enter your new password below": "Veuillez saisir votre nouveau mot de passe ci-dessous",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Veuillez vérifier votre adresse e-mail en cliquant sur le lien que nous venons de vous envoyer.",
|
||||
"Port": "Port",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Propulsé par ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Privé",
|
||||
"Processing": "En traitement",
|
||||
"Profile": "Profil",
|
||||
"Profile information": "Informations du profil",
|
||||
"Profile information was updated": "Les informations du profil ont été mises à jour",
|
||||
"Profile settings": "Paramètres du profil",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion est disponible (vous avez la :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend est un logiciel libre et open source pour envoyer des fichiers à vos clients. Il est publié sous la :license, ce qui signifie que vous êtes libre de l'utiliser, de l'étudier, de le modifier et de le partager.",
|
||||
"ProjectSend is ready": "ProjectSend est prêt",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Les adhésions demandées attendent leur approbation dans la file des demandes d'adhésion.",
|
||||
"Require two-factor authentication": "Exiger l'authentification à deux facteurs",
|
||||
"Required": "Obligatoire",
|
||||
"Resend verification email": "Renvoyer l'e-mail de vérification",
|
||||
"Reset Content": "Réinitialiser le contenu",
|
||||
"Reset Password": "Réinitialisation du mot de passe",
|
||||
"Reset password": "Réinitialiser le mot de passe",
|
||||
"Reset Password Notification": "Notification de réinitialisation du mot de passe",
|
||||
"Reset this email to its default wording?": "Réinitialiser cet e-mail à sa formulation par défaut ?",
|
||||
"Reset to default": "Réinitialiser aux valeurs par défaut",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": "Sauvegarder :name",
|
||||
"Save & Close": "Sauvegarder et fermer",
|
||||
"Save & Return": "Sauvegarder et retourner",
|
||||
"Save password": "Enregistrer le mot de passe",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Enregistrez vos modifications pour mettre cela en ligne — d'ici là, ces boutons pointent vers une page qui n'est pas encore publiée.",
|
||||
"Saved": "Enregistré",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Scannez ce QR code avec votre application d'authentification, puis saisissez le code à six chiffres pour confirmer.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Thème mis à jour.",
|
||||
"Theming": "Thèmes",
|
||||
"Theming settings": "Paramètres des thèmes",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "Ce compte se connecte via votre annuaire ; son mot de passe ne se définit donc pas ici. Contactez un administrateur si vous ne parvenez pas à vous connecter.",
|
||||
"This action is unauthorized.": "Cette action n'est pas autorisée.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Ce fichier dépasse la taille maximale autorisée de :max Mo.",
|
||||
"This folder is empty.": "Ce dossier est vide.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "Il s'agit d'un rôle intégré ; sa portée ne peut pas être modifiée.",
|
||||
"This is a preview of the :theme email theme.": "Ceci est un aperçu du thème d'e-mail :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "Ceci est un exemple d'e-mail — aucune notification n'a réellement été envoyée.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Ceci est une zone sécurisée de l'application. Veuillez confirmer votre mot de passe avant de continuer.",
|
||||
"This is a test email from :site.": "Ceci est un e-mail de test de :site.",
|
||||
"This is the last active administrator account.": "Il s'agit du dernier compte administrateur actif.",
|
||||
"This link cannot match the file's own public URL slug.": "Ce lien ne peut pas être identique au slug d'URL publique du fichier lui-même.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Jusqu'à :max Mo par fichier. Les téléversements peuvent être mis en pause et reprennent automatiquement après une interruption.",
|
||||
"Update": "Mettre à jour",
|
||||
"Update :name": "Mettre à jour :name",
|
||||
"Update password": "Modifier le mot de passe",
|
||||
"Update your name and email address": "Mettez à jour votre nom et votre adresse e-mail",
|
||||
"Updated the account \":subject\"": "A mis à jour le compte \":subject\"",
|
||||
"Updated the file \":subject\"": "A mis à jour le fichier \":subject\"",
|
||||
"Updated the group \":subject\"": "A mis à jour le groupe \":subject\"",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Utilisateurs",
|
||||
"Username": "Nom d'utilisateur",
|
||||
"Variant Also Negotiates": "La variante négocie également",
|
||||
"Verify email": "Vérifier l'e-mail",
|
||||
"Verify Email Address": "Vérifier l'adresse e-mail",
|
||||
"Version": "Version",
|
||||
"Version :version": "Version :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Votre administrateur exige l'authentification à deux facteurs sur ce compte. Configurez-la ci-dessous pour continuer.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Votre objet et votre corps de message personnalisés seront immédiatement abandonnés et remplacés par la formulation par défaut — cette action est irréversible.",
|
||||
"Your download should start automatically.": "Votre téléchargement devrait démarrer automatiquement.",
|
||||
"Your email address is unverified.": "Votre adresse e-mail n'est pas vérifiée.",
|
||||
"Your existing recovery codes will stop working immediately.": "Vos codes de récupération actuels cesseront de fonctionner immédiatement.",
|
||||
"Your group membership request was approved": "Votre demande d'adhésion au groupe a été approuvée",
|
||||
"Your group membership request was denied": "Votre demande d'adhésion au groupe a été refusée",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest a commenté le fichier \":subject\"",
|
||||
":name — files": ":name — fichiers",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated fichiers sur les :requested sélectionnés ont été mis à jour. Les autres ont été ignorés faute de permission de les modifier.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": ":updated fichiers sur les :requested sélectionnés ont été mis à jour. Les autres ont été ignorés faute de permission d'effectuer ces modifications.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Un en-tête affirmé construit autour de votre logo — s'accorde au style Gallery pour une boîte de réception soignée et fidèle à votre marque.",
|
||||
"Access": "Accès",
|
||||
"Access key": "Clé d'accès",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Les clients de cette installation sont limités à :count. Supprimez-en un, ou demandez une formule plus large.",
|
||||
":used of :limit staff seats used": "Utilisés : :used sur :limit comptes système",
|
||||
":used of :limit client accounts used": "Utilisés : :used sur :limit comptes client",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Ce dossier ne peut pas être supprimé : il contient :count fichier que vous n'avez pas le droit de supprimer.|Ce dossier ne peut pas être supprimé : il contient :count fichiers que vous n'avez pas le droit de supprimer."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Ce dossier ne peut pas être supprimé : il contient :count fichier que vous n'avez pas le droit de supprimer.|Ce dossier ne peut pas être supprimé : il contient :count fichiers que vous n'avez pas le droit de supprimer.",
|
||||
"A PNG with a transparent background works best.": "Un PNG avec un fond transparent fonctionne le mieux.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "S'applique aux miniatures et aux aperçus. Ceux qui existent déjà sont regénérés au prochain affichage.",
|
||||
"A sample image with the watermark applied": "Une image d'exemple avec le filigrane appliqué",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Une image de substitution, pas l'un de vos fichiers. Choisir un nouveau filigrane ci-dessus la met à jour dès que vous enregistrez.",
|
||||
"Attribution": "Attribution",
|
||||
"Bottom centre": "En bas au centre",
|
||||
"Bottom left": "En bas à gauche",
|
||||
"Bottom right": "En bas à droite",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "Par défaut, une petite ligne « Powered by ProjectSend » apparaît sur les pages publiques, dans le portail client et au bas des e-mails sortants. Désactivez-la pour ne laisser aucune trace du logiciel qu'utilisent vos clients.",
|
||||
"Centre": "Centre",
|
||||
"Choose a file only to replace the image above.": "Ne choisissez un fichier que pour remplacer l'image ci-dessus.",
|
||||
"Choose the image to use as the watermark.": "Choisissez l'image à utiliser comme filigrane.",
|
||||
"Hide \"Powered by ProjectSend\"": "Masquer « Powered by ProjectSend »",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo supprimé.",
|
||||
"Logo updated.": "Logo mis à jour.",
|
||||
"Middle left": "Au milieu à gauche",
|
||||
"Middle right": "Au milieu à droite",
|
||||
"No custom logo set — the default icon is shown.": "Aucun logo personnalisé — l'icône par défaut est affichée.",
|
||||
"Opacity (%)": "Opacité (%)",
|
||||
"Position": "Position",
|
||||
"Remove logo": "Supprimer le logo",
|
||||
"Remove watermark": "Supprimer le filigrane",
|
||||
"Save attribution settings": "Enregistrer les paramètres d'attribution",
|
||||
"Saved.": "Enregistré.",
|
||||
"Save watermark settings": "Enregistrer les paramètres du filigrane",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Affichez votre propre logo dans la barre latérale à la place de l'icône par défaut.",
|
||||
"Size (% of the image)": "Taille (% de l'image)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Apposez une image sur les miniatures et les aperçus que voient les clients et les visiteurs. Ce que votre équipe voit dans le gestionnaire de fichiers reste sans marque, et les fichiers d'origine — y compris chaque téléchargement — ne sont jamais modifiés.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "Le filigrane est redimensionné pour tenir dans cette part de ce sur quoi il est dessiné, en gardant ses proportions — ainsi une miniature et un aperçu ont l'air du même design.",
|
||||
"Top centre": "En haut au centre",
|
||||
"Top left": "En haut à gauche",
|
||||
"Top right": "En haut à droite",
|
||||
"Upload a watermark image below to see a preview here.": "Téléversez une image de filigrane ci-dessous pour en voir un aperçu ici.",
|
||||
"Upload logo": "Téléverser un logo",
|
||||
"Watermark": "Filigrane",
|
||||
"Watermark image": "Image du filigrane",
|
||||
"Watermark removed.": "Filigrane supprimé.",
|
||||
"Watermark settings saved.": "Paramètres du filigrane enregistrés.",
|
||||
"Watermark what clients and visitors see": "Filigraner ce que voient les clients et les visiteurs",
|
||||
"What clients will see": "Ce que les clients verront",
|
||||
"Your own artwork on this installation.": "Votre propre graphisme sur cette installation.",
|
||||
"Your own staff still see the version and licence on the About screen.": "Votre équipe voit toujours la version et la licence sur l'écran À propos.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Détecté à partir du serveur web. Définis PROJECTSEND_FILE_DELIVERY dans ton fichier .env pour choisir explicitement.",
|
||||
"Downloads are being sent by PHP": "Les téléchargements sont envoyés par PHP",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "Les téléchargements sont envoyés par PHP — pourquoi ce n'est pas optimal",
|
||||
"Downloads are not being handed to the web server": "Les téléchargements ne sont pas confiés au serveur web",
|
||||
"Downloads sent by": "Téléchargements envoyés par",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Tout fonctionne. Il s'agit de la charge que ton serveur peut encaisser, pas d'une panne.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "Les fichiers sont confiés à nginx, qui les envoie sans mobiliser un processus PHP. C'est l'option la plus rapide et elle ne demande rien de plus.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "Les fichiers sont confiés à ton serveur web via l'en-tête X-Sendfile, qui les envoie sans mobiliser un processus PHP.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Confier un fichier demande un en-tête de réponse, et chaque serveur web en lit un différent. ProjectSend ne l'envoie que lorsqu'il sait que le serveur y donnera suite, car un serveur qui l'ignore renvoie une réponse vide — qui arrive sous la forme d'un fichier de 0 octet.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Ici, PHP envoie les fichiers lui-même parce que PROJECTSEND_FILE_DELIVERY est réglé sur php.",
|
||||
"How downloads are sent": "Comment les téléchargements sont envoyés",
|
||||
"How to change it": "Comment changer cela",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "Il n'a pas reconnu le serveur placé devant lui, il s'est donc rabattu sur l'option qui fonctionne partout.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "Ou, sous Apache, installe mod_xsendfile et autorise ton répertoire de stockage avec XSendFilePath ; LiteSpeed n'a besoin d'aucun module. Définis ensuite PROJECTSEND_FILE_DELIVERY=xsendfile.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "Ou stocke les fichiers sur un stockage compatible S3 ou Google Cloud, pour que les téléchargements ne passent pas du tout par ton serveur.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP lit chaque fichier et l'envoie. Cela fonctionne sur tous les serveurs, mais occupe un processus PHP pendant toute la durée de chaque téléchargement.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Fais tourner ProjectSend derrière nginx, avec le bloc location d'INSTALL.md. Rien d'autre à régler — c'est détecté automatiquement.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Défini explicitement par PROJECTSEND_FILE_DELIVERY dans ton fichier .env.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "Cet avis reste affiché tant que PHP envoie les fichiers, y compris si ce choix est délibéré.",
|
||||
"Web server (nginx)": "Serveur web (nginx)",
|
||||
"Web server (X-Sendfile)": "Serveur web (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Les serveurs web font cela bien mieux : ils utilisent l'appel système prévu pour, gèrent la reprise et l'avance dans les vidéos, et un seul processus sert de nombreux transferts à la fois.",
|
||||
"What is happening": "Ce qui se passe",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Pendant que PHP envoie un fichier, un processus reste occupé pour tout le téléchargement. Quelques gros téléchargements simultanés peuvent occuper tous tes processus, et le site cesse de répondre — y compris pour ceux qui veulent seulement se connecter, alors que le processeur ne fait rien.",
|
||||
"Why it is set this way": "Pourquoi c'est réglé ainsi",
|
||||
"Why that is worth changing": "Pourquoi cela vaut la peine d'être changé",
|
||||
"Why this matters, and how to change it": "Pourquoi c'est important, et comment le changer",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Tes fichiers sont stockés hors de la racine web : chaque téléchargement passe donc d'abord par ProjectSend, qui vérifie que la personne a le droit de l'obtenir. Après cette vérification, PHP ouvre le fichier et l'envoie. L'autre solution est que PHP dise à ton serveur web « envoie ce fichier » et se termine aussitôt."
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "Akun klien baru telah dibuat: :name (:email).",
|
||||
"A new client has registered": "Klien baru telah mendaftar",
|
||||
"A new ProjectSend version is available": "Tersedia versi ProjectSend yang baru",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Tautan verifikasi baru telah dikirim ke alamat email yang Anda berikan saat mendaftar.",
|
||||
"A new verification link has been sent to your email address.": "Tautan verifikasi baru telah dikirim ke alamat email Anda.",
|
||||
"A public link was created": "Sebuah tautan publik dibuat",
|
||||
"A public link was revoked": "Sebuah tautan publik dicabut",
|
||||
"A role was created": "Sebuah peran dibuat",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Pilih Gambar",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Pilih notifikasi mana yang juga dikirim lewat email. Semuanya tetap selalu muncul di lonceng notifikasi.",
|
||||
"Clear": "Bersihkan",
|
||||
"Click here to re-send the verification email.": "Klik di sini untuk mengirim ulang email verifikasi.",
|
||||
"Click to copy": "Klik untuk menyalin",
|
||||
"Client": "Klien",
|
||||
"Client account approved": "Akun klien disetujui",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Komentar",
|
||||
"Confirm": "Konfirmasi",
|
||||
"Confirm password": "Konfirmasi kata sandi",
|
||||
"Confirm your password": "Konfirmasi kata sandi Anda",
|
||||
"Conflict": "Konflik",
|
||||
"Connect": "Menghubung",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Hubungkan bucket eksternal — kompatibel dengan S3 (AWS S3, MinIO, Backblaze) atau Google Cloud Storage — sebagai penyimpanan untuk unggahan baru.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "Membuat folder \":subject\"",
|
||||
"Created the group \":subject\"": "Membuat grup \":subject\"",
|
||||
"Created the role \":subject\"": "Membuat peran \":subject\"",
|
||||
"Current password": "Kata sandi saat ini",
|
||||
"Custom": "Kustom",
|
||||
"Custom assets": "Aset kustom",
|
||||
"Custom fields": "Bidang kustom",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Edisi",
|
||||
"Email": "Email",
|
||||
"Email address": "Alamat email",
|
||||
"Email password reset link": "Kirim tautan atur ulang kata sandi",
|
||||
"Email settings": "Pengaturan email",
|
||||
"Email template reset to default.": "Templat email dikembalikan ke teks bawaan.",
|
||||
"Email template saved.": "Templat email disimpan.",
|
||||
"Email templates": "Templat email",
|
||||
"Email verification": "Verifikasi email",
|
||||
"Empty file": "Berkas kosong",
|
||||
"Enable": "Nyalakan",
|
||||
"Enable the public directory page": "Aktifkan halaman direktori publik",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "Enkripsi file environment sudah ada.",
|
||||
"Encrypted environment file not found.": "Enkripsi file environment tidak ditemukan.",
|
||||
"Encryption": "Enkripsi",
|
||||
"Ensure your account is using a long, random password to stay secure": "Pastikan akun Anda menggunakan kata sandi yang panjang dan acak agar tetap aman",
|
||||
"Enter one of your recovery codes.": "Masukkan salah satu kode pemulihan Anda.",
|
||||
"Enter the six-digit code from your authenticator app.": "Masukkan kode enam digit dari aplikasi autentikator Anda.",
|
||||
"Enter your email and password below to log in": "Masukkan email dan kata sandi Anda di bawah ini untuk masuk",
|
||||
"Enter your email to receive a password reset link": "Masukkan email Anda untuk menerima tautan atur ulang kata sandi",
|
||||
"Environment": "Lingkungan",
|
||||
"Environment file already exists.": "File environment sudah ada.",
|
||||
"Environment file not found.": "file environment tidak ditemukan.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Folder hanya bisa dibagikan ke klien atau grup.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Folder tidak bisa bersarang lebih dari :max tingkat.",
|
||||
"Forbidden": "Dilarang",
|
||||
"Forgot password": "Lupa kata sandi",
|
||||
"Forgot password?": "Lupa kata sandi?",
|
||||
"Found": "Ditemukan",
|
||||
"From": "Dari",
|
||||
"From address": "Alamat pengirim",
|
||||
"From name": "Nama pengirim",
|
||||
"Full name": "Nama lengkap",
|
||||
"Gateway Timeout": "Gateway waktu habis",
|
||||
"General": "Umum",
|
||||
"General settings": "Pengaturan umum",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Terkunci",
|
||||
"Log in": "Masuk",
|
||||
"Log In": "Masuk",
|
||||
"log in": "halaman masuk",
|
||||
"Log in to your account": "Masuk ke akun Anda",
|
||||
"Log out": "Keluar",
|
||||
"Log Out": "Keluar",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Buka daftar publik",
|
||||
"Options": "Pilihan",
|
||||
"Or enter this key manually:": "Atau masukkan kunci ini secara manual:",
|
||||
"Or, return to": "Atau kembali ke",
|
||||
"Orange": "Oranye",
|
||||
"Origin Is Unreachable": "Asal tidak terjangkau",
|
||||
"Outgoing email": "Email keluar",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Konten parsial",
|
||||
"Password": "Kata sandi",
|
||||
"Password reset": "Penyetelan ulang kata sandi",
|
||||
"Password settings": "Pengaturan kata sandi",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Tempel berkas kunci JSON milik akun layanan yang punya akses baca dan tulis ke objek di bucket. Berkas disimpan terenkripsi dan tidak pernah ditampilkan lagi.",
|
||||
"Path": "Jalur",
|
||||
"Payload Too Large": "Payload terlalu besar",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Merah muda",
|
||||
"Platform": "Platform",
|
||||
"Please click the button below to verify your email address.": "Silakan klik tombol di bawah untuk memverifikasi alamat surel Anda.",
|
||||
"Please enter your new password below": "Silakan masukkan kata sandi baru Anda di bawah ini",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Silakan verifikasi alamat email Anda dengan mengeklik tautan yang baru saja kami kirimkan.",
|
||||
"Port": "Porta",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Ditenagai oleh ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Pribadi",
|
||||
"Processing": "Memproses",
|
||||
"Profile": "Profil",
|
||||
"Profile information": "Informasi profil",
|
||||
"Profile information was updated": "Informasi profil diperbarui",
|
||||
"Profile settings": "Pengaturan profil",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion sudah tersedia (Anda memakai :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend adalah perangkat lunak bebas dan sumber terbuka untuk mengirim file kepada klien Anda. Dirilis di bawah :license, yang berarti Anda bebas menggunakan, mempelajari, mengubah, dan membagikannya.",
|
||||
"ProjectSend is ready": "ProjectSend siap digunakan",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Keanggotaan yang diminta menunggu persetujuan di antrean permintaan keanggotaan.",
|
||||
"Require two-factor authentication": "Wajibkan autentikasi dua faktor",
|
||||
"Required": "Wajib",
|
||||
"Resend verification email": "Kirim ulang email verifikasi",
|
||||
"Reset Content": "Setel ulang konten",
|
||||
"Reset Password": "Atur Ulang Kata Sandi",
|
||||
"Reset password": "Atur ulang kata sandi",
|
||||
"Reset Password Notification": "Pemberitahuan Pengaturan Ulang Kata Sandi",
|
||||
"Reset this email to its default wording?": "Kembalikan email ini ke teks bawaannya?",
|
||||
"Reset to default": "Kembalikan ke bawaan",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": "Hemat :name",
|
||||
"Save & Close": "Simpan & Tutup",
|
||||
"Save & Return": "Simpan & Kembalikan",
|
||||
"Save password": "Simpan kata sandi",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Simpan perubahan Anda agar ini aktif — sebelum itu, tombol-tombol ini mengarah ke halaman yang belum diterbitkan.",
|
||||
"Saved": "Tersimpan",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Pindai kode QR ini dengan aplikasi autentikator Anda, lalu masukkan kode enam digit untuk mengonfirmasi.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Tema diperbarui.",
|
||||
"Theming": "Tema",
|
||||
"Theming settings": "Pengaturan tema",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "Akun ini masuk melalui direktori Anda, sehingga kata sandinya tidak diatur di sini. Hubungi administrator jika Anda tidak dapat masuk.",
|
||||
"This action is unauthorized.": "Tindakan ini tidak sah.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Berkas ini melampaui ukuran maksimum yang diizinkan, yaitu :max MB.",
|
||||
"This folder is empty.": "Folder ini kosong.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "Ini peran bawaan; cakupannya tidak bisa diubah.",
|
||||
"This is a preview of the :theme email theme.": "Ini pratinjau tema email :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "Ini contoh email — tidak ada notifikasi yang benar-benar dikirim.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Ini adalah area aman pada aplikasi. Silakan konfirmasi kata sandi Anda sebelum melanjutkan.",
|
||||
"This is a test email from :site.": "Ini email uji dari :site.",
|
||||
"This is the last active administrator account.": "Ini akun administrator aktif yang terakhir.",
|
||||
"This link cannot match the file's own public URL slug.": "Tautan ini tidak boleh sama dengan slug URL publik berkas itu sendiri.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Maksimal :max MB per berkas. Unggahan bisa dijeda dan otomatis berlanjut setelah terputus.",
|
||||
"Update": "Perbarui",
|
||||
"Update :name": "Pembaruan :name",
|
||||
"Update password": "Perbarui kata sandi",
|
||||
"Update your name and email address": "Perbarui nama dan alamat email Anda",
|
||||
"Updated the account \":subject\"": "Memperbarui akun \":subject\"",
|
||||
"Updated the file \":subject\"": "Memperbarui berkas \":subject\"",
|
||||
"Updated the group \":subject\"": "Memperbarui grup \":subject\"",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Pengguna",
|
||||
"Username": "Nama pengguna",
|
||||
"Variant Also Negotiates": "Varian Juga Nego",
|
||||
"Verify email": "Verifikasi email",
|
||||
"Verify Email Address": "Verifikasi Alamat Surel",
|
||||
"Version": "Versi",
|
||||
"Version :version": "Versi :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Administrator Anda mewajibkan autentikasi dua faktor pada akun ini. Siapkan di bawah ini untuk melanjutkan.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Subjek dan isi yang Anda sesuaikan akan langsung dibuang dan diganti dengan teks bawaan — tindakan ini tidak bisa dibatalkan.",
|
||||
"Your download should start automatically.": "Unduhan Anda semestinya dimulai otomatis.",
|
||||
"Your email address is unverified.": "Alamat email Anda belum diverifikasi.",
|
||||
"Your existing recovery codes will stop working immediately.": "Kode pemulihan Anda yang sekarang akan langsung tidak berlaku.",
|
||||
"Your group membership request was approved": "Permintaan keanggotaan grup Anda disetujui",
|
||||
"Your group membership request was denied": "Permintaan keanggotaan grup Anda ditolak",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest berkomentar pada berkas \":subject\"",
|
||||
":name — files": ":name — berkas",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated dari :requested berkas yang dipilih berhasil diperbarui. Sisanya dilewati karena Anda tidak punya izin mengubahnya.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": ":updated dari :requested berkas yang dipilih berhasil diperbarui. Sisanya dilewati karena Anda tidak punya izin melakukan perubahan tersebut.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Kepala surat yang tegas berpusat pada logo Anda — dipadukan dengan tampilan Gallery, kotak masuk terasa rapi dan sesuai identitas merek.",
|
||||
"Access": "Akses",
|
||||
"Access key": "Kunci akses",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Klien pada instalasi ini dibatasi hingga :count. Hapus satu, atau minta paket yang lebih besar.",
|
||||
":used of :limit staff seats used": ":used dari :limit akun sistem terpakai",
|
||||
":used of :limit client accounts used": ":used dari :limit akun klien terpakai",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Folder ini tidak dapat dihapus: berisi :count berkas yang tidak boleh kamu hapus.|Folder ini tidak dapat dihapus: berisi :count berkas yang tidak boleh kamu hapus."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Folder ini tidak dapat dihapus: berisi :count berkas yang tidak boleh kamu hapus.|Folder ini tidak dapat dihapus: berisi :count berkas yang tidak boleh kamu hapus.",
|
||||
"A PNG with a transparent background works best.": "PNG dengan latar transparan memberi hasil terbaik.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "Berlaku untuk gambar mini dan pratinjau. Yang sudah ada dibuat ulang saat berikutnya dilihat.",
|
||||
"A sample image with the watermark applied": "Contoh gambar dengan tanda air diterapkan",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Ini gambar contoh, bukan berkas Anda. Memilih tanda air baru di atas akan memperbaruinya setelah Anda simpan.",
|
||||
"Attribution": "Atribusi",
|
||||
"Bottom centre": "Bawah tengah",
|
||||
"Bottom left": "Kiri bawah",
|
||||
"Bottom right": "Kanan bawah",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "Secara bawaan, baris kecil \"Powered by ProjectSend\" muncul di halaman publik, di portal klien, dan di kaki email keluar. Matikan agar tidak meninggalkan jejak perangkat lunak yang dipakai klien Anda.",
|
||||
"Centre": "Tengah",
|
||||
"Choose a file only to replace the image above.": "Pilih berkas hanya jika ingin mengganti gambar di atas.",
|
||||
"Choose the image to use as the watermark.": "Pilih gambar yang akan dipakai sebagai tanda air.",
|
||||
"Hide \"Powered by ProjectSend\"": "Sembunyikan \"Powered by ProjectSend\"",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo dihapus.",
|
||||
"Logo updated.": "Logo diperbarui.",
|
||||
"Middle left": "Tengah kiri",
|
||||
"Middle right": "Tengah kanan",
|
||||
"No custom logo set — the default icon is shown.": "Belum ada logo sendiri — ikon bawaan yang ditampilkan.",
|
||||
"Opacity (%)": "Keburaman (%)",
|
||||
"Position": "Posisi",
|
||||
"Remove logo": "Hapus logo",
|
||||
"Remove watermark": "Hapus tanda air",
|
||||
"Save attribution settings": "Simpan pengaturan atribusi",
|
||||
"Saved.": "Tersimpan.",
|
||||
"Save watermark settings": "Simpan pengaturan tanda air",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Tampilkan logo Anda sendiri di bilah sisi menggantikan ikon bawaan.",
|
||||
"Size (% of the image)": "Ukuran (% dari gambar)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Bubuhkan gambar di atas gambar mini dan pratinjau yang dilihat klien dan pengunjung. Yang dilihat staf Anda di pengelola berkas tetap tanpa tanda, dan berkas aslinya — termasuk setiap unduhan — tidak pernah diubah.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "Tanda air diskalakan agar muat dalam bagian ini dari apa pun yang ditempelinya, dengan proporsi tetap — sehingga gambar mini dan pratinjau tampak seperti desain yang sama.",
|
||||
"Top centre": "Atas tengah",
|
||||
"Top left": "Kiri atas",
|
||||
"Top right": "Kanan atas",
|
||||
"Upload a watermark image below to see a preview here.": "Unggah gambar tanda air di bawah untuk melihat pratinjaunya di sini.",
|
||||
"Upload logo": "Unggah logo",
|
||||
"Watermark": "Tanda air",
|
||||
"Watermark image": "Gambar tanda air",
|
||||
"Watermark removed.": "Tanda air dihapus.",
|
||||
"Watermark settings saved.": "Pengaturan tanda air disimpan.",
|
||||
"Watermark what clients and visitors see": "Beri tanda air pada apa yang dilihat klien dan pengunjung",
|
||||
"What clients will see": "Yang akan dilihat klien",
|
||||
"Your own artwork on this installation.": "Karya visual Anda sendiri di instalasi ini.",
|
||||
"Your own staff still see the version and licence on the About screen.": "Staf Anda tetap melihat versi dan lisensi di layar Tentang.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Terdeteksi dari server web. Setel PROJECTSEND_FILE_DELIVERY di berkas .env Anda untuk memilih secara eksplisit.",
|
||||
"Downloads are being sent by PHP": "Unduhan dikirim oleh PHP",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "Unduhan dikirim oleh PHP — mengapa ini bukan pilihan optimal",
|
||||
"Downloads are not being handed to the web server": "Unduhan tidak diserahkan ke server web",
|
||||
"Downloads sent by": "Unduhan dikirim oleh",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Semuanya berfungsi. Ini soal seberapa besar beban yang sanggup ditanggung server Anda, bukan soal ada yang rusak.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "Berkas diserahkan ke nginx, yang mengirimkannya tanpa menahan satu proses PHP. Ini pilihan tercepat dan tidak perlu pengaturan lain.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "Berkas diserahkan ke server web Anda lewat header X-Sendfile, yang mengirimkannya tanpa menahan satu proses PHP.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Menyerahkan berkas memerlukan header respons, dan setiap server web membaca header yang berbeda. ProjectSend hanya mengirimkannya bila yakin server akan menindaklanjuti, karena server yang mengabaikannya justru mengirim respons kosong — yang sampai sebagai berkas berukuran 0 byte.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Di sini PHP sendiri yang mengirim berkas karena PROJECTSEND_FILE_DELIVERY disetel ke php.",
|
||||
"How downloads are sent": "Bagaimana unduhan dikirim",
|
||||
"How to change it": "Cara mengubahnya",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "Server di depannya tidak dikenali, jadi sistem kembali ke pilihan yang berfungsi di mana saja.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "Atau pada Apache, pasang mod_xsendfile dan izinkan direktori penyimpanan Anda dengan XSendFilePath; LiteSpeed tidak memerlukan modul. Lalu setel PROJECTSEND_FILE_DELIVERY=xsendfile.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "Atau simpan berkas di penyimpanan yang kompatibel dengan S3 atau di Google Cloud, sehingga unduhan sama sekali tidak melewati server Anda.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP membaca setiap berkas dan mengirimkannya. Cara ini berjalan di server mana pun, tetapi menahan satu proses pekerja PHP selama seluruh unduhan berlangsung.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Jalankan ProjectSend di belakang nginx, dengan blok location dari INSTALL.md. Tidak ada yang perlu disetel lagi — ini terdeteksi otomatis.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Disetel secara eksplisit oleh PROJECTSEND_FILE_DELIVERY di berkas .env Anda.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "Pemberitahuan ini tetap muncul selama berkas dikirim oleh PHP, termasuk bila itu memang dipilih dengan sengaja.",
|
||||
"Web server (nginx)": "Server web (nginx)",
|
||||
"Web server (X-Sendfile)": "Server web (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Server web jauh lebih baik dalam hal ini: mereka memakai system call yang memang dirancang untuk itu, menangani pelanjutan unduhan dan pencarian posisi video, dan satu proses melayani banyak transfer sekaligus.",
|
||||
"What is happening": "Apa yang sedang terjadi",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Selama PHP mengirim sebuah berkas, satu proses pekerja sibuk sepanjang unduhan. Beberapa unduhan besar sekaligus bisa menghabiskan seluruh proses yang Anda miliki, dan situs berhenti merespons — termasuk bagi orang yang hanya ingin masuk — sementara prosesornya menganggur.",
|
||||
"Why it is set this way": "Mengapa disetel seperti ini",
|
||||
"Why that is worth changing": "Mengapa ini layak diubah",
|
||||
"Why this matters, and how to change it": "Mengapa ini penting, dan cara mengubahnya",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Berkas Anda disimpan di luar akar web, jadi setiap unduhan melewati ProjectSend lebih dulu untuk memastikan orang tersebut memang berhak menerimanya. Setelah pemeriksaan itu, PHP membuka berkas dan mengirimkannya. Alternatifnya adalah PHP memberi tahu server web Anda “kirim berkas ini” lalu langsung selesai."
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "È stato creato un nuovo account cliente: :name (:email).",
|
||||
"A new client has registered": "Un nuovo cliente si è registrato",
|
||||
"A new ProjectSend version is available": "È disponibile una nuova versione di ProjectSend",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Un nuovo link di verifica è stato inviato all'indirizzo e-mail che hai indicato durante la registrazione.",
|
||||
"A new verification link has been sent to your email address.": "Un nuovo link di verifica è stato inviato al tuo indirizzo e-mail.",
|
||||
"A public link was created": "Un link pubblico è stato creato",
|
||||
"A public link was revoked": "Un link pubblico è stato revocato",
|
||||
"A role was created": "Un ruolo è stato creato",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Scegli Immagine",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Scegli quali notifiche ti vengono inviate anche via e-mail. Tutto compare comunque sempre nella campanella delle notifiche.",
|
||||
"Clear": "Azzera",
|
||||
"Click here to re-send the verification email.": "Fai clic qui per inviare di nuovo l'e-mail di verifica.",
|
||||
"Click to copy": "Fare clic per copiare",
|
||||
"Client": "Cliente",
|
||||
"Client account approved": "Account cliente approvato",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Commenta",
|
||||
"Confirm": "Conferma",
|
||||
"Confirm password": "Conferma password",
|
||||
"Confirm your password": "Conferma la tua password",
|
||||
"Conflict": "Conflitto",
|
||||
"Connect": "Collega",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Collega un bucket esterno — compatibile con S3 (AWS S3, MinIO, Backblaze) o Google Cloud Storage — come spazio di archiviazione per i nuovi caricamenti.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "Ha creato la cartella \":subject\"",
|
||||
"Created the group \":subject\"": "Ha creato il gruppo \":subject\"",
|
||||
"Created the role \":subject\"": "Ha creato il ruolo \":subject\"",
|
||||
"Current password": "Password attuale",
|
||||
"Custom": "Personalizzato",
|
||||
"Custom assets": "Risorse personalizzate",
|
||||
"Custom fields": "Campi personalizzati",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Edizione",
|
||||
"Email": "E-mail",
|
||||
"Email address": "Indirizzo e-mail",
|
||||
"Email password reset link": "Invia il link per reimpostare la password",
|
||||
"Email settings": "Impostazioni e-mail",
|
||||
"Email template reset to default.": "Modello e-mail ripristinato ai valori predefiniti.",
|
||||
"Email template saved.": "Modello e-mail salvato.",
|
||||
"Email templates": "Modelli e-mail",
|
||||
"Email verification": "Verifica e-mail",
|
||||
"Empty file": "File vuoto",
|
||||
"Enable": "Abilita",
|
||||
"Enable the public directory page": "Attiva la pagina dell'elenco pubblico",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "Il file di ambiente criptato esiste già.",
|
||||
"Encrypted environment file not found.": "Non è stato trovato il file di ambiente criptato.",
|
||||
"Encryption": "Crittografia",
|
||||
"Ensure your account is using a long, random password to stay secure": "Assicurati che il tuo account usi una password lunga e casuale per restare al sicuro",
|
||||
"Enter one of your recovery codes.": "Inserisci uno dei tuoi codici di recupero.",
|
||||
"Enter the six-digit code from your authenticator app.": "Inserisci il codice a sei cifre della tua app di autenticazione.",
|
||||
"Enter your email and password below to log in": "Inserisci qui sotto la tua e-mail e la tua password per accedere",
|
||||
"Enter your email to receive a password reset link": "Inserisci la tua e-mail per ricevere un link di reimpostazione della password",
|
||||
"Environment": "Ambiente",
|
||||
"Environment file already exists.": "Il file di ambiente esiste già.",
|
||||
"Environment file not found.": "File di ambiente non trovato.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Le cartelle possono essere condivise solo con clienti o gruppi.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Le cartelle non possono essere annidate per più di :max livelli.",
|
||||
"Forbidden": "Vietato",
|
||||
"Forgot password": "Password dimenticata",
|
||||
"Forgot password?": "Password dimenticata?",
|
||||
"Found": "Trovato",
|
||||
"From": "Da",
|
||||
"From address": "Indirizzo mittente",
|
||||
"From name": "Nome mittente",
|
||||
"Full name": "Nome completo",
|
||||
"Gateway Timeout": "Tempo scaduto per il gateway",
|
||||
"General": "Generale",
|
||||
"General settings": "Impostazioni generali",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Bloccata",
|
||||
"Log in": "Accedi",
|
||||
"Log In": "Accedi",
|
||||
"log in": "login",
|
||||
"Log in to your account": "Accedi al tuo account",
|
||||
"Log out": "Esci",
|
||||
"Log Out": "Esci",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Apri l'elenco pubblico",
|
||||
"Options": "Opzioni",
|
||||
"Or enter this key manually:": "Oppure inserisci questa chiave manualmente:",
|
||||
"Or, return to": "Oppure torna al",
|
||||
"Orange": "Arancione",
|
||||
"Origin Is Unreachable": "Origine non raggiungibile",
|
||||
"Outgoing email": "E-mail in uscita",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Contenuto parziale",
|
||||
"Password": "Password",
|
||||
"Password reset": "Reimpostazione della password",
|
||||
"Password settings": "Impostazioni password",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Incolla il file di chiave JSON di un account di servizio con accesso in lettura e scrittura agli oggetti del bucket. Viene salvato cifrato e non viene più mostrato.",
|
||||
"Path": "Percorso",
|
||||
"Payload Too Large": "Payload troppo grande",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Rosa",
|
||||
"Platform": "Piattaforma",
|
||||
"Please click the button below to verify your email address.": "Clicca sul pulsante qui sotto per verificare il tuo indirizzo email.",
|
||||
"Please enter your new password below": "Inserisci qui sotto la tua nuova password",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Verifica il tuo indirizzo e-mail facendo clic sul link che ti abbiamo appena inviato.",
|
||||
"Port": "Porta",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Basato su ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Privato",
|
||||
"Processing": "In processo",
|
||||
"Profile": "Profilo",
|
||||
"Profile information": "Informazioni del profilo",
|
||||
"Profile information was updated": "Le informazioni del profilo sono state aggiornate",
|
||||
"Profile settings": "Impostazioni del profilo",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion è disponibile (tu hai la :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend è software libero e open source per inviare file ai tuoi clienti. È rilasciato sotto la :license, il che significa che sei libero di usarlo, studiarlo, modificarlo e condividerlo.",
|
||||
"ProjectSend is ready": "ProjectSend è pronto",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Le adesioni richieste restano in attesa di approvazione nella coda delle richieste di adesione.",
|
||||
"Require two-factor authentication": "Richiedi l'autenticazione a due fattori",
|
||||
"Required": "Obbligatorio",
|
||||
"Resend verification email": "Invia di nuovo l'e-mail di verifica",
|
||||
"Reset Content": "Resetta il contenuto",
|
||||
"Reset Password": "Reimposta password",
|
||||
"Reset password": "Reimposta la password",
|
||||
"Reset Password Notification": "Notifica di reset della password",
|
||||
"Reset this email to its default wording?": "Ripristinare il testo predefinito di questa e-mail?",
|
||||
"Reset to default": "Ripristina i valori predefiniti",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": "Salva :name",
|
||||
"Save & Close": "Salva e chiudi",
|
||||
"Save & Return": "Salva e ritorna",
|
||||
"Save password": "Salva password",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Salva le modifiche per pubblicarlo — fino ad allora questi pulsanti rimandano a una pagina non ancora pubblicata.",
|
||||
"Saved": "Salvato",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Scansiona questo codice QR con la tua app di autenticazione, poi inserisci il codice a sei cifre per confermare.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Tema aggiornato.",
|
||||
"Theming": "Temi",
|
||||
"Theming settings": "Impostazioni dei temi",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "Questo account accede tramite la tua directory, quindi la sua password non si imposta qui. Rivolgiti a un amministratore se non riesci ad accedere.",
|
||||
"This action is unauthorized.": "Questa azione non è autorizzata.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Questo file supera la dimensione massima consentita di :max MB.",
|
||||
"This folder is empty.": "Questa cartella è vuota.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "Questo è un ruolo predefinito; il suo ambito non può essere modificato.",
|
||||
"This is a preview of the :theme email theme.": "Questa è un'anteprima del tema e-mail :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "Questa è un'e-mail di esempio — nessuna notifica è stata realmente inviata.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Questa è un'area protetta dell'applicazione. Conferma la tua password prima di continuare.",
|
||||
"This is a test email from :site.": "Questa è un'e-mail di prova da :site.",
|
||||
"This is the last active administrator account.": "Questo è l'ultimo account amministratore attivo.",
|
||||
"This link cannot match the file's own public URL slug.": "Questo link non può coincidere con lo slug dell'URL pubblico del file stesso.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Fino a :max MB per file. I caricamenti possono essere messi in pausa e riprendono automaticamente dopo un'interruzione.",
|
||||
"Update": "Aggiorna",
|
||||
"Update :name": "Aggiorna :name",
|
||||
"Update password": "Aggiorna password",
|
||||
"Update your name and email address": "Aggiorna il tuo nome e il tuo indirizzo e-mail",
|
||||
"Updated the account \":subject\"": "Ha aggiornato l'account \":subject\"",
|
||||
"Updated the file \":subject\"": "Ha aggiornato il file \":subject\"",
|
||||
"Updated the group \":subject\"": "Ha aggiornato il gruppo \":subject\"",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Utenti",
|
||||
"Username": "Nome utente",
|
||||
"Variant Also Negotiates": "Variante anche negozia",
|
||||
"Verify email": "Verifica e-mail",
|
||||
"Verify Email Address": "Verifica indirizzo email",
|
||||
"Version": "Versione",
|
||||
"Version :version": "Versione :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Il tuo amministratore richiede l'autenticazione a due fattori su questo account. Configurala qui sotto per continuare.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "L'oggetto e il corpo personalizzati verranno subito scartati e sostituiti con il testo predefinito — l'operazione non è reversibile.",
|
||||
"Your download should start automatically.": "Il download dovrebbe iniziare automaticamente.",
|
||||
"Your email address is unverified.": "Il tuo indirizzo e-mail non è verificato.",
|
||||
"Your existing recovery codes will stop working immediately.": "I tuoi codici di recupero attuali smetteranno di funzionare immediatamente.",
|
||||
"Your group membership request was approved": "La tua richiesta di adesione al gruppo è stata approvata",
|
||||
"Your group membership request was denied": "La tua richiesta di adesione al gruppo è stata rifiutata",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest ha commentato il file \":subject\"",
|
||||
":name — files": ":name — file",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated file sui :requested selezionati sono stati aggiornati. Gli altri sono stati saltati perché non hai l'autorizzazione per modificarli.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": ":updated file sui :requested selezionati sono stati aggiornati. Gli altri sono stati saltati perché non hai l'autorizzazione per apportare queste modifiche.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Un'intestazione decisa costruita attorno al tuo logo — si abbina allo stile Gallery per una casella di posta curata e coerente con il tuo marchio.",
|
||||
"Access": "Accesso",
|
||||
"Access key": "Chiave di accesso",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "I clienti di questa installazione sono limitati a :count. Rimuovine uno o chiedi un piano più grande.",
|
||||
":used of :limit staff seats used": "Usati :used di :limit account di sistema",
|
||||
":used of :limit client accounts used": "Usati :used di :limit account cliente",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Questa cartella non può essere eliminata: contiene :count file che non puoi eliminare.|Questa cartella non può essere eliminata: contiene :count file che non puoi eliminare."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Questa cartella non può essere eliminata: contiene :count file che non puoi eliminare.|Questa cartella non può essere eliminata: contiene :count file che non puoi eliminare.",
|
||||
"A PNG with a transparent background works best.": "Un PNG con sfondo trasparente funziona meglio.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "Vale per le miniature e le anteprime. Quelle esistenti vengono rigenerate alla prossima visualizzazione.",
|
||||
"A sample image with the watermark applied": "Un'immagine di esempio con la filigrana applicata",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Un'immagine segnaposto, non un tuo file. Scegliendo una nuova filigrana qui sopra si aggiorna quando salvi.",
|
||||
"Attribution": "Attribuzione",
|
||||
"Bottom centre": "In basso al centro",
|
||||
"Bottom left": "In basso a sinistra",
|
||||
"Bottom right": "In basso a destra",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "Per impostazione predefinita una piccola riga «Powered by ProjectSend» compare sulle pagine pubbliche, nel portale clienti e in fondo alle e-mail in uscita. Disattivala per non lasciare traccia del software che i tuoi clienti stanno usando.",
|
||||
"Centre": "Centro",
|
||||
"Choose a file only to replace the image above.": "Scegli un file solo per sostituire l'immagine qui sopra.",
|
||||
"Choose the image to use as the watermark.": "Scegli l'immagine da usare come filigrana.",
|
||||
"Hide \"Powered by ProjectSend\"": "Nascondi «Powered by ProjectSend»",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo rimosso.",
|
||||
"Logo updated.": "Logo aggiornato.",
|
||||
"Middle left": "Al centro a sinistra",
|
||||
"Middle right": "Al centro a destra",
|
||||
"No custom logo set — the default icon is shown.": "Nessun logo personalizzato — viene mostrata l'icona predefinita.",
|
||||
"Opacity (%)": "Opacità (%)",
|
||||
"Position": "Posizione",
|
||||
"Remove logo": "Rimuovi il logo",
|
||||
"Remove watermark": "Rimuovi la filigrana",
|
||||
"Save attribution settings": "Salva le impostazioni di attribuzione",
|
||||
"Saved.": "Salvato.",
|
||||
"Save watermark settings": "Salva le impostazioni della filigrana",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Mostra il tuo logo nella barra laterale al posto dell'icona predefinita.",
|
||||
"Size (% of the image)": "Dimensione (% dell'immagine)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Applica un'immagine sopra le miniature e le anteprime che vedono clienti e visitatori. Quello che il tuo staff vede nel gestore file resta senza filigrana, e i file originali — compreso ogni download — non vengono mai alterati.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "La filigrana viene ridimensionata per stare dentro questa quota di ciò su cui è disegnata, mantenendo le proporzioni: così una miniatura e un'anteprima sembrano lo stesso disegno.",
|
||||
"Top centre": "In alto al centro",
|
||||
"Top left": "In alto a sinistra",
|
||||
"Top right": "In alto a destra",
|
||||
"Upload a watermark image below to see a preview here.": "Carica qui sotto un'immagine di filigrana per vederne un'anteprima qui.",
|
||||
"Upload logo": "Carica un logo",
|
||||
"Watermark": "Filigrana",
|
||||
"Watermark image": "Immagine della filigrana",
|
||||
"Watermark removed.": "Filigrana rimossa.",
|
||||
"Watermark settings saved.": "Impostazioni della filigrana salvate.",
|
||||
"Watermark what clients and visitors see": "Filigrana su ciò che vedono clienti e visitatori",
|
||||
"What clients will see": "Cosa vedranno i clienti",
|
||||
"Your own artwork on this installation.": "La tua grafica su questa installazione.",
|
||||
"Your own staff still see the version and licence on the About screen.": "Il tuo staff continua a vedere versione e licenza nella schermata Informazioni.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Rilevato dal server web. Imposta PROJECTSEND_FILE_DELIVERY nel tuo file .env per sceglierlo esplicitamente.",
|
||||
"Downloads are being sent by PHP": "I download vengono inviati da PHP",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "I download vengono inviati da PHP — perché non è la soluzione ottimale",
|
||||
"Downloads are not being handed to the web server": "I download non vengono affidati al server web",
|
||||
"Downloads sent by": "Download inviati da",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Funziona tutto. Si tratta di quanto carico regge il tuo server, non di qualcosa che si è rotto.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "I file vengono affidati a nginx, che li invia senza tenere occupato un processo PHP. È l'opzione più veloce e non richiede altro.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "I file vengono affidati al tuo server web con l'header X-Sendfile, che li invia senza tenere occupato un processo PHP.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Affidare un file richiede un header di risposta, e ogni server web ne legge uno diverso. ProjectSend lo invia solo quando sa che il server lo interpreterà, perché un server che lo ignora invia invece una risposta vuota, che arriva come un file da 0 byte.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Qui è PHP stesso a inviare i file perché PROJECTSEND_FILE_DELIVERY è impostato su php.",
|
||||
"How downloads are sent": "Come vengono inviati i download",
|
||||
"How to change it": "Come cambiarlo",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "Non ha riconosciuto il server che ha davanti, quindi è tornato all'opzione che funziona ovunque.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "Oppure, su Apache, installa mod_xsendfile e autorizza la tua directory di archiviazione con XSendFilePath; LiteSpeed non richiede alcun modulo. Poi imposta PROJECTSEND_FILE_DELIVERY=xsendfile.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "Oppure archivia i file su storage compatibile con S3 o su Google Cloud, così i download non passano affatto dal tuo server.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP legge ogni file e lo invia. Funziona su qualsiasi server, ma occupa un processo di lavoro PHP per l'intera durata di ogni download.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Esegui ProjectSend dietro nginx, con il blocco location di INSTALL.md. Non c'è altro da impostare: viene rilevato.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Impostato esplicitamente da PROJECTSEND_FILE_DELIVERY nel tuo file .env.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "Questo avviso resta finché è PHP a inviare i file, anche quando è una scelta deliberata.",
|
||||
"Web server (nginx)": "Server web (nginx)",
|
||||
"Web server (X-Sendfile)": "Server web (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "I server web lo fanno molto meglio: usano la chiamata di sistema pensata per questo, gestiscono la ripresa e lo scorrimento dei video, e un solo processo serve molti trasferimenti insieme.",
|
||||
"What is happening": "Che cosa sta succedendo",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Mentre PHP invia un file, un processo di lavoro resta occupato per tutta la durata del download. Pochi download di grandi dimensioni contemporanei possono occupare tutti i processi disponibili e il sito smette di rispondere — anche per chi vuole solo accedere, con il processore fermo.",
|
||||
"Why it is set this way": "Perché è impostato così",
|
||||
"Why that is worth changing": "Perché vale la pena cambiarlo",
|
||||
"Why this matters, and how to change it": "Perché è importante e come cambiarlo",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "I tuoi file sono archiviati fuori dalla radice web, quindi ogni download passa prima da ProjectSend, che verifica che chi lo chiede possa averlo. Dopo quel controllo, PHP apre il file e lo invia. L'alternativa è che PHP dica al tuo server web «invia questo file» e concluda subito."
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "新しいクライアントアカウントが作成されました: :name (:email)。",
|
||||
"A new client has registered": "新しいクライアントが登録しました",
|
||||
"A new ProjectSend version is available": "ProjectSend の新しいバージョンが利用できます",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "登録時に入力されたメールアドレスに、新しい確認リンクを送信しました。",
|
||||
"A new verification link has been sent to your email address.": "あなたのメールアドレスに新しい確認リンクを送信しました。",
|
||||
"A public link was created": "公開リンクが作成されました",
|
||||
"A public link was revoked": "公開リンクが無効化されました",
|
||||
"A role was created": "ロールが作成されました",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "画像の選択",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "メールでも受け取る通知を選んでください。すべての通知は、いずれにせよ通知ベルに表示されます。",
|
||||
"Clear": "クリア",
|
||||
"Click here to re-send the verification email.": "こちらをクリックすると確認メールを再送信します。",
|
||||
"Click to copy": "クリックしてコピー",
|
||||
"Client": "クライアント",
|
||||
"Client account approved": "クライアントアカウントを承認",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "コメント",
|
||||
"Confirm": "確認",
|
||||
"Confirm password": "パスワードの確認",
|
||||
"Confirm your password": "パスワードを確認してください",
|
||||
"Conflict": "競合",
|
||||
"Connect": "接続",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "新しいアップロードの保存先として、外部のバケット (S3 互換 — AWS S3、MinIO、Backblaze — または Google Cloud Storage) を接続します。",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "フォルダ「:subject」を作成しました",
|
||||
"Created the group \":subject\"": "グループ「:subject」を作成しました",
|
||||
"Created the role \":subject\"": "ロール「:subject」を作成しました",
|
||||
"Current password": "現在のパスワード",
|
||||
"Custom": "カスタム",
|
||||
"Custom assets": "カスタムアセット",
|
||||
"Custom fields": "カスタムフィールド",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "エディション",
|
||||
"Email": "メール",
|
||||
"Email address": "メールアドレス",
|
||||
"Email password reset link": "パスワード再設定リンクを送信",
|
||||
"Email settings": "メール設定",
|
||||
"Email template reset to default.": "メールテンプレートを既定の文面に戻しました。",
|
||||
"Email template saved.": "メールテンプレートを保存しました。",
|
||||
"Email templates": "メールテンプレート",
|
||||
"Email verification": "メールアドレスの確認",
|
||||
"Empty file": "空のファイル",
|
||||
"Enable": "有効化",
|
||||
"Enable the public directory page": "公開ディレクトリページを有効にする",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "暗号化されたenvファイルが既に存在しています。",
|
||||
"Encrypted environment file not found.": "暗号化されたenvファイルが見つかりません。",
|
||||
"Encryption": "暗号化",
|
||||
"Ensure your account is using a long, random password to stay secure": "アカウントを安全に保つため、長くランダムなパスワードを使用してください",
|
||||
"Enter one of your recovery codes.": "リカバリーコードのいずれかを入力してください。",
|
||||
"Enter the six-digit code from your authenticator app.": "認証アプリに表示される 6 桁のコードを入力してください。",
|
||||
"Enter your email and password below to log in": "ログインするには、以下にメールアドレスとパスワードを入力してください",
|
||||
"Enter your email to receive a password reset link": "パスワード再設定リンクを受け取るメールアドレスを入力してください",
|
||||
"Environment": "動作環境",
|
||||
"Environment file already exists.": "envファイルが既に存在しています。",
|
||||
"Environment file not found.": "envファイルが見つかりません。",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "フォルダを共有できるのはクライアントかグループだけです。",
|
||||
"Folders cannot be nested more than :max levels deep.": "フォルダは :max 階層より深くは入れ子にできません。",
|
||||
"Forbidden": "禁止されています",
|
||||
"Forgot password": "パスワードをお忘れの方",
|
||||
"Forgot password?": "パスワードをお忘れですか?",
|
||||
"Found": "発見",
|
||||
"From": "差出人",
|
||||
"From address": "差出人アドレス",
|
||||
"From name": "差出人名",
|
||||
"Full name": "氏名",
|
||||
"Gateway Timeout": "ゲートウェイのタイムアウト",
|
||||
"General": "全般",
|
||||
"General settings": "全般設定",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "ロック済",
|
||||
"Log in": "ログイン",
|
||||
"Log In": "ログイン",
|
||||
"log in": "ログインに戻る",
|
||||
"Log in to your account": "アカウントにログイン",
|
||||
"Log out": "ログアウト",
|
||||
"Log Out": "ログアウト",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "公開一覧を開く",
|
||||
"Options": "選択肢",
|
||||
"Or enter this key manually:": "または、次のキーを手動で入力してください:",
|
||||
"Or, return to": "または",
|
||||
"Orange": "オレンジ",
|
||||
"Origin Is Unreachable": "オリジンに到達できません",
|
||||
"Outgoing email": "送信メール",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "部分的なコンテンツ",
|
||||
"Password": "パスワード",
|
||||
"Password reset": "パスワードの再設定",
|
||||
"Password settings": "パスワード設定",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "バケット内のオブジェクトへの読み取りと書き込み権限を持つサービスアカウントの JSON キーファイルを貼り付けてください。暗号化して保存され、二度と表示されません。",
|
||||
"Path": "パス",
|
||||
"Payload Too Large": "ペイロードが大きすぎます",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "ピンク",
|
||||
"Platform": "プラットフォーム",
|
||||
"Please click the button below to verify your email address.": "メールアドレスを確認するには、以下のボタンをクリックしてください。",
|
||||
"Please enter your new password below": "新しいパスワードを以下に入力してください",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "先ほどお送りしたリンクをクリックして、メールアドレスを確認してください。",
|
||||
"Port": "ポート",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "ProjectSend で動作しています",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "非公開",
|
||||
"Processing": "処理中",
|
||||
"Profile": "プロフィール",
|
||||
"Profile information": "プロフィール情報",
|
||||
"Profile information was updated": "プロフィール情報が更新されました",
|
||||
"Profile settings": "プロフィール設定",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion が利用できます (現在は :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend は、クライアントにファイルを届けるための自由でオープンソースなソフトウェアです。:license のもとで公開されており、自由に使用・研究・改変・共有できます。",
|
||||
"ProjectSend is ready": "ProjectSend の準備が整いました",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "申請した参加は、参加申請の一覧で承認を待ちます。",
|
||||
"Require two-factor authentication": "二要素認証を必須にする",
|
||||
"Required": "必須",
|
||||
"Resend verification email": "確認メールを再送信",
|
||||
"Reset Content": "コンテンツのリセット",
|
||||
"Reset Password": "パスワード再設定",
|
||||
"Reset password": "パスワードを再設定",
|
||||
"Reset Password Notification": "パスワード再設定のお知らせ",
|
||||
"Reset this email to its default wording?": "このメールを既定の文面に戻しますか?",
|
||||
"Reset to default": "既定に戻す",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": ":nameを保存",
|
||||
"Save & Close": "保存して閉じる",
|
||||
"Save & Return": "保存して戻る",
|
||||
"Save password": "パスワードを保存",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "変更を保存すると公開されます。それまでは、これらのボタンは未公開のページを指しています。",
|
||||
"Saved": "保存しました",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "この QR コードを認証アプリで読み取り、表示された 6 桁のコードを入力して確認してください。",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "テーマを更新しました。",
|
||||
"Theming": "テーマ",
|
||||
"Theming settings": "テーマ設定",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "このアカウントはディレクトリ経由でサインインするため、パスワードはここでは設定しません。サインインできない場合は管理者にお問い合わせください。",
|
||||
"This action is unauthorized.": "この行為は許可されていません。",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "このファイルは、許可されている最大サイズ :max MB を超えています。",
|
||||
"This folder is empty.": "このフォルダは空です。",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "これは組み込みロールです。適用範囲は変更できません。",
|
||||
"This is a preview of the :theme email theme.": "これはメールテーマ :theme のプレビューです。",
|
||||
"This is a sample email — no notification was actually sent.": "これはサンプルメールです。実際に通知は送信されていません。",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "ここはアプリケーションの保護された領域です。続行する前にパスワードを確認してください。",
|
||||
"This is a test email from :site.": "これは :site からのテストメールです。",
|
||||
"This is the last active administrator account.": "これが最後の有効な管理者アカウントです。",
|
||||
"This link cannot match the file's own public URL slug.": "このリンクは、ファイル自身の公開 URL スラッグと同じにはできません。",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "1 ファイルにつき最大 :max MB です。アップロードは一時停止でき、中断後は自動的に再開します。",
|
||||
"Update": "更新",
|
||||
"Update :name": ":nameを更新",
|
||||
"Update password": "パスワードの変更",
|
||||
"Update your name and email address": "名前とメールアドレスを更新します",
|
||||
"Updated the account \":subject\"": "アカウント「:subject」を更新しました",
|
||||
"Updated the file \":subject\"": "ファイル「:subject」を更新しました",
|
||||
"Updated the group \":subject\"": "グループ「:subject」を更新しました",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "ユーザー",
|
||||
"Username": "ユーザー名",
|
||||
"Variant Also Negotiates": "バリアントの再認証",
|
||||
"Verify email": "メールアドレスを確認",
|
||||
"Verify Email Address": "メールアドレスの確認",
|
||||
"Version": "バージョン",
|
||||
"Version :version": "バージョン :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "管理者がこのアカウントに二要素認証を必須としています。続けるには以下で設定してください。",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "カスタマイズした件名と本文はただちに破棄され、既定の文面に置き換えられます。この操作は取り消せません。",
|
||||
"Your download should start automatically.": "ダウンロードは自動的に始まります。",
|
||||
"Your email address is unverified.": "メールアドレスが未確認です。",
|
||||
"Your existing recovery codes will stop working immediately.": "現在のリカバリーコードはただちに使えなくなります。",
|
||||
"Your group membership request was approved": "グループ参加の申請が承認されました",
|
||||
"Your group membership request was denied": "グループ参加の申請が却下されました",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest がファイル「:subject」にコメントしました",
|
||||
":name — files": ":name — ファイル",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "選択した :requested 件のうち :updated 件を更新しました。残りは編集権限がないためスキップされました。",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": "選択した :requested 件のうち :updated 件を更新しました。残りはこれらの変更を行う権限がないためスキップされました。",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "ロゴを主役にした力強いヘッダー。Gallery の見た目と組み合わせれば、ブランドらしい洗練された受信トレイになります。",
|
||||
"Access": "アクセス",
|
||||
"Access key": "アクセスキー",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "このインストールの取引先の上限は :count です。1 件削除するか、上位のプランをご依頼ください。",
|
||||
":used of :limit staff seats used": "システムアカウント :limit 件中 :used 件を使用中",
|
||||
":used of :limit client accounts used": "クライアントアカウント :limit 件中 :used 件を使用中",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "このフォルダーは削除できません:削除権限のないファイルが :count 件含まれています。|このフォルダーは削除できません:削除権限のないファイルが :count 件含まれています。"
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "このフォルダーは削除できません:削除権限のないファイルが :count 件含まれています。|このフォルダーは削除できません:削除権限のないファイルが :count 件含まれています。",
|
||||
"A PNG with a transparent background works best.": "背景が透明な PNG が最適です。",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "サムネイルとプレビューに適用されます。既存のものは次に表示されるときに作り直されます。",
|
||||
"A sample image with the watermark applied": "透かしを適用したサンプル画像",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "あなたのファイルではなく、見本の画像です。上で新しい透かしを選ぶと、保存後にここも変わります。",
|
||||
"Attribution": "クレジット表記",
|
||||
"Bottom centre": "下中央",
|
||||
"Bottom left": "左下",
|
||||
"Bottom right": "右下",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "初期状態では、公開ページ・クライアントポータル・送信メールの末尾に小さな「Powered by ProjectSend」の行が表示されます。クライアントが使っているソフトウェアの痕跡を残したくない場合はオフにしてください。",
|
||||
"Centre": "中央",
|
||||
"Choose a file only to replace the image above.": "上の画像を差し替えるときだけファイルを選んでください。",
|
||||
"Choose the image to use as the watermark.": "透かしに使う画像を選んでください。",
|
||||
"Hide \"Powered by ProjectSend\"": "「Powered by ProjectSend」を隠す",
|
||||
"Logo": "ロゴ",
|
||||
"Logo removed.": "ロゴを削除しました。",
|
||||
"Logo updated.": "ロゴを更新しました。",
|
||||
"Middle left": "中央左",
|
||||
"Middle right": "中央右",
|
||||
"No custom logo set — the default icon is shown.": "独自のロゴは未設定です — 既定のアイコンが表示されます。",
|
||||
"Opacity (%)": "不透明度 (%)",
|
||||
"Position": "位置",
|
||||
"Remove logo": "ロゴを削除",
|
||||
"Remove watermark": "透かしを削除",
|
||||
"Save attribution settings": "クレジット表記の設定を保存",
|
||||
"Saved.": "保存しました。",
|
||||
"Save watermark settings": "透かしの設定を保存",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "サイドバーに既定のアイコンではなく自分のロゴを表示します。",
|
||||
"Size (% of the image)": "サイズ (画像に対する %)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "クライアントや訪問者が見るサムネイルとプレビューに画像を重ねます。スタッフがファイル管理画面で見るものには入らず、元のファイル — ダウンロードされるものも含め — は一切変更されません。",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "透かしは描画先のこの割合に収まるよう、比率を保ったまま拡大縮小されます。そのためサムネイルもプレビューも同じデザインに見えます。",
|
||||
"Top centre": "上中央",
|
||||
"Top left": "左上",
|
||||
"Top right": "右上",
|
||||
"Upload a watermark image below to see a preview here.": "下から透かし画像をアップロードすると、ここにプレビューが表示されます。",
|
||||
"Upload logo": "ロゴをアップロード",
|
||||
"Watermark": "透かし",
|
||||
"Watermark image": "透かし画像",
|
||||
"Watermark removed.": "透かしを削除しました。",
|
||||
"Watermark settings saved.": "透かしの設定を保存しました。",
|
||||
"Watermark what clients and visitors see": "クライアントと訪問者が見るものに透かしを入れる",
|
||||
"What clients will see": "クライアントに見えるもの",
|
||||
"Your own artwork on this installation.": "このインストールにあなた自身のデザインを。",
|
||||
"Your own staff still see the version and licence on the About screen.": "スタッフには引き続き「情報」画面でバージョンとライセンスが表示されます。",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Web サーバーから自動検出されました。明示的に指定するには .env ファイルで PROJECTSEND_FILE_DELIVERY を設定してください。",
|
||||
"Downloads are being sent by PHP": "ダウンロードは PHP が送信しています",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "ダウンロードは PHP が送信しています — これが最適でない理由",
|
||||
"Downloads are not being handed to the web server": "ダウンロードが Web サーバーに引き渡されていません",
|
||||
"Downloads sent by": "ダウンロードの送信元",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "動作に問題はありません。これはサーバーがどれだけの負荷に耐えられるかという話であり、何かが壊れているわけではありません。",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "ファイルは nginx に引き渡され、PHP のプロセスを占有せずに送信されます。最も高速な方法で、追加の設定は不要です。",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "ファイルは X-Sendfile ヘッダーで Web サーバーに引き渡され、PHP のプロセスを占有せずに送信されます。",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "ファイルを引き渡すにはレスポンスヘッダーが必要ですが、その名前は Web サーバーごとに異なります。ProjectSend はサーバーがそのヘッダーを処理すると分かっている場合にのみ送信します。無視するサーバーでは代わりに空のレスポンスが返り、0 バイトのファイルとして届いてしまうからです。",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "ここでは PROJECTSEND_FILE_DELIVERY が php に設定されているため、PHP 自身がファイルを送信しています。",
|
||||
"How downloads are sent": "ダウンロードの送信方法",
|
||||
"How to change it": "変更方法",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "手前の Web サーバーを認識できなかったため、どこでも動作する方法にフォールバックしました。",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "または Apache であれば mod_xsendfile を導入し、XSendFilePath でストレージディレクトリを許可してください(LiteSpeed にモジュールは不要です)。そのうえで PROJECTSEND_FILE_DELIVERY=xsendfile を設定します。",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "または、S3 互換ストレージや Google Cloud にファイルを保存すれば、ダウンロードがサーバーを経由しなくなります。",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP が各ファイルを読み込んで送信しています。どのサーバーでも動作しますが、ダウンロードのあいだ PHP のワーカープロセスを 1 つ占有し続けます。",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "INSTALL.md の location ブロックを使い、nginx の背後で ProjectSend を動かしてください。ほかに設定は不要で、自動的に検出されます。",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": ".env ファイルの PROJECTSEND_FILE_DELIVERY で明示的に設定されています。",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "この通知は PHP がファイルを送信しているあいだ表示され続けます。意図的にそう設定した場合も同様です。",
|
||||
"Web server (nginx)": "Web サーバー (nginx)",
|
||||
"Web server (X-Sendfile)": "Web サーバー (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Web サーバーはこの処理がはるかに得意です。専用のシステムコールを使い、再開や動画のシークにも対応し、1 つのプロセスで多数の転送を同時に処理できます。",
|
||||
"What is happening": "何が起きているか",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "PHP がファイルを送信しているあいだ、ワーカープロセスが 1 つダウンロード中ずっと占有されます。大きなダウンロードが数件重なるだけで全ワーカーが埋まり、CPU は空いているのにサイトが応答しなくなります。ログインしようとしているだけの人にも影響します。",
|
||||
"Why it is set this way": "なぜこの設定になっているか",
|
||||
"Why that is worth changing": "変更する価値がある理由",
|
||||
"Why this matters, and how to change it": "これが重要な理由と変更方法",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "ファイルは Web ルートの外に保存されているため、ダウンロードはまず ProjectSend を通り、要求した人に権限があるかを確認します。その確認のあと、PHP がファイルを開いて送信しています。もう一つの方法は、PHP が Web サーバーに「このファイルを送って」と伝えてすぐ処理を終えることです。"
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "Er is een nieuw klantaccount aangemaakt: :name (:email).",
|
||||
"A new client has registered": "Een nieuwe klant heeft zich geregistreerd",
|
||||
"A new ProjectSend version is available": "Er is een nieuwe versie van ProjectSend beschikbaar",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Er is een nieuwe verificatielink verstuurd naar het e-mailadres dat je bij je registratie hebt opgegeven.",
|
||||
"A new verification link has been sent to your email address.": "Er is een nieuwe verificatielink naar je e-mailadres verstuurd.",
|
||||
"A public link was created": "Er is een openbare link aangemaakt",
|
||||
"A public link was revoked": "Een openbare link is ingetrokken",
|
||||
"A role was created": "Een rol is aangemaakt",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Afbeelding kiezen",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Kies welke meldingen je ook per e-mail ontvangt. Alles verschijnt sowieso altijd in de meldingsbel.",
|
||||
"Clear": "Wissen",
|
||||
"Click here to re-send the verification email.": "Klik hier om de verificatiemail opnieuw te versturen.",
|
||||
"Click to copy": "Klik om te kopiëren",
|
||||
"Client": "Klant",
|
||||
"Client account approved": "Klantaccount goedgekeurd",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Reageren",
|
||||
"Confirm": "Bevestigen",
|
||||
"Confirm password": "Wachtwoord bevestigen",
|
||||
"Confirm your password": "Bevestig je wachtwoord",
|
||||
"Conflict": "Conflict",
|
||||
"Connect": "Verbinden",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Koppel een externe bucket — S3-compatibel (AWS S3, MinIO, Backblaze) of Google Cloud Storage — als opslag voor nieuwe uploads.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "Heeft de map \":subject\" aangemaakt",
|
||||
"Created the group \":subject\"": "Heeft de groep \":subject\" aangemaakt",
|
||||
"Created the role \":subject\"": "Heeft de rol \":subject\" aangemaakt",
|
||||
"Current password": "Huidig wachtwoord",
|
||||
"Custom": "Aangepast",
|
||||
"Custom assets": "Eigen assets",
|
||||
"Custom fields": "Aangepaste velden",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Editie",
|
||||
"Email": "E-mail",
|
||||
"Email address": "E-mailadres",
|
||||
"Email password reset link": "Link om wachtwoord te herstellen versturen",
|
||||
"Email settings": "E-mailinstellingen",
|
||||
"Email template reset to default.": "E-mailsjabloon teruggezet naar de standaardtekst.",
|
||||
"Email template saved.": "E-mailsjabloon opgeslagen.",
|
||||
"Email templates": "E-mailsjablonen",
|
||||
"Email verification": "E-mailverificatie",
|
||||
"Empty file": "Leeg bestand",
|
||||
"Enable": "Inschakelen",
|
||||
"Enable the public directory page": "De openbare overzichtspagina inschakelen",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "Versleuteld environment-bestand bestaat al.",
|
||||
"Encrypted environment file not found.": "Versleuteld environment-bestand niet gevonden.",
|
||||
"Encryption": "Versleuteling",
|
||||
"Ensure your account is using a long, random password to stay secure": "Zorg ervoor dat je account een lang, willekeurig wachtwoord gebruikt om veilig te blijven",
|
||||
"Enter one of your recovery codes.": "Voer een van je herstelcodes in.",
|
||||
"Enter the six-digit code from your authenticator app.": "Voer de zescijferige code uit je authenticator-app in.",
|
||||
"Enter your email and password below to log in": "Voer hieronder je e-mailadres en wachtwoord in om in te loggen",
|
||||
"Enter your email to receive a password reset link": "Vul je e-mailadres in om een herstellink te ontvangen",
|
||||
"Environment": "Omgeving",
|
||||
"Environment file already exists.": "Environment-bestand bestaat al.",
|
||||
"Environment file not found.": "Environment-bestand niet gevonden.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Mappen kunnen alleen met klanten of groepen worden gedeeld.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Mappen kunnen niet dieper dan :max niveaus worden genest.",
|
||||
"Forbidden": "Geen toegang",
|
||||
"Forgot password": "Wachtwoord vergeten",
|
||||
"Forgot password?": "Wachtwoord vergeten?",
|
||||
"Found": "Gevonden",
|
||||
"From": "Van",
|
||||
"From address": "Afzenderadres",
|
||||
"From name": "Afzendernaam",
|
||||
"Full name": "Volledige naam",
|
||||
"Gateway Timeout": "Gateway-time-out",
|
||||
"General": "Algemeen",
|
||||
"General settings": "Algemene instellingen",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Afgesloten",
|
||||
"Log in": "Inloggen",
|
||||
"Log In": "Inloggen",
|
||||
"log in": "inloggen",
|
||||
"Log in to your account": "Log in op je account",
|
||||
"Log out": "Uitloggen",
|
||||
"Log Out": "Uitloggen",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Openbaar overzicht openen",
|
||||
"Options": "Opties",
|
||||
"Or enter this key manually:": "Of voer deze sleutel handmatig in:",
|
||||
"Or, return to": "Of ga terug naar",
|
||||
"Orange": "Oranje",
|
||||
"Origin Is Unreachable": "Herkomst is onbereikbaar",
|
||||
"Outgoing email": "Uitgaande e-mail",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Gedeeltelijke inhoud",
|
||||
"Password": "Wachtwoord",
|
||||
"Password reset": "Wachtwoordherstel",
|
||||
"Password settings": "Wachtwoordinstellingen",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Plak het JSON-sleutelbestand van een serviceaccount met lees- en schrijftoegang tot de objecten in de bucket. Het wordt versleuteld opgeslagen en nooit meer getoond.",
|
||||
"Path": "Pad",
|
||||
"Payload Too Large": "Aanvraag te groot",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Roze",
|
||||
"Platform": "Platform",
|
||||
"Please click the button below to verify your email address.": "Klik op de knop hieronder om je e-mailadres te verifiëren.",
|
||||
"Please enter your new password below": "Voer hieronder je nieuwe wachtwoord in",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Bevestig je e-mailadres door op de link te klikken die we je zojuist hebben gestuurd.",
|
||||
"Port": "Poort",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Mogelijk gemaakt door ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Privé",
|
||||
"Processing": "Verwerken",
|
||||
"Profile": "Profiel",
|
||||
"Profile information": "Profielgegevens",
|
||||
"Profile information was updated": "De profielgegevens zijn bijgewerkt",
|
||||
"Profile settings": "Profielinstellingen",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "ProjectSend :latestVersion is beschikbaar (jij hebt :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend is vrije, opensource software om bestanden naar je klanten te sturen. Het wordt uitgebracht onder de :license, wat betekent dat je het vrij mag gebruiken, bestuderen, aanpassen en delen.",
|
||||
"ProjectSend is ready": "ProjectSend is klaar voor gebruik",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Aangevraagde lidmaatschappen wachten op goedkeuring in de wachtrij met lidmaatschapsaanvragen.",
|
||||
"Require two-factor authentication": "Tweefactorauthenticatie verplichten",
|
||||
"Required": "Verplicht",
|
||||
"Resend verification email": "Verificatiemail opnieuw versturen",
|
||||
"Reset Content": "Inhoud opnieuw instellen",
|
||||
"Reset Password": "Wachtwoord herstellen",
|
||||
"Reset password": "Wachtwoord herstellen",
|
||||
"Reset Password Notification": "Notificatie wachtwoordherstel",
|
||||
"Reset this email to its default wording?": "Deze e-mail terugzetten naar de standaardtekst?",
|
||||
"Reset to default": "Terugzetten naar standaard",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": ":Name opslaan",
|
||||
"Save & Close": "Opslaan en sluiten",
|
||||
"Save & Return": "Opslaan en teruggaan",
|
||||
"Save password": "Wachtwoord opslaan",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Sla je wijzigingen op om dit live te zetten — tot dan verwijzen deze knoppen naar een pagina die nog niet gepubliceerd is.",
|
||||
"Saved": "Opgeslagen",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Scan deze QR-code met je authenticator-app en voer daarna de zescijferige code in ter bevestiging.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Thema bijgewerkt.",
|
||||
"Theming": "Thema's",
|
||||
"Theming settings": "Thema-instellingen",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "Dit account logt in via je directory, dus het wachtwoord wordt hier niet ingesteld. Vraag een beheerder om hulp als je niet kunt inloggen.",
|
||||
"This action is unauthorized.": "Deze actie is niet toegestaan.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Dit bestand overschrijdt de maximaal toegestane grootte van :max MB.",
|
||||
"This folder is empty.": "Deze map is leeg.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "Dit is een ingebouwde rol; het bereik ervan kan niet worden gewijzigd.",
|
||||
"This is a preview of the :theme email theme.": "Dit is een voorbeeld van het e-mailthema :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "Dit is een voorbeeld-e-mail — er is geen melding daadwerkelijk verstuurd.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "Dit is een beveiligd gedeelte van de applicatie. Bevestig je wachtwoord voordat je verdergaat.",
|
||||
"This is a test email from :site.": "Dit is een test-e-mail van :site.",
|
||||
"This is the last active administrator account.": "Dit is het laatste actieve beheerdersaccount.",
|
||||
"This link cannot match the file's own public URL slug.": "Deze link mag niet gelijk zijn aan de eigen openbare URL-slug van het bestand.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Tot :max MB per bestand. Uploads kunnen worden gepauzeerd en hervatten automatisch na een onderbreking.",
|
||||
"Update": "Aanpassen",
|
||||
"Update :name": ":Name bijwerken",
|
||||
"Update password": "Wachtwoord wijzigen",
|
||||
"Update your name and email address": "Werk je naam en e-mailadres bij",
|
||||
"Updated the account \":subject\"": "Heeft het account \":subject\" bijgewerkt",
|
||||
"Updated the file \":subject\"": "Heeft het bestand \":subject\" bijgewerkt",
|
||||
"Updated the group \":subject\"": "Heeft de groep \":subject\" bijgewerkt",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Gebruikers",
|
||||
"Username": "Gebruikersnaam",
|
||||
"Variant Also Negotiates": "Variant onderhandelt ook",
|
||||
"Verify email": "E-mail bevestigen",
|
||||
"Verify Email Address": "Verifieer e-mailadres",
|
||||
"Version": "Versie",
|
||||
"Version :version": "Versie :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Je beheerder verplicht tweefactorauthenticatie op dit account. Stel het hieronder in om verder te gaan.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Je aangepaste onderwerp en berichttekst worden meteen verworpen en vervangen door de standaardtekst — dit kan niet ongedaan worden gemaakt.",
|
||||
"Your download should start automatically.": "Je download zou automatisch moeten beginnen.",
|
||||
"Your email address is unverified.": "Je e-mailadres is niet geverifieerd.",
|
||||
"Your existing recovery codes will stop working immediately.": "Je huidige herstelcodes werken vanaf nu niet meer.",
|
||||
"Your group membership request was approved": "Je aanvraag voor groepslidmaatschap is goedgekeurd",
|
||||
"Your group membership request was denied": "Je aanvraag voor groepslidmaatschap is geweigerd",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest heeft gereageerd op het bestand \":subject\"",
|
||||
":name — files": ":name — bestanden",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": ":updated van de :requested geselecteerde bestanden zijn bijgewerkt. De rest is overgeslagen omdat je geen recht hebt ze te bewerken.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": ":updated van de :requested geselecteerde bestanden zijn bijgewerkt. De rest is overgeslagen omdat je geen recht hebt deze wijzigingen aan te brengen.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Een uitgesproken koptekst rond je logo — past bij de Gallery-stijl voor een verzorgde inbox in je eigen huisstijl.",
|
||||
"Access": "Toegang",
|
||||
"Access key": "Toegangssleutel",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "De klanten van deze installatie zijn beperkt tot :count. Verwijder er een of vraag om een groter abonnement.",
|
||||
":used of :limit staff seats used": ":used van :limit systeemaccounts in gebruik",
|
||||
":used of :limit client accounts used": ":used van :limit klantaccounts in gebruik",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Deze map kan niet worden verwijderd: er staat :count bestand in dat je niet mag verwijderen.|Deze map kan niet worden verwijderd: er staan :count bestanden in die je niet mag verwijderen."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Deze map kan niet worden verwijderd: er staat :count bestand in dat je niet mag verwijderen.|Deze map kan niet worden verwijderd: er staan :count bestanden in die je niet mag verwijderen.",
|
||||
"A PNG with a transparent background works best.": "Een PNG met een transparante achtergrond werkt het best.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "Geldt voor miniaturen en voorbeelden. Bestaande worden opnieuw gemaakt zodra ze weer bekeken worden.",
|
||||
"A sample image with the watermark applied": "Een voorbeeldafbeelding met het watermerk erop",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Een voorbeeldafbeelding, niet een van je eigen bestanden. Kies je hierboven een nieuw watermerk, dan wordt dit bijgewerkt zodra je opslaat.",
|
||||
"Attribution": "Naamsvermelding",
|
||||
"Bottom centre": "Onderaan in het midden",
|
||||
"Bottom left": "Linksonder",
|
||||
"Bottom right": "Rechtsonder",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "Standaard verschijnt er een kleine regel \"Powered by ProjectSend\" op de openbare pagina's, in het klantportaal en onderaan uitgaande e-mail. Zet hem uit om geen spoor achter te laten van de software die je klanten gebruiken.",
|
||||
"Centre": "Midden",
|
||||
"Choose a file only to replace the image above.": "Kies alleen een bestand als je de afbeelding hierboven wilt vervangen.",
|
||||
"Choose the image to use as the watermark.": "Kies de afbeelding die als watermerk gebruikt wordt.",
|
||||
"Hide \"Powered by ProjectSend\"": "\"Powered by ProjectSend\" verbergen",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo verwijderd.",
|
||||
"Logo updated.": "Logo bijgewerkt.",
|
||||
"Middle left": "Midden links",
|
||||
"Middle right": "Midden rechts",
|
||||
"No custom logo set — the default icon is shown.": "Geen eigen logo ingesteld — het standaardpictogram wordt getoond.",
|
||||
"Opacity (%)": "Dekking (%)",
|
||||
"Position": "Positie",
|
||||
"Remove logo": "Logo verwijderen",
|
||||
"Remove watermark": "Watermerk verwijderen",
|
||||
"Save attribution settings": "Instellingen voor naamsvermelding opslaan",
|
||||
"Saved.": "Opgeslagen.",
|
||||
"Save watermark settings": "Watermerkinstellingen opslaan",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Toon je eigen logo in de zijbalk in plaats van het standaardpictogram.",
|
||||
"Size (% of the image)": "Grootte (% van de afbeelding)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Zet een afbeelding over de miniaturen en voorbeelden die klanten en bezoekers zien. Wat je team in het bestandsbeheer ziet blijft onbewerkt, en de originele bestanden — elke download inbegrepen — worden nooit aangepast.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "Het watermerk wordt op schaal gebracht zodat het binnen dit deel past van waar het op getekend wordt, met behoud van de verhoudingen — zo zien een miniatuur en een voorbeeld eruit als hetzelfde ontwerp.",
|
||||
"Top centre": "Bovenaan in het midden",
|
||||
"Top left": "Linksboven",
|
||||
"Top right": "Rechtsboven",
|
||||
"Upload a watermark image below to see a preview here.": "Upload hieronder een watermerkafbeelding om hier een voorbeeld te zien.",
|
||||
"Upload logo": "Logo uploaden",
|
||||
"Watermark": "Watermerk",
|
||||
"Watermark image": "Watermerkafbeelding",
|
||||
"Watermark removed.": "Watermerk verwijderd.",
|
||||
"Watermark settings saved.": "Watermerkinstellingen opgeslagen.",
|
||||
"Watermark what clients and visitors see": "Watermerk op wat klanten en bezoekers zien",
|
||||
"What clients will see": "Wat klanten te zien krijgen",
|
||||
"Your own artwork on this installation.": "Je eigen vormgeving op deze installatie.",
|
||||
"Your own staff still see the version and licence on the About screen.": "Je eigen team ziet de versie en de licentie nog steeds op het Over-scherm.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Gedetecteerd op basis van de webserver. Stel PROJECTSEND_FILE_DELIVERY in je .env-bestand in om het expliciet te kiezen.",
|
||||
"Downloads are being sent by PHP": "Downloads worden door PHP verstuurd",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "Downloads worden door PHP verstuurd — waarom dat niet optimaal is",
|
||||
"Downloads are not being handed to the web server": "Downloads worden niet aan de webserver overgedragen",
|
||||
"Downloads sent by": "Downloads verstuurd door",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Alles werkt. Dit gaat over hoeveel belasting je server aankan, niet over iets dat stuk is.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "De bestanden worden aan nginx overgedragen, die ze verstuurt zonder een PHP-proces bezet te houden. Dit is de snelste optie en vraagt verder niets.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "De bestanden worden met de X-Sendfile-header aan je webserver overgedragen, die ze verstuurt zonder een PHP-proces bezet te houden.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Een bestand overdragen vraagt om een response-header, en elke webserver leest een andere. ProjectSend stuurt hem alleen als het weet dat de server ermee aan de slag gaat, want een server die hem negeert stuurt in plaats daarvan een leeg antwoord — dat aankomt als een bestand van 0 byte.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Hier verstuurt PHP de bestanden zelf, omdat PROJECTSEND_FILE_DELIVERY op php staat.",
|
||||
"How downloads are sent": "Hoe downloads worden verstuurd",
|
||||
"How to change it": "Hoe je dit verandert",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "De server ervoor werd niet herkend, dus is teruggevallen op de optie die overal werkt.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "Of installeer op Apache mod_xsendfile en geef je opslagmap vrij met XSendFilePath; LiteSpeed heeft geen module nodig. Stel daarna PROJECTSEND_FILE_DELIVERY=xsendfile in.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "Of bewaar bestanden in S3-compatibele opslag of Google Cloud, zodat downloads je server helemaal niet raken.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP leest elk bestand en verstuurt het. Dat werkt op elke server, maar houdt een PHP-werkproces bezet zolang de download duurt.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Draai ProjectSend achter nginx, met het location-blok uit INSTALL.md. Verder is er niets in te stellen — het wordt gedetecteerd.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Expliciet ingesteld via PROJECTSEND_FILE_DELIVERY in je .env-bestand.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "Deze melding blijft staan zolang PHP de bestanden verstuurt, ook als dat een bewuste keuze was.",
|
||||
"Web server (nginx)": "Webserver (nginx)",
|
||||
"Web server (X-Sendfile)": "Webserver (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Webservers doen dit veel beter: ze gebruiken de systeemaanroep die ervoor bedoeld is, kunnen hervatten en door video's spoelen, en één proces bedient veel overdrachten tegelijk.",
|
||||
"What is happening": "Wat er gebeurt",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Terwijl PHP een bestand verstuurt, is één werkproces de hele download bezet. Een paar grote downloads tegelijk kunnen al je werkprocessen bezetten, waarna de site niet meer reageert — ook voor mensen die alleen willen inloggen, terwijl de processor niets doet.",
|
||||
"Why it is set this way": "Waarom het zo staat ingesteld",
|
||||
"Why that is worth changing": "Waarom het de moeite waard is dit te veranderen",
|
||||
"Why this matters, and how to change it": "Waarom dit uitmaakt, en hoe je het verandert",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Je bestanden staan buiten de webroot, dus elke download gaat eerst langs ProjectSend om te controleren of de persoon het bestand mag hebben. Na die controle opent PHP het bestand en verstuurt het. Het alternatief is dat PHP tegen je webserver zegt “stuur dit bestand” en meteen klaar is."
|
||||
}
|
||||
|
||||
+101
-1
@@ -55,6 +55,8 @@
|
||||
"A new client account was created: :name (:email).": "Utworzono nowe konto klienta: :name (:email).",
|
||||
"A new client has registered": "Zarejestrował się nowy klient",
|
||||
"A new ProjectSend version is available": "Dostępna jest nowa wersja ProjectSend",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Nowy link weryfikacyjny został wysłany na adres e-mail podany podczas rejestracji.",
|
||||
"A new verification link has been sent to your email address.": "Nowy link weryfikacyjny został wysłany na Twój adres e-mail.",
|
||||
"A public link was created": "Utworzono link publiczny",
|
||||
"A public link was revoked": "Unieważniono link publiczny",
|
||||
"A role was created": "Utworzono rolę",
|
||||
@@ -178,6 +180,7 @@
|
||||
"Choose Image": "Wybierz obraz",
|
||||
"Choose which notifications also send you an email. Everything always appears in the notification bell.": "Wybierz, które powiadomienia mają też przychodzić e-mailem. Wszystko i tak zawsze pojawia się w dzwonku powiadomień.",
|
||||
"Clear": "Wyczyść",
|
||||
"Click here to re-send the verification email.": "Kliknij tutaj, aby wysłać e-mail weryfikacyjny ponownie.",
|
||||
"Click to copy": "Kliknij, aby skopiować",
|
||||
"Client": "Klient",
|
||||
"Client account approved": "Konto klienta zatwierdzone",
|
||||
@@ -202,6 +205,7 @@
|
||||
"Comment": "Skomentuj",
|
||||
"Confirm": "Potwierdź",
|
||||
"Confirm password": "Potwierdź hasło",
|
||||
"Confirm your password": "Potwierdź swoje hasło",
|
||||
"Conflict": "Konflikt",
|
||||
"Connect": "Połącz",
|
||||
"Connect an external bucket — S3-compatible (AWS S3, MinIO, Backblaze) or Google Cloud Storage — as the storage backend for new uploads.": "Podłącz zewnętrzny bucket — zgodny z S3 (AWS S3, MinIO, Backblaze) lub Google Cloud Storage — jako magazyn dla nowych przesyłanych plików.",
|
||||
@@ -239,6 +243,7 @@
|
||||
"Created the folder \":subject\"": "Utworzył(a) folder \":subject\"",
|
||||
"Created the group \":subject\"": "Utworzył(a) grupę \":subject\"",
|
||||
"Created the role \":subject\"": "Utworzył(a) rolę \":subject\"",
|
||||
"Current password": "Obecne hasło",
|
||||
"Custom": "Własne",
|
||||
"Custom assets": "Własne zasoby",
|
||||
"Custom fields": "Pola niestandardowe",
|
||||
@@ -342,10 +347,12 @@
|
||||
"Edition": "Edycja",
|
||||
"Email": "E-mail",
|
||||
"Email address": "Adres e-mail",
|
||||
"Email password reset link": "Wyślij link do resetowania hasła",
|
||||
"Email settings": "Ustawienia e-mail",
|
||||
"Email template reset to default.": "Przywrócono domyślną treść szablonu e-mail.",
|
||||
"Email template saved.": "Zapisano szablon e-mail.",
|
||||
"Email templates": "Szablony e-mail",
|
||||
"Email verification": "Weryfikacja e-maila",
|
||||
"Empty file": "Pusty plik",
|
||||
"Enable": "Włącz",
|
||||
"Enable the public directory page": "Włącz stronę katalogu publicznego",
|
||||
@@ -354,9 +361,11 @@
|
||||
"Encrypted environment file already exists.": "Zaszyfrowany plik konfiguracji środowiska już istnieje.",
|
||||
"Encrypted environment file not found.": "Nie znaleziono zaszyfrowanego pliku konfiguracji środowiska.",
|
||||
"Encryption": "Szyfrowanie",
|
||||
"Ensure your account is using a long, random password to stay secure": "Zadbaj o to, aby Twoje konto miało długie, losowe hasło",
|
||||
"Enter one of your recovery codes.": "Wprowadź jeden ze swoich kodów odzyskiwania.",
|
||||
"Enter the six-digit code from your authenticator app.": "Wprowadź sześciocyfrowy kod z aplikacji uwierzytelniającej.",
|
||||
"Enter your email and password below to log in": "Wpisz poniżej swój adres e-mail i hasło, aby się zalogować",
|
||||
"Enter your email to receive a password reset link": "Podaj swój adres e-mail, aby otrzymać link do resetowania hasła",
|
||||
"Environment": "Środowisko",
|
||||
"Environment file already exists.": "Plik konfiguracji środowiska już istnieje.",
|
||||
"Environment file not found.": "Nie znaleziono pliku konfiguracji środowiska.",
|
||||
@@ -399,11 +408,13 @@
|
||||
"Folders can only be shared with clients or groups.": "Foldery można udostępniać wyłącznie klientom lub grupom.",
|
||||
"Folders cannot be nested more than :max levels deep.": "Maksymalna głębokość zagnieżdżenia folderów: :max.",
|
||||
"Forbidden": "Zabronione",
|
||||
"Forgot password": "Nie pamiętasz hasła",
|
||||
"Forgot password?": "Nie pamiętasz hasła?",
|
||||
"Found": "Znaleziono",
|
||||
"From": "Od",
|
||||
"From address": "Adres nadawcy",
|
||||
"From name": "Nazwa nadawcy",
|
||||
"Full name": "Imię i nazwisko",
|
||||
"Gateway Timeout": "Przekroczenie limitu czasu bramy",
|
||||
"General": "Ogólne",
|
||||
"General settings": "Ustawienia ogólne",
|
||||
@@ -486,6 +497,7 @@
|
||||
"Locked": "Zablokowany",
|
||||
"Log in": "Zaloguj się",
|
||||
"Log In": "Zaloguj się",
|
||||
"log in": "logowania",
|
||||
"Log in to your account": "Zaloguj się na swoje konto",
|
||||
"Log out": "Wyloguj się",
|
||||
"Log Out": "Wyloguj się",
|
||||
@@ -612,6 +624,7 @@
|
||||
"Open public listing": "Otwórz listę publiczną",
|
||||
"Options": "Opcje",
|
||||
"Or enter this key manually:": "Albo wpisz ten klucz ręcznie:",
|
||||
"Or, return to": "Albo wróć do",
|
||||
"Orange": "Pomarańczowy",
|
||||
"Origin Is Unreachable": "Serwer źródłowy jest nieosiągalny",
|
||||
"Outgoing email": "Poczta wychodząca",
|
||||
@@ -620,6 +633,7 @@
|
||||
"Partial Content": "Częściowa zawartość",
|
||||
"Password": "Hasło",
|
||||
"Password reset": "Resetowanie hasła",
|
||||
"Password settings": "Ustawienia hasła",
|
||||
"Paste the JSON key file for a service account with object read and write access to the bucket. It is stored encrypted and never shown again.": "Wklej plik klucza JSON konta usługi z prawem odczytu i zapisu obiektów w buckecie. Jest przechowywany w postaci zaszyfrowanej i nigdy więcej nie zostanie pokazany.",
|
||||
"Path": "Ścieżka",
|
||||
"Payload Too Large": "Ładunek zbyt duży",
|
||||
@@ -631,6 +645,8 @@
|
||||
"Pink": "Różowy",
|
||||
"Platform": "Platforma",
|
||||
"Please click the button below to verify your email address.": "Kliknij poniższy przycisk aby zweryfikować swój adres e-mail.",
|
||||
"Please enter your new password below": "Wpisz poniżej swoje nowe hasło",
|
||||
"Please verify your email address by clicking on the link we just emailed to you.": "Potwierdź swój adres e-mail, klikając link, który właśnie do Ciebie wysłaliśmy.",
|
||||
"Port": "Port",
|
||||
"Postmark": "Postmark",
|
||||
"Powered by ProjectSend": "Działa na ProjectSend",
|
||||
@@ -646,7 +662,9 @@
|
||||
"Private": "Prywatne",
|
||||
"Processing": "Przetwarzanie",
|
||||
"Profile": "Profil",
|
||||
"Profile information": "Informacje o profilu",
|
||||
"Profile information was updated": "Zaktualizowano dane profilu",
|
||||
"Profile settings": "Ustawienia profilu",
|
||||
"ProjectSend :latestVersion is available (you have :currentVersion)": "Dostępny jest ProjectSend :latestVersion (masz :currentVersion)",
|
||||
"ProjectSend is free, open source software for sending files to your clients. It is released under the :license, which means you are free to use, study, change and share it.": "ProjectSend to wolne oprogramowanie o otwartym kodzie źródłowym do wysyłania plików klientom. Jest wydawane na licencji :license, co oznacza, że możesz go swobodnie używać, badać, zmieniać i udostępniać.",
|
||||
"ProjectSend is ready": "ProjectSend jest gotowy",
|
||||
@@ -706,8 +724,10 @@
|
||||
"Requested memberships wait in the membership requests queue for approval.": "Zgłoszone członkostwa czekają na zatwierdzenie w kolejce wniosków o członkostwo.",
|
||||
"Require two-factor authentication": "Wymagaj uwierzytelniania dwuskładnikowego",
|
||||
"Required": "Wymagane",
|
||||
"Resend verification email": "Wyślij e-mail weryfikacyjny ponownie",
|
||||
"Reset Content": "Zresetuj zawartość",
|
||||
"Reset Password": "Zresetuj Hasło",
|
||||
"Reset password": "Zresetuj hasło",
|
||||
"Reset Password Notification": "Powiadomienie o Zresetowaniu Hasła",
|
||||
"Reset this email to its default wording?": "Przywrócić domyślną treść tej wiadomości?",
|
||||
"Reset to default": "Przywróć domyślne",
|
||||
@@ -734,6 +754,7 @@
|
||||
"Save :name": "Zapisz :name",
|
||||
"Save & Close": "Zapisz i zamknij",
|
||||
"Save & Return": "Zapisz i wróć",
|
||||
"Save password": "Zapisz hasło",
|
||||
"Save your changes to make this live — until then, these buttons point to a page that isn't published yet.": "Zapisz zmiany, aby to opublikować — do tego czasu te przyciski prowadzą do strony, której jeszcze nie ma.",
|
||||
"Saved": "Zapisano",
|
||||
"Scan this QR code with your authenticator app, then enter the six-digit code to confirm.": "Zeskanuj ten kod QR aplikacją uwierzytelniającą, a następnie wpisz sześciocyfrowy kod, aby potwierdzić.",
|
||||
@@ -879,6 +900,7 @@
|
||||
"Theme updated.": "Zaktualizowano motyw.",
|
||||
"Theming": "Motywy",
|
||||
"Theming settings": "Ustawienia motywów",
|
||||
"This account signs in through your directory, so its password is not set here. Ask an administrator if you cannot sign in.": "To konto loguje się przez Twój katalog, więc jego hasło nie jest ustawiane tutaj. Skontaktuj się z administratorem, jeśli nie możesz się zalogować.",
|
||||
"This action is unauthorized.": "To działanie jest niedozwolone.",
|
||||
"This file exceeds the maximum allowed size of :max MB.": "Ten plik przekracza maksymalny dozwolony rozmiar :max MB.",
|
||||
"This folder is empty.": "Ten folder jest pusty.",
|
||||
@@ -886,6 +908,7 @@
|
||||
"This is a built-in role; its scope can't be changed.": "To rola wbudowana; jej zakresu nie można zmienić.",
|
||||
"This is a preview of the :theme email theme.": "To podgląd motywu wiadomości :theme.",
|
||||
"This is a sample email — no notification was actually sent.": "To przykładowa wiadomość — żadne powiadomienie nie zostało naprawdę wysłane.",
|
||||
"This is a secure area of the application. Please confirm your password before continuing.": "To jest zabezpieczony obszar aplikacji. Potwierdź swoje hasło, zanim przejdziesz dalej.",
|
||||
"This is a test email from :site.": "To wiadomość testowa z :site.",
|
||||
"This is the last active administrator account.": "To ostatnie aktywne konto administratora.",
|
||||
"This link cannot match the file's own public URL slug.": "Ten link nie może być taki sam jak własny publiczny identyfikator URL pliku.",
|
||||
@@ -931,6 +954,8 @@
|
||||
"Up to :max MB per file. Uploads can be paused and resume automatically after interruptions.": "Do :max MB na plik. Przesyłanie można wstrzymać, a po przerwie wznawia się automatycznie.",
|
||||
"Update": "Aktualizacja",
|
||||
"Update :name": "Aktualizuj :name",
|
||||
"Update password": "Zmień hasło",
|
||||
"Update your name and email address": "Zaktualizuj swoje imię i adres e-mail",
|
||||
"Updated the account \":subject\"": "Zaktualizował(a) konto \":subject\"",
|
||||
"Updated the file \":subject\"": "Zaktualizował(a) plik \":subject\"",
|
||||
"Updated the group \":subject\"": "Zaktualizował(a) grupę \":subject\"",
|
||||
@@ -965,6 +990,7 @@
|
||||
"Users": "Użytkownicy",
|
||||
"Username": "Nazwa użytkownika",
|
||||
"Variant Also Negotiates": "Wariant również prowadzi negocjacje",
|
||||
"Verify email": "Zweryfikuj e-mail",
|
||||
"Verify Email Address": "Zweryfikuj Adres E-mail",
|
||||
"Version": "Wersja",
|
||||
"Version :version": "Wersja :version",
|
||||
@@ -1023,6 +1049,7 @@
|
||||
"Your administrator requires two-factor authentication on this account. Set it up below to continue.": "Twój administrator wymaga uwierzytelniania dwuskładnikowego na tym koncie. Skonfiguruj je poniżej, aby kontynuować.",
|
||||
"Your customized subject and body will be discarded and replaced with the default wording immediately — this cannot be undone.": "Twój własny temat i treść zostaną natychmiast odrzucone i zastąpione domyślnym tekstem — tej operacji nie można cofnąć.",
|
||||
"Your download should start automatically.": "Pobieranie powinno rozpocząć się automatycznie.",
|
||||
"Your email address is unverified.": "Twój adres e-mail nie został zweryfikowany.",
|
||||
"Your existing recovery codes will stop working immediately.": "Twoje dotychczasowe kody odzyskiwania natychmiast przestaną działać.",
|
||||
"Your group membership request was approved": "Twój wniosek o członkostwo w grupie został zatwierdzony",
|
||||
"Your group membership request was denied": "Twój wniosek o członkostwo w grupie został odrzucony",
|
||||
@@ -1186,6 +1213,7 @@
|
||||
":guest commented on the file \":subject\"": ":guest skomentował(a) plik \":subject\"",
|
||||
":name — files": ":name — pliki",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to edit them.": "Zaktualizowano :updated z :requested zaznaczonych plików. Resztę pominięto, bo nie masz uprawnień do ich edycji.",
|
||||
":updated of :requested selected files were updated. The rest were skipped because you don't have permission to make those changes.": "Zaktualizowano :updated z :requested zaznaczonych plików. Resztę pominięto, bo nie masz uprawnień do wprowadzenia tych zmian.",
|
||||
"A bold header built around your logo — pairs with the Gallery look for a polished, on-brand inbox.": "Wyrazisty nagłówek zbudowany wokół Twojego logo — w parze ze stylem Gallery daje dopracowaną, spójną z marką skrzynkę odbiorczą.",
|
||||
"Access": "Dostęp",
|
||||
"Access key": "Klucz dostępu",
|
||||
@@ -1905,5 +1933,77 @@
|
||||
"Clients on this installation are limited to :count. Remove one, or ask for a larger plan.": "Liczba klientów w tej instalacji jest ograniczona do :count. Usuń jednego lub poproś o większy plan.",
|
||||
":used of :limit staff seats used": "Wykorzystano :used z :limit kont systemowych",
|
||||
":used of :limit client accounts used": "Wykorzystano :used z :limit kont klientów",
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Tego folderu nie można usunąć: zawiera :count plik, którego nie możesz usunąć.|Tego folderu nie można usunąć: zawiera :count pliki, których nie możesz usunąć.|Tego folderu nie można usunąć: zawiera :count plików, których nie możesz usunąć."
|
||||
"This folder cannot be deleted: it holds :count file you may not delete.|This folder cannot be deleted: it holds :count files you may not delete.": "Tego folderu nie można usunąć: zawiera :count plik, którego nie możesz usunąć.|Tego folderu nie można usunąć: zawiera :count pliki, których nie możesz usunąć.|Tego folderu nie można usunąć: zawiera :count plików, których nie możesz usunąć.",
|
||||
"A PNG with a transparent background works best.": "Najlepiej sprawdza się PNG z przezroczystym tłem.",
|
||||
"Applies to thumbnails and previews. Existing ones are rebuilt the next time they are viewed.": "Dotyczy miniatur i podglądów. Istniejące zostaną odtworzone przy następnym wyświetleniu.",
|
||||
"A sample image with the watermark applied": "Przykładowy obraz z nałożonym znakiem wodnym",
|
||||
"A stand-in image, not one of your files. Choosing a new watermark above updates this once you save.": "Obraz zastępczy, nie jeden z twoich plików. Wybranie nowego znaku wodnego powyżej zaktualizuje go po zapisaniu.",
|
||||
"Attribution": "Atrybucja",
|
||||
"Bottom centre": "Na dole pośrodku",
|
||||
"Bottom left": "Na dole po lewej",
|
||||
"Bottom right": "Na dole po prawej",
|
||||
"By default a small \"Powered by ProjectSend\" line appears on the public pages, in the client portal and at the foot of outgoing email. Turn it off to leave no trace of the software your clients are using.": "Domyślnie na stronach publicznych, w portalu klienta i w stopce wychodzących e-maili pojawia się mały napis „Powered by ProjectSend”. Wyłącz go, aby nie zostawiać śladu po oprogramowaniu, z którego korzystają twoi klienci.",
|
||||
"Centre": "Środek",
|
||||
"Choose a file only to replace the image above.": "Wybierz plik tylko wtedy, gdy chcesz zastąpić obraz powyżej.",
|
||||
"Choose the image to use as the watermark.": "Wybierz obraz, który posłuży za znak wodny.",
|
||||
"Hide \"Powered by ProjectSend\"": "Ukryj „Powered by ProjectSend”",
|
||||
"Logo": "Logo",
|
||||
"Logo removed.": "Logo usunięte.",
|
||||
"Logo updated.": "Logo zaktualizowane.",
|
||||
"Middle left": "Pośrodku po lewej",
|
||||
"Middle right": "Pośrodku po prawej",
|
||||
"No custom logo set — the default icon is shown.": "Brak własnego logo — pokazywana jest domyślna ikona.",
|
||||
"Opacity (%)": "Krycie (%)",
|
||||
"Position": "Pozycja",
|
||||
"Remove logo": "Usuń logo",
|
||||
"Remove watermark": "Usuń znak wodny",
|
||||
"Save attribution settings": "Zapisz ustawienia atrybucji",
|
||||
"Saved.": "Zapisano.",
|
||||
"Save watermark settings": "Zapisz ustawienia znaku wodnego",
|
||||
"Show your own logo in the sidebar instead of the default icon.": "Pokaż własne logo na pasku bocznym zamiast domyślnej ikony.",
|
||||
"Size (% of the image)": "Rozmiar (% obrazu)",
|
||||
"Stamp an image over the thumbnails and previews clients and public visitors see. What staff see in the file manager is left unmarked, and the original files — including every download — are never altered.": "Nałóż obraz na miniatury i podglądy, które widzą klienci i odwiedzający. To, co twój zespół widzi w menedżerze plików, pozostaje bez znaku, a oryginalne pliki — łącznie z każdym pobraniem — nigdy nie są zmieniane.",
|
||||
"The watermark is scaled to fit inside this share of whatever it is drawn on, keeping its proportions — so a thumbnail and a preview look like the same design.": "Znak wodny jest skalowany tak, aby zmieścić się w tej części tego, na czym jest rysowany, z zachowaniem proporcji — dzięki temu miniatura i podgląd wyglądają jak ten sam projekt.",
|
||||
"Top centre": "Na górze pośrodku",
|
||||
"Top left": "Na górze po lewej",
|
||||
"Top right": "Na górze po prawej",
|
||||
"Upload a watermark image below to see a preview here.": "Prześlij poniżej obraz znaku wodnego, aby zobaczyć tu podgląd.",
|
||||
"Upload logo": "Prześlij logo",
|
||||
"Watermark": "Znak wodny",
|
||||
"Watermark image": "Obraz znaku wodnego",
|
||||
"Watermark removed.": "Znak wodny usunięty.",
|
||||
"Watermark settings saved.": "Ustawienia znaku wodnego zapisane.",
|
||||
"Watermark what clients and visitors see": "Znak wodny na tym, co widzą klienci i odwiedzający",
|
||||
"What clients will see": "Co zobaczą klienci",
|
||||
"Your own artwork on this installation.": "Twoja własna grafika w tej instalacji.",
|
||||
"Your own staff still see the version and licence on the About screen.": "Twój zespół nadal widzi wersję i licencję na ekranie „O programie”.",
|
||||
"Detected from the web server. Set PROJECTSEND_FILE_DELIVERY in your .env file to choose explicitly.": "Wykryto na podstawie serwera WWW. Ustaw PROJECTSEND_FILE_DELIVERY w pliku .env, aby wybrać wprost.",
|
||||
"Downloads are being sent by PHP": "Pobierania są wysyłane przez PHP",
|
||||
"Downloads are being sent by PHP — why this is not optimal": "Pobierania są wysyłane przez PHP — dlaczego to nie jest optymalne",
|
||||
"Downloads are not being handed to the web server": "Pobierania nie są przekazywane serwerowi WWW",
|
||||
"Downloads sent by": "Pobierania wysyła",
|
||||
"Everything works. This is about how much load your server can take, not about anything being broken.": "Wszystko działa. Rzecz w tym, jak duże obciążenie wytrzyma serwer, a nie w tym, że coś jest zepsute.",
|
||||
"Files are handed to nginx, which sends them without holding a PHP process open. This is the fastest option and needs nothing further.": "Pliki są przekazywane do nginx, który wysyła je bez blokowania procesu PHP. To najszybsza opcja i nie wymaga niczego więcej.",
|
||||
"Files are handed to your web server with the X-Sendfile header, which sends them without holding a PHP process open.": "Pliki są przekazywane serwerowi WWW nagłówkiem X-Sendfile, który wysyła je bez blokowania procesu PHP.",
|
||||
"Handing a file over needs a response header, and every web server reads a different one. ProjectSend only sends it when it knows the server will act on it, because a server that ignores it sends an empty response instead — which arrives as a 0-byte file.": "Przekazanie pliku wymaga nagłówka odpowiedzi, a każdy serwer WWW czyta inny. ProjectSend wysyła go tylko wtedy, gdy wie, że serwer go obsłuży, bo serwer, który go zignoruje, odsyła pustą odpowiedź — docierającą jako plik o rozmiarze 0 bajtów.",
|
||||
"Here it is sending files itself because PROJECTSEND_FILE_DELIVERY is set to php.": "Tutaj pliki wysyła samo PHP, ponieważ PROJECTSEND_FILE_DELIVERY ma wartość php.",
|
||||
"How downloads are sent": "Jak wysyłane są pobierania",
|
||||
"How to change it": "Jak to zmienić",
|
||||
"It did not recognise the server in front of it, so it fell back to the option that works everywhere.": "Nie rozpoznano serwera stojącego z przodu, więc wybrano opcję działającą wszędzie.",
|
||||
"Or on Apache, install mod_xsendfile and allow your storage directory with XSendFilePath; LiteSpeed needs no module. Then set PROJECTSEND_FILE_DELIVERY=xsendfile.": "Albo na Apache zainstaluj mod_xsendfile i udostępnij katalog magazynu dyrektywą XSendFilePath; LiteSpeed nie potrzebuje modułu. Następnie ustaw PROJECTSEND_FILE_DELIVERY=xsendfile.",
|
||||
"Or store files in S3-compatible or Google Cloud storage, so downloads never touch your server at all.": "Albo przechowuj pliki w magazynie zgodnym z S3 lub w Google Cloud, żeby pobierania w ogóle nie obciążały serwera.",
|
||||
"PHP": "PHP",
|
||||
"PHP is reading each file and sending it. That works on every server, but it occupies a PHP worker process for the whole of each download.": "PHP czyta każdy plik i go wysyła. Działa to na każdym serwerze, ale zajmuje proces roboczy PHP na czas całego pobierania.",
|
||||
"Run ProjectSend behind nginx, with the location block from INSTALL.md. Nothing else to set — it is detected.": "Uruchom ProjectSend za nginx, z blokiem location z INSTALL.md. Nic więcej nie trzeba ustawiać — zostanie wykryty.",
|
||||
"Set explicitly by PROJECTSEND_FILE_DELIVERY in your .env file.": "Ustawione wprost przez PROJECTSEND_FILE_DELIVERY w pliku .env.",
|
||||
"This notice stays while PHP is sending files, including when that was chosen deliberately.": "To powiadomienie pozostaje, dopóki pliki wysyła PHP — także wtedy, gdy wybrano to celowo.",
|
||||
"Web server (nginx)": "Serwer WWW (nginx)",
|
||||
"Web server (X-Sendfile)": "Serwer WWW (X-Sendfile)",
|
||||
"Web servers do this far better: they use the system call meant for it, handle resuming and video seeking, and one process serves many transfers at once.": "Serwery WWW robią to znacznie lepiej: używają przeznaczonego do tego wywołania systemowego, obsługują wznawianie i przewijanie wideo, a jeden proces obsługuje wiele transferów naraz.",
|
||||
"What is happening": "Co się dzieje",
|
||||
"While PHP sends a file, one worker process is busy for the whole download. A few large downloads at once can occupy every worker you have, and the site stops responding — including for people only trying to log in, with the processor sitting idle.": "Kiedy PHP wysyła plik, jeden proces roboczy jest zajęty przez całe pobieranie. Kilka dużych pobrań naraz może zająć wszystkie procesy, a witryna przestaje odpowiadać — również osobom, które chcą się tylko zalogować — przy bezczynnym procesorze.",
|
||||
"Why it is set this way": "Dlaczego jest tak ustawione",
|
||||
"Why that is worth changing": "Dlaczego warto to zmienić",
|
||||
"Why this matters, and how to change it": "Dlaczego to ma znaczenie i jak to zmienić",
|
||||
"Your files are stored outside the web root, so every download goes through ProjectSend first to check the person is allowed to have it. After that check, PHP is opening the file and sending it. The alternative is for PHP to tell your web server \"send this file\" and finish immediately.": "Twoje pliki są przechowywane poza katalogiem publicznym, więc każde pobranie przechodzi najpierw przez ProjectSend, który sprawdza, czy dana osoba ma do niego prawo. Po tym sprawdzeniu PHP otwiera plik i go wysyła. Alternatywą jest, by PHP powiedziało serwerowi WWW „wyślij ten plik” i od razu zakończyło pracę."
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user