Files
projectsend/app/Modules/Platform/Settings/Setting.php
T
ignacionelson 616a355d54 Give each client a folder of their own, standing in for the root
A client who may create folders creates them at the top of the library,
beside the ones staff made, and their uploads land at the root too. An
administrator opening /files gets one flat pile with nothing saying which
parts belong to whom.

With the new "Give each client a folder of their own" setting, every new
client gets a folder named after them and it acts as their root: what they
upload and any folder they create goes inside it. /files becomes a list of
clients rather than a pile.

The sentence this feature has to keep true: **the home is a default
location, not a boundary.** Folder::scopeVisibleToClient is untouched, so a
folder staff shared with a client still reaches them and sits beside their
own. Making the home a jail would have silently revoked every share that
already exists -- a data-access change wearing the clothes of a tidying-up
feature. There is a test named after that rule.

What the client sees is the *inside* of their folder, not a folder wearing
their own name, which is not information to them. The breadcrumb is trimmed
of it for the same reason: "Invoices", not "Acme Ltd / Invoices".

Some decisions worth naming:

- **A column, not a convention.** `folders.home_for_user_id`, unique.
  Matching on the name breaks the moment two clients share one, and
  `created_by` plus a null parent catches every root folder a client ever
  made themselves. The question is asked on each upload and each portal
  listing and the answer has to be exact.
- **created_by is the client**, because that is how scopeVisibleToClient
  already grants somebody their own folder -- no assignment row to keep in
  step with it. That is also why this writes the row rather than calling
  FolderService::create(), which takes created_by from auth()->id().
- **On model events**, not in the services that make and rename clients.
  There are nine of those (ClientAccounts, ClientProvisioning, the profile
  screen, two update endpoints, AccountConversion, invitations, LDAP,
  social) and a rule repeated in nine places is missing from the tenth.
- **Turning the setting on creates nothing.** Existing clients get a folder
  when an administrator presses a button that says how many are waiting,
  and it reports created/total/already-had afterwards. Somebody should be
  able to switch this on, look, and switch it off without having
  reorganised a library. It moves no files either.
- **Nobody deletes a home from a folder screen**, staff included, and the
  client cannot rename theirs -- they own it, so ownership alone would have
  let them, and its name follows the account anyway.
- **The name always follows the client**, over a hand-typed one. A folder
  still called "Acme Ltd" under an account now called something else
  misleads the administrator the feature exists for.

Verified in a real browser as well as in tests: the screen mounts, the
panel reads "24 of your existing clients have no folder yet", and pressing
the button answers "24 of 24 clients got a folder. 0 already had one."
2026-09-17 00:22:29 -03:00

619 lines
31 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';
// How long a staff-sent client invitation link stays valid before the
// invited address has to ask for a new one. Consumed by
// InvitationController and InvitationRedemptionController, wherever a
// fresh Invitation is issued.
case ClientInvitationExpiryHours = 'client_invitation_expiry_hours';
// Whether the client portal offers inline preview at all — the whole
// affordance, images included, not just the media types. Staff are
// never gated by it: it exists so an installation can decide that a
// client either downloads a file or does not get it, without taking
// the tool away from the people who administer the library. See
// PreviewKind and FileThumbnailController::preview.
case ClientsCanPreviewFiles = 'clients_can_preview_files';
// Give every new client a folder of their own, named after them, and
// treat it as their root: their uploads and the folders they create
// land inside it instead of at the top of the library. What the
// administrator gets out of it is a /files that reads as one folder
// per client rather than a flat pile.
//
// It is NOT a boundary. A folder staff share with a client is still
// theirs to see, alongside their own -- making the home a jail would
// silently break every share that already exists. See ClientHomeFolders.
//
// Off by default, and turning it on changes nothing that already
// exists: existing clients get a home only when an administrator asks
// for one, with the button beside the switch. A setting that quietly
// reorganised a library on save would be one nobody could try.
case ClientsHomeFolders = 'clients_home_folders';
// 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';
// Largest zip download that can be asked for, in MB (0 = unlimited).
// The cap is on total size rather than on file count because bytes are
// what a build actually costs — worker time, the temp copies a remote
// disk needs, and the archive itself. The file count has its own,
// deliberately fixed, rail in ZipDownloadsController::MAX_FILES.
case MaxZipDownloadSizeMb = 'max_zip_download_size_mb';
// 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 long a permanently failed queue job is kept, and how long a
// notification somebody has already read is kept. Both tables grow
// with use and neither ever shrank on its own: the failed-jobs list
// waited for somebody to press "Delete all failed", and nothing at
// all pruned notifications. An installation nobody tends should not
// pay for that with its database.
//
// 0 means keep indefinitely in both cases, the same explicit choice
// ApiRequestLogRetentionDays offers.
case FailedJobRetentionDays = 'failed_job_retention_days';
case NotificationRetentionDays = 'notification_retention_days';
// How many days a deleted account is retained (soft-deleted) before
// PurgeErasuresCommand permanently erases it. Consumed by
// ErasureSchedule, which every deletion path calls.
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).
// Virus scanning — see docs/feature-virus-scanning.md and
// App\Modules\Files\Scanning\ScanningConfig, which is what reads
// these (the address and the on/off switch can both be overruled by a
// managed configuration in the environment).
case VirusScanningEnabled = 'virus_scanning_enabled';
case VirusScannerAddress = 'virus_scanner_address';
case VirusScanMaxSizeMb = 'virus_scan_max_size_mb';
// 'allow' or 'block' — what happens to a file the scanner cannot open
// (too large, or encrypted). Allowing marks it "not scanned" rather
// than calling it clean.
case VirusUnscannablePolicy = 'virus_unscannable_policy';
// 'allow' or 'hold' — what happens to new uploads while the scanner
// is unreachable.
case VirusScannerDownPolicy = 'virus_scanner_down_policy';
// How long a file waits for an unreachable scanner before the policy
// above is applied.
case VirusScannerWaitMinutes = 'virus_scanner_wait_minutes';
// How fast "Scan existing files" works through a library that was
// uploaded before scanning was switched on.
case VirusScanExistingRatePerMinute = 'virus_scan_existing_rate_per_minute';
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 same switch as ClientsCanPreviewFiles, for the anonymous side:
// whether a public file page offers to show the file as well as hand
// it over. v1 parity: public_listing_enable_preview. See
// PublicGroupsController::preview.
case PublicListingPreviewEnabled = 'public_listing_preview_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';
// Whether this installation fetches the project's news feed for the
// dashboard card. Its own key rather than riding on CheckForUpdates
// above, because they are two different wants: "do not tell me about
// releases" and "do not show me the project's news" are asked
// separately, and an installation with no outbound access at all
// wants both off while an ordinary one may want updates and no feed.
//
// On by default, so nothing changes for an installation that has
// never seen this switch.
case FetchNews = 'fetch_news';
// 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';
// An update finished and the administrator has not been shown what it
// brought yet — the two versions it moved between, so the page can
// name every release in the gap rather than only the newest.
//
// Written by UpdateInstallation, and only for a genuine update of an
// existing installation: a fresh install has nothing to catch up on,
// and a container that reboots has not updated anything. Cleared when
// the page is read. Both empty means nothing is waiting.
case UpdateWelcomeFrom = 'update_welcome_from';
case UpdateWelcomeTo = 'update_welcome_to';
// This installation has just been installed and its administrator has
// not been shown the getting-started page yet.
//
// Raised where the first administrator is created — the setup screen
// and the provisioning command both — and cleared when the page is
// read. False by default, which is what keeps an installation that
// updates into this feature from being congratulated on an
// installation it completed a year ago.
case GettingStartedPending = 'getting_started_pending';
// 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::VirusScannerAddress,
self::VirusUnscannablePolicy,
self::VirusScannerDownPolicy,
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::UpdateWelcomeFrom,
self::UpdateWelcomeTo,
self::NewsLastFetchedAt,
self::CaptchaProvider,
self::CaptchaKeySource,
self::AccountErasureContentAction => SettingType::String,
self::ClientsCanRegister,
self::ClientsAutoApprove,
self::ClientsHomeFolders,
self::EmailNotificationsEnabled,
self::DiscourageSearchIndexing,
self::PublicListingEnabled,
self::ClientsCanPreviewFiles,
self::PublicListingPreviewEnabled,
self::CheckForUpdates,
self::FetchNews,
self::ExpiredFilesAutoDeleteEnabled,
self::PublicCommentsEnabled,
self::CommentsGuestModeration,
self::OrphanFilesAutoDeleteEnabled,
self::CaptchaOnLogin,
self::CaptchaOnRegistration,
self::CaptchaOnPasswordReset,
self::CaptchaOnPublicComments,
self::PasswordRejectBreached,
self::VirusScanningEnabled,
self::GettingStartedPending => SettingType::Boolean,
self::ClientsAutoGroup,
self::ClientsMembershipDenyCooldownDays,
self::ClientInvitationExpiryHours,
self::MaxFileSizeMb,
self::MaxZipDownloadSizeMb,
self::DefaultClientStorageQuotaMb,
self::AccountErasureGraceDays,
self::AccountErasureReassignTo,
self::ApiRequestLogRetentionDays,
self::FailedJobRetentionDays,
self::NotificationRetentionDays,
self::ExpiredFilesDeleteAfterDays,
self::CommentsEditWindowMinutes,
self::OrphanFilesDeleteAfterDays,
self::VirusScanMaxSizeMb,
self::VirusScannerWaitMinutes,
self::VirusScanExistingRatePerMinute,
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,
// Off until somebody points it at a scanner, or a managed
// configuration names one — see ScanningConfig.
self::VirusScanningEnabled,
self::OrphanFilesAutoDeleteEnabled => false,
// Off, because switching it on is a change to how a library is
// laid out and that should be somebody's decision rather than
// something an upgrade did to them overnight.
self::ClientsHomeFolders => false,
self::CheckForUpdates,
self::FetchNews,
self::CommentsGuestModeration,
// On, so that an installation updating into these switches
// keeps the preview it already had rather than losing it to a
// setting nobody has seen yet.
self::ClientsCanPreviewFiles,
self::PublicListingPreviewEnabled,
// 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::ClientInvitationExpiryHours => 72,
self::MaxFileSizeMb => 2048,
self::MaxZipDownloadSizeMb => 2048,
self::DefaultClientStorageQuotaMb => 0,
self::AccountErasureGraceDays => 30,
self::AccountErasureReassignTo => 0,
self::ApiRequestLogRetentionDays => 30,
self::FailedJobRetentionDays => 30,
self::NotificationRetentionDays => 90,
self::ExpiredFilesDeleteAfterDays => 30,
self::OrphanFilesDeleteAfterDays => 30,
self::CommentsEditWindowMinutes => 15,
// Large enough for ordinary documents and archives, small
// enough that one upload does not hold the scanner for
// minutes. Must stay under clamd's own StreamMaxLength.
self::VirusScanMaxSizeMb => 512,
self::VirusScannerWaitMinutes => 10,
self::VirusScanExistingRatePerMinute => 60,
// Both of these let files through, which is the product
// owner's decision (2026-09-14): a scanner that cannot answer
// must not stop people working. What pays for it is
// visibility — every file allowed through this way is marked
// "not scanned", and the dashboard and projectsend:status both
// say so while it is happening.
self::VirusUnscannablePolicy,
self::VirusScannerDownPolicy => 'allow',
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',
// Empty means no scanner configured here. A managed
// configuration in the environment beats this when present —
// ScanningConfig, not this default, is what a caller asks.
self::VirusScannerAddress => '',
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::UpdateWelcomeFrom => '',
self::UpdateWelcomeTo => '',
// False, so that an installation which updates into this
// feature is not welcomed to an installation it finished a
// year ago. Only creating the first administrator raises it.
self::GettingStartedPending => false,
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',
],
};
}
}