Files
projectsend/app/Modules/Platform/Settings/Setting.php
T
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

427 lines
21 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';
// 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::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 => '',
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',
],
};
}
}