Files
projectsend/config/projectsend.php
T
ignacionelson 763e7b0e2e Render one image once, however many requests ask at the same time
Renditions are generated on demand and cached by existence, and nothing
between the callers stopped two requests decoding the same image at once.
The atomic rename settled which file survived; it never stopped both from
doing the work. So N concurrent requests for one cold rendition were N
full-size decodes, each holding four bytes per source pixel — up to 160 MB
at the 40-megapixel ceiling.

That is not an attack. A public listing emits a thumbnail URL per file, a
browser opens six or more connections at once, and the first visit to a
gallery of ordinary camera images was six simultaneous decodes on a
container sized for one. PublicGroupsController reaches the generator with
no account at all, so nothing about it required a customer to be signed
in, and the 240/min throttle bounds rate rather than concurrency.

Worse than a crash, it did not resolve itself: a render killed mid-flight
renames nothing, so the cache warmed only by whatever finished before the
kill and the page died again on the next visit.

A lock keyed on the destination path — which already encodes the file, the
audience and the rendition, so two requests collide exactly when they
would have written the same path. The waiter re-reads after acquiring,
which is what turns a wait into a cache hit rather than a second decode of
the same image.

Waiting rather than refusing, because the arithmetic says so: a waiting
request holds an idle worker at about 35 MB, a rendering one holds that
plus the whole source bitmap. Six waiters cost what one renderer costs.

On timeout it refuses instead of rendering anyway. Falling through would
reinstate the pile-on at the moment the system is already struggling, and
one failed thumbnail is a better outcome than a container that dies and
takes the warm cache with it.

The wait is configurable because the right number is a property of the
machine — a small VPS reading a large source off a slow disk wants longer
— and clamped to at least a second, since a stray empty variable would
otherwise make every concurrent request fail instantly, which is the
opposite of the point.

Eight tests. Two go red without the lock, and the clamp is asserted on the
resolved value rather than the clock, because block() measures in whole
seconds and a timing assertion there would be flaky rather than wrong.

Found by the session sizing free-tier containers, from the outside.
2026-09-08 15:29:20 -03:00

199 lines
8.3 KiB
PHP

<?php
use App\Modules\Platform\Capabilities\Edition;
return [
/*
|--------------------------------------------------------------------------
| Edition
|--------------------------------------------------------------------------
|
| Which edition this installation runs as. Edition is configuration, not a
| code branch: every behavioural difference between editions must flow
| through the capability registry, never through ad-hoc edition checks.
|
| Supported: "community", "cloud"
|
*/
'edition' => Edition::from((string) env('PROJECTSEND_EDITION', 'community')),
/*
|--------------------------------------------------------------------------
| Uploads
|--------------------------------------------------------------------------
|
| Where a chunked upload's parts wait while the transfer is running,
| before they are assembled onto the storage disk. Leave this unset:
| it exists so the test suite can give each parallel worker its own
| directory, since parts are real files on a real path rather than a
| faked disk, and session ids restart at 1 in every worker's database.
|
*/
/*
|--------------------------------------------------------------------------
| Platform seats
|--------------------------------------------------------------------------
|
| How many staff accounts and how many clients this installation may
| hold. Unset means unlimited, which is every self-hosted install: this
| exists for a managed one, where the operator sold a number and the
| application is the only process that can actually count against it.
|
| An operator stating the installation's own limit is not the same as
| the application inventing a plan tier — the distinction config/api.php
| draws when it declines to key a rate limit off billing. Nothing here
| knows what a plan is; it accepts a number and refuses to exceed it.
|
*/
/*
|--------------------------------------------------------------------------
| 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'),
// Seeded into Setting::TwoFactorEnforcement on first boot and never
// afterwards — see SeedSettingsCommand. Here rather than read from
// env() at the point of use, because config:cache stops .env being
// read at all and that is how TRUSTED_PROXIES came to silently do
// nothing.
'two_factor_enforcement' => env('PROJECTSEND_TWO_FACTOR_ENFORCEMENT'),
],
'uploads' => [
'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)
|--------------------------------------------------------------------------
|
| Each resumable-upload part travels as one request of this size;
| web-server/PHP body limits only need to cover a single part.
|
*/
'upload_part_size_mb' => 20,
/*
|--------------------------------------------------------------------------
| CAPTCHA
|--------------------------------------------------------------------------
|
| Two things live here rather than in the settings store, for two
| different reasons.
|
| "disabled" is an escape hatch: a wrong secret key cannot lock anybody
| out (see CaptchaResult), but an operator who has managed it some other
| way needs a fix that touches no database and needs no working login.
|
| The managed keys are the platform's own, applied to every tenant on
| cloud and absent everywhere else. In config rather than the tenant
| database so a database dump never carries our credential, and so
| rotating it is one fleet-wide change instead of a migration. They do
| nothing without Capability::CaptchaManagedKeys.
|
*/
'captcha' => [
'disabled' => (bool) env('PROJECTSEND_CAPTCHA_DISABLED', false),
'managed' => [
'provider' => env('PROJECTSEND_CAPTCHA_MANAGED_PROVIDER'),
'site_key' => env('PROJECTSEND_CAPTCHA_MANAGED_SITE_KEY'),
'secret_key' => env('PROJECTSEND_CAPTCHA_MANAGED_SECRET_KEY'),
'score_threshold' => (float) env('PROJECTSEND_CAPTCHA_MANAGED_SCORE_THRESHOLD', 0.5),
],
],
/*
|--------------------------------------------------------------------------
| Release identity
|--------------------------------------------------------------------------
|
| Per-release facts that ship with the code. Not settings: they never
| vary per install or tenant.
|
*/
// How long a request waits for another one that is already rendering
// the same thumbnail or preview, before giving up rather than
// decoding the same image a second time. See ThumbnailGenerator.
// A slow disk or a large source wants longer than the default 15.
'rendition_lock_wait_seconds' => env('PROJECTSEND_RENDITION_LOCK_WAIT_SECONDS'),
'version' => '2.4.0',
/*
|--------------------------------------------------------------------------
| Official links
|--------------------------------------------------------------------------
*/
// Read through App\Modules\Platform\OfficialLinks rather than
// directly: which of the two front doors "website" means, and whether
// the donation link is offered at all, both depend on the edition.
'links' => [
'website' => 'https://www.projectsend.org/',
// The hosted service's own front door. A managed installation
// links here instead — including from the "Powered by" line on
// client-facing pages and outgoing email.
'website_cloud' => 'https://www.projectsend.cloud/',
// Where this code lives, and the same repository
// CheckForUpdatesCommand asks for the latest release. v1 remains
// available at github.com/projectsend/legacy.
'source' => 'https://github.com/projectsend/projectsend',
'open_collective' => 'https://opencollective.com/projectsend',
// Kept identical to the invitation update.sh prints when an update
// finishes — the two are the same offer, made in the terminal and
// then again on the screen the administrator lands on.
'discord' => 'https://discord.gg/VT9n6cyvXT',
],
];