Files
projectsend/app/Modules/Platform/Settings/Setting.php
T
Ignacio Nelson ed0d36de25 Reduce a manual update to one command that asks first (#1628)
Updating a server install cost nine artisan invocations plus a PHP-FPM
reload, written out in three places that had already drifted apart. One
of those steps is silently fatal to skip: with opcache.validate_timestamps
off — what production guides recommend and what our own image ships — the
database moves to the new version while every visitor keeps being served
the old code, and artisan reports the new version throughout.

`sudo ./update.sh` is now the whole procedure. It asks whether to check
GitHub, asks whether to download the release and verifies the checksum
published beside it, and asks whether there is a backup — offering to dump
the database when the answer is no. Then it takes the site down, replaces
the files, runs the update, reloads PHP-FPM, restarts the worker and
brings the site back. The application still has no self-updater: nothing
is fetched or applied unless somebody runs this and answers yes.

Underneath it is `php artisan projectsend:update`, which is everything an
update does that needs no root — and now the only definition of it. Both
container entrypoints call it instead of carrying their own copy of the
sequence, so the two paths cannot drift again.

Three findings worth keeping in the record, all from rehearsing rather
than reasoning:

  - queue:restart has to come last. It writes its signal into the cache,
    so clearing the cache afterwards deletes it and the worker runs old
    code forever.
  - optimize:clear is not safe to recommend. It runs cache:clear, which
    on Redis is FLUSHDB — harmless on the default two-database layout,
    but on a single-database Redis it takes the sessions and the queue
    with it. The compiled caches are cleared individually instead.
  - update.sh overwrites itself mid-run, because the zip contains it and
    bash reads its own script lazily by byte offset. It re-execs from a
    temporary copy before touching anything.

And when the reload is skipped anyway, the application now says so:
projectsend:update records the version it applied, and any staff page
compares that with what the running process actually compiled. The same
check catches the mirror image — new files in place, update never run.

Rehearsed end to end against real installs: a container upgrade (69 to 73
migrations, key and data intact, healthy), a scripted update on a real
nginx + php-fpm install with OPcache pinned (web process moved 2.1.0 to
2.1.1), the skipped-reload case (banner appears naming both versions, and
clears on reload), the refusals (downgrade, non-release zip, truncated
zip, URL passed to --zip, non-root), a database taken down mid-update
(site comes back out of maintenance mode by itself), and a real download
of the published 2.0.0 zip with its checksum verified.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:29:20 -03:00

450 lines
22 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Settings;
/**
* Every application setting, typed and with its default in code — the
* replacement for v1's ~230 loose option rows read per request. The
* database stores overrides only; an absent row means the default.
*
* Add a case here (plus type and default below) when a feature gains a
* setting. Settings are per-install.
*/
enum Setting: string
{
case SiteName = 'site_name';
// Client self-registration (v1 parity: clients_can_register /
// clients_auto_approve). Consumed by the Clients module.
case ClientsCanRegister = 'clients_can_register';
case ClientsAutoApprove = 'clients_auto_approve';
// Registration group options (v1 parity): auto-join a group on
// signup (0 = off), and which groups registrants may request
// membership to (none | public | all). Consumed by Clients/Groups.
case ClientsAutoGroup = 'clients_auto_group';
case ClientsCanSelectGroup = 'clients_can_select_group';
// Days a denied membership request blocks re-requesting (0 = none).
case ClientsMembershipDenyCooldownDays = 'clients_membership_deny_cooldown_days';
// Maximum upload size in MB (0 = unlimited).
case MaxFileSizeMb = 'max_file_size_mb';
// Prefills a new client's storage_quota_mb at creation time only —
// not a runtime fallback (0 = unlimited). Consumed by ClientsController.
case DefaultClientStorageQuotaMb = 'default_client_storage_quota_mb';
// Security: who must have two-factor authentication enabled
// (none | staff | clients | all) — see TwoFactorEnforcement.
case TwoFactorEnforcement = 'two_factor_enforcement';
// The two halves of the password policy, read by PasswordPolicy and
// applied through Password::defaults() at every field that *sets* a
// password. Deliberately not a composition policy: NIST SP 800-63B
// advises against mandatory uppercase/number/symbol rules and for
// length plus breach-checking, which is what these two express.
//
// The minimum is clamped to 8..128 on read, so no stored value can
// take an installation below Laravel's own default.
case PasswordMinLength = 'password_min_length';
// Whether a new password is checked against the haveibeenpwned breach
// corpus. The check is k-anonymous (only a SHA-1 prefix leaves the
// server) and fails open, so this exists for outbound egress and
// latency on isolated installs, not for correctness.
case PasswordRejectBreached = 'password_reject_breached';
// Master switch for transactional email (file shared, account
// approved/denied, …). Off by default — a fresh install's mailer
// isn't configured yet. SMTP transport itself is .env-only (no
// credential storage in this table); see Setting::EmailNotificationsEnabled
// usage in the Email settings page.
case EmailNotificationsEnabled = 'email_notifications_enabled';
// Freeform recipient list for admin-facing notifications (new client
// registration, client upload) — not tied to specific User rows,
// since "admin" isn't a role here, just whoever staff configure.
// Seeded with the first administrator's email at setup/CLI creation.
case AdminNotificationEmails = 'admin_notification_emails';
// Security: who is restricted to Setting::AllowedUploadExtensions
// (none | clients | all) — see UploadTypeRestriction. v1 parity:
// "Limit file types uploading to".
case UploadTypeRestriction = 'upload_type_restriction';
// Lowercase extensions (no dots) accepted for upload when the
// restriction above applies to the uploader. v1 parity: allowed_file_types.
case AllowedUploadExtensions = 'allowed_upload_extensions';
// File comments. Governed by settings rather than permission keys
// because the roles screen is capability-gated off in the cloud
// edition, and because anonymous authors have no role to hold a key
// at all — see CommentAuthors' docblock.
//
// CommentsScope decides which files accept comments (CommentScope);
// CommentsAuthors decides who may write one (CommentAuthors).
// PublicCommentsEnabled is separate from both on purpose: without it,
// a logged-in client on a public file could publish a world-visible
// comment that no administrator ever decided to allow.
case CommentsScope = 'comments_scope';
case CommentsAuthors = 'comments_authors';
case PublicCommentsEnabled = 'public_comments_enabled';
// Only meaningful when CommentsAuthors is `everyone` — an anonymous
// comment stays invisible until a moderator approves it.
case CommentsGuestModeration = 'comments_guest_moderation';
// How long after posting an author may still edit or delete their own
// comment (0 disables editing entirely).
case CommentsEditWindowMinutes = 'comments_edit_window_minutes';
// Whether a download's IP is recorded in the activity log (all |
// anonymous_only | none). Only affects Action::FileDownloaded /
// ShareLinkDownloaded entries — see ActivityLogger::shouldRecordIp().
// v1 parity: privacy_record_downloads_ip_address.
case DownloadIpLogging = 'download_ip_logging';
// How long API request telemetry is kept before pruning. Distinct from
// the activity log, which is an audit trail and is never pruned — see
// the api_request_logs migration for why the two are separate.
case ApiRequestLogRetentionDays = 'api_request_log_retention_days';
// How many days a self-deleted account is retained (soft-deleted)
// before PurgeErasuresCommand permanently erases it. Consumed by
// ProfileController.
case AccountErasureGraceDays = 'account_erasure_grace_days';
// What the unattended erasure does with the files and folders a purged
// account owned: 'cascade_delete' removes them, 'reassign' hands them to
// AccountErasureReassignTo. The admin-initiated delete asks this per
// account; the cron job cannot, so it follows this setting. Consumed by
// AccountEraser.
case AccountErasureContentAction = 'account_erasure_content_action';
// The active account that inherits a purged account's content when
// AccountErasureContentAction is 'reassign'. 0 = none chosen; if it is
// unset or no longer valid at erase time, AccountEraser cascade-deletes
// instead, so content is never left orphaned.
case AccountErasureReassignTo = 'account_erasure_reassign_to';
// Whether PurgeExpiredFilesCommand's daily run actually deletes
// anything (off by default — deletion is destructive, so an admin
// must opt in) and how many days after a file's own expires_at it
// waits before doing so. Consumed by FileRetentionSettingsController
// and PurgeExpiredFilesCommand.
case ExpiredFilesAutoDeleteEnabled = 'expired_files_auto_delete_enabled';
case ExpiredFilesDeleteAfterDays = 'expired_files_delete_after_days';
// Whether PurgeOrphanFilesCommand's daily run actually deletes
// anything (off by default) and how many days after an orphan is
// first found it waits before doing so. Hardcoded off — regardless
// of this stored value — whenever external storage is active; this
// feature only ever operates on the local disk (see
// PurgeOrphanFilesCommand and FileRetentionSettingsController).
case OrphanFilesAutoDeleteEnabled = 'orphan_files_auto_delete_enabled';
case OrphanFilesDeleteAfterDays = 'orphan_files_delete_after_days';
// Emits <meta name="robots" content="noindex"> site-wide when true.
// v1 parity: privacy_noindex_site.
case DiscourageSearchIndexing = 'discourage_search_indexing';
// Toggles only the browsable *directory* (the front page listing
// every public group/file) — a specific public group's page and
// downloads work off its own `public` flag regardless of this
// setting, same as a share link isn't gated by any global toggle.
// v1 parity: the public_listing_* feature, rescoped to v2's
// group/file public flags instead of tokens. See PublicGroupsController.
case PublicListingEnabled = 'public_listing_enabled';
// The configurable base URL segment for the public listing (e.g.
// "public" -> /public, /public/{group-slug}). Consumed by
// PublicGroupsController's guard against every request's first path
// segment.
case PublicListingSlug = 'public_listing_slug';
// The active theme (a ThemeRegistry key) shared by the guest public
// pages and the client portal — one selection covers both, matching
// v1's single selected_clients_template. See PublicThemeRegistry.
case Theme = 'theme';
// The active theme for outgoing notification emails — a header/footer
// skin, independent of the per-notification subject/body editor
// (EmailTemplate). See EmailThemeRegistry.
case EmailTheme = 'email_theme';
// Which of the installed translation catalogues the language switcher
// actually offers. Installing a pack (dropping lang/{locale}.json in)
// makes a language *available*; this makes it *offered*, so an
// operator can run an English-and-Spanish site without the other
// fourteen catalogues showing up on their clients' screens. English
// is always offered whatever this says — it is the translation key,
// not a catalogue. See LocaleRegistry.
case EnabledLocales = 'enabled_locales';
// The language everyone starts in — the last word after a signed-in
// person's own preference and the browser's Accept-Language header,
// and therefore what an anonymous visitor with no matching header
// actually sees. English is only the default because it is the
// default default; an operator whose clients are all Spanish should
// not have to make each of them switch. Empty means "whatever
// APP_LOCALE says", so an existing install that set the environment
// variable keeps working until someone chooses here. Always resolved
// through LocaleRegistry::defaultLocale(), which refuses to hand back
// a language that is not enabled.
case DefaultLocale = 'default_locale';
// The clock everyone reads the application by — v1 parity: its
// `timezone` option. Unlike v1, nothing here calls
// date_default_timezone_set(): storage and computation stay UTC and
// this is applied only where a date is shown or a calendar day is
// asked for. That is what makes it safe for a signed-in user to
// override it with their own (users.timezone) without any two
// viewers disagreeing about what is actually stored.
//
// Empty means "whatever APP_TIMEZONE says", exactly like
// DefaultLocale above — an install that set the environment variable
// keeps working until someone chooses here. Always resolved through
// TimezoneRegistry, which refuses to hand back an identifier tzdata
// no longer knows.
case Timezone = 'timezone';
// Community-edition only (Capability::SystemUpdates) — self-hosted
// opt-out for the daily "is a newer version available" check. There
// is no in-app self-updater (see CheckForUpdatesCommand's docblock
// for why); this only controls whether the dashboard's System card
// is allowed to tell the admin a newer release exists.
case CheckForUpdates = 'check_for_updates';
// Cached result of the last update check — never written directly by
// a settings form, only by CheckForUpdatesCommand. Empty string means
// "no successful check yet" (fresh install, or checks disabled).
case LatestKnownVersion = 'latest_known_version';
case LatestVersionCheckedAt = 'latest_version_checked_at';
// The rest of the cached release, for the "what's new" modal
// (LatestReleaseInfo) — title/notes/url/date of the GitHub release
// LatestKnownVersion came from. Notes is the release's raw Markdown
// body, rendered as plain text on the frontend (never as HTML —
// it's third-party content).
case LatestReleaseTitle = 'latest_release_title';
case LatestReleaseNotes = 'latest_release_notes';
case LatestReleaseUrl = 'latest_release_url';
case LatestReleasePublishedAt = 'latest_release_published_at';
// The version `projectsend:update` last brought this database in line
// with — written by that command only, never by a settings form.
//
// Its whole purpose is to be compared against config('projectsend.version')
// as read by the *web* process, which is not the same number when a
// manual install replaced its files and never reloaded PHP-FPM: OPcache
// keeps serving the code it compiled before the update, silently, while
// artisan reports the new version to the person trying to work out why.
// See RunningCodeState.
//
// Written downwards as well as upwards, because the command runs on
// every container boot and on every update.sh run — including the ones
// that go backwards.
case AppliedVersion = 'applied_version';
case AppliedVersionAt = 'applied_version_at';
// Cached result of the last dashboard news feed fetch — never written
// directly by a settings form, only by FetchNewsCommand. Both editions
// see this (unlike CheckForUpdates above, which is Community-only);
// gated on the view_news permission, not a capability. Each item's
// `content` is HTML-Purified at fetch time (see FetchNewsCommand), so
// it's already safe to render — unlike LatestReleaseNotes above, this
// one legitimately needs inline HTML (links to the full changelog).
case NewsItems = 'news_items';
case NewsLastFetchedAt = 'news_last_fetched_at';
// CAPTCHA on public forms (v1 parity: captcha_method). Which service
// is active — 'none' or a CaptchaProvider value — and, on cloud only,
// whether the platform's own keys are used ('managed') or this
// installation's ('own'). The credentials themselves are not here:
// this store has no encrypted type, so they live in captcha_providers.
case CaptchaProvider = 'captcha_provider';
case CaptchaKeySource = 'captcha_key_source';
// Which forms actually ask for a token. Separate switches because an
// installation that takes no self-registrations, or does not want a
// challenge in front of its own staff login, should be able to say so
// without turning the whole feature off.
case CaptchaOnLogin = 'captcha_on_login';
case CaptchaOnRegistration = 'captcha_on_registration';
case CaptchaOnPasswordReset = 'captcha_on_password_reset';
case CaptchaOnPublicComments = 'captcha_on_public_comments';
public function type(): SettingType
{
return match ($this) {
self::SiteName,
self::ClientsCanSelectGroup,
self::TwoFactorEnforcement,
self::UploadTypeRestriction,
self::DownloadIpLogging,
self::CommentsScope,
self::CommentsAuthors,
self::PublicListingSlug,
self::DefaultLocale,
self::Timezone,
self::Theme,
self::EmailTheme,
self::LatestKnownVersion,
self::LatestVersionCheckedAt,
self::LatestReleaseTitle,
self::LatestReleaseNotes,
self::LatestReleaseUrl,
self::LatestReleasePublishedAt,
self::AppliedVersion,
self::AppliedVersionAt,
self::NewsLastFetchedAt,
self::CaptchaProvider,
self::CaptchaKeySource,
self::AccountErasureContentAction => SettingType::String,
self::ClientsCanRegister,
self::ClientsAutoApprove,
self::EmailNotificationsEnabled,
self::DiscourageSearchIndexing,
self::PublicListingEnabled,
self::CheckForUpdates,
self::ExpiredFilesAutoDeleteEnabled,
self::PublicCommentsEnabled,
self::CommentsGuestModeration,
self::OrphanFilesAutoDeleteEnabled,
self::CaptchaOnLogin,
self::CaptchaOnRegistration,
self::CaptchaOnPasswordReset,
self::CaptchaOnPublicComments,
self::PasswordRejectBreached => SettingType::Boolean,
self::ClientsAutoGroup,
self::ClientsMembershipDenyCooldownDays,
self::MaxFileSizeMb,
self::DefaultClientStorageQuotaMb,
self::AccountErasureGraceDays,
self::AccountErasureReassignTo,
self::ApiRequestLogRetentionDays,
self::ExpiredFilesDeleteAfterDays,
self::CommentsEditWindowMinutes,
self::OrphanFilesDeleteAfterDays,
self::PasswordMinLength => SettingType::Integer,
self::AdminNotificationEmails,
self::AllowedUploadExtensions,
self::EnabledLocales,
self::NewsItems => SettingType::Json,
};
}
// Widen this union as settings with new types are added — PHPStan
// enforces that it matches what the cases actually return.
/**
* @return string|bool|int|list<string>|list<array{title: string, date: string, content: string, link: string}>
*/
public function default(): string|bool|int|array
{
return match ($this) {
self::SiteName => 'ProjectSend',
self::TwoFactorEnforcement => 'none',
// 12 rather than Laravel's 8: this was the value security audit
// finding 14 settled on, and the setting exists so an
// installation can move it, not so an upgrade can lower it.
self::PasswordMinLength => 12,
self::PasswordRejectBreached => true,
self::ClientsCanSelectGroup => 'none',
self::CaptchaProvider => 'none',
// Inert without Capability::CaptchaManagedKeys, so this is the
// default only in the edition where it means anything. A cloud
// tenant is protected from the first request rather than after
// finding the screen.
self::CaptchaKeySource => 'managed',
self::ClientsCanRegister,
self::ClientsAutoApprove,
self::EmailNotificationsEnabled,
self::DiscourageSearchIndexing,
self::PublicListingEnabled,
self::ExpiredFilesAutoDeleteEnabled,
self::PublicCommentsEnabled,
self::OrphanFilesAutoDeleteEnabled => false,
self::CheckForUpdates,
self::CommentsGuestModeration,
// On by default, but only ever consulted once a provider is
// configured — so a fresh install is not protecting forms it
// has no keys for.
self::CaptchaOnLogin,
self::CaptchaOnRegistration,
self::CaptchaOnPasswordReset,
self::CaptchaOnPublicComments => true,
self::ClientsAutoGroup => 0,
self::ClientsMembershipDenyCooldownDays => 30,
self::MaxFileSizeMb => 2048,
self::DefaultClientStorageQuotaMb => 0,
self::AccountErasureGraceDays => 30,
self::AccountErasureReassignTo => 0,
self::ApiRequestLogRetentionDays => 30,
self::ExpiredFilesDeleteAfterDays => 30,
self::OrphanFilesDeleteAfterDays => 30,
self::CommentsEditWindowMinutes => 15,
self::AdminNotificationEmails => [],
// English only out of the box: a fresh install offering
// sixteen languages nobody asked for is noise, and an
// operator who wants more turns them on deliberately.
self::EnabledLocales => ['en'],
self::UploadTypeRestriction => 'all',
self::DownloadIpLogging => 'all',
// Erasure removes a person's data, so deleting the files they
// uploaded is the sensible zero-config default; reassign is opt-in
// and needs a fallback account chosen.
self::AccountErasureContentAction => 'cascade_delete',
// Commenting is on for every file out of the box, but only
// between people who are logged in: reaching the public
// requires PublicCommentsEnabled, which is off by default.
self::CommentsScope => 'all',
self::CommentsAuthors => 'staff_and_clients',
self::PublicListingSlug => 'public',
self::DefaultLocale => '',
self::Timezone => '',
self::Theme => 'default',
self::EmailTheme => 'default',
self::LatestKnownVersion => '',
self::LatestVersionCheckedAt => '',
self::LatestReleaseTitle => '',
self::LatestReleaseNotes => '',
self::LatestReleaseUrl => '',
self::LatestReleasePublishedAt => '',
// Empty means no update has ever been applied through the
// command — a fresh install, or one that predates it. Never a
// notice, in either direction.
self::AppliedVersion => '',
self::AppliedVersionAt => '',
self::NewsLastFetchedAt => '',
self::NewsItems => [],
// Documents, images, audio, video, archives. Deliberately
// excludes server-executable extensions (php, cgi, …) and
// browser-renderable-with-script types (htm, html, svg) —
// FileThumbnailController::preview() serves files inline
// using the stored mime type, so an allowed .html/.svg
// would execute script in the previewer's browser.
self::AllowedUploadExtensions => [
'pdf', 'doc', 'docx', 'docm', 'dot', 'dotx', 'rtf', 'txt', 'csv', 'odt', 'ott',
'ppt', 'pptx', 'pptm', 'pps', 'ppsx', 'odp',
'xls', 'xlsx', 'xlsm', 'xltx', 'ods',
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'tif', 'tiff', 'webp', 'heic', 'psd', 'ai', 'eps',
'mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac',
'mp4', 'mov', 'avi', 'mpg', 'mpeg', 'webm', 'mkv', 'wmv',
'zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'iso',
],
};
}
}