Files
projectsend/routes/settings.php
T
ignacionelson dc0937fda1 Add an Activity tab that shows a scan as it happens
A backfill runs for minutes or hours inside a queue worker, where none
of it is visible. The third tab polls every four seconds and says what
is happening: whether anything is running, how many uploads are held,
how deep the queue is, how many files were checked in the last hour,
and the last twenty verdicts with what each one was. When nothing is
running, that same list is the record of the last run, which is what
somebody opening the tab after the fact came for.

Two things the live screen found that the tests had not:

**A backfill read as "nothing is being scanned."** Re-scanning a file
that already went out unchecked deliberately leaves it available, so it
is never "pending" — and the screen counted only pending files. It
counts the scans queue too, and the two are shown separately, because
"an upload nobody can download yet" and "work the scanner has not
reached" are different facts.

**A file whose bytes are missing was recorded as "the scanner could not
be reached."** Wrong on screen, and worse than wrong in behaviour: that
is the one reason the hourly sweep re-queues, so every orphaned row
would have been rescanned every hour forever. It has its own reason
now, and goes through the same policy as a file the scanner could not
open.

Both tabs also gained the header shortcut to Quarantine, and Quarantine
one back to the settings, each shown only to somebody the destination
will actually let in.
2026-09-16 20:18:37 -03:00

320 lines
24 KiB
PHP

<?php
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Modules\Clients\Http\Controllers\ClientSettingsController;
use App\Modules\Comments\Http\Controllers\CommentSettingsController;
use App\Modules\Files\Http\Controllers\DownloadSettingsController;
use App\Modules\Files\Http\Controllers\FileRetentionSettingsController;
use App\Modules\Files\Http\Controllers\UploadSettingsController;
use App\Modules\Files\Http\Controllers\VirusScanningSettingsController;
use App\Modules\Identity\Http\Controllers\ApiTokensController;
use App\Modules\Identity\Http\Controllers\ConnectedAccountsController;
use App\Modules\Identity\Http\Controllers\LdapSettingsController;
use App\Modules\Identity\Http\Controllers\SecuritySettingsController;
use App\Modules\Identity\Http\Controllers\SocialLoginController;
use App\Modules\Identity\Http\Controllers\SocialLoginSettingsController;
use App\Modules\Identity\Http\Controllers\TwoFactorEnrollmentController;
use App\Modules\Notifications\Http\Controllers\NotificationPreferencesController;
use App\Modules\Platform\Branding\Http\Controllers\BrandingController;
use App\Modules\Platform\Http\Controllers\AboutController;
use App\Modules\Platform\Http\Controllers\CaptchaSettingsController;
use App\Modules\Platform\Http\Controllers\EmailOAuthController;
use App\Modules\Platform\Http\Controllers\EmailSettingsController;
use App\Modules\Platform\Http\Controllers\EmailTemplatesController;
use App\Modules\Platform\Http\Controllers\ExternalStorageSettingsController;
use App\Modules\Platform\Http\Controllers\GettingStartedController;
use App\Modules\Platform\Http\Controllers\LanguageSettingsController;
use App\Modules\Platform\Http\Controllers\PrivacySettingsController;
use App\Modules\Platform\Http\Controllers\PublicListingSettingsController;
use App\Modules\Platform\Http\Controllers\SchedulerMonitoringController;
use App\Modules\Platform\Http\Controllers\SystemSettingsController;
use App\Modules\Platform\Http\Controllers\ThemingSettingsController;
use App\Modules\Platform\Http\Controllers\WhatsNewController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function () {
// Route::redirect()'s destination needs a leading slash, or Laravel
// deliberately emits a *relative* Location header (stripping the
// leading slash it would otherwise generate) instead of an absolute
// one — see Illuminate\Routing\RedirectController. A relative
// redirect resolves against the current path's directory, which
// breaks the moment the redirect lives more than one segment deep
// (e.g. system/settings -> system/settings/general would resolve to
// system/system/settings/general). Always use an absolute path here.
Route::redirect('settings', '/settings/profile');
Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::get('settings/delete-account', [ProfileController::class, 'deleteAccount'])->name('profile.delete-account');
// Throttled for the same reason as confirm-password in routes/auth.php:
// destroy() verifies the account's password (`current_password`), and a
// credential check without a bucket is a credential check somebody can
// sit on. Named buckets, per the note at the top of routes/auth.php --
// a bare `throttle:` would share one counter with the public share
// links.
Route::delete('settings/profile', [ProfileController::class, 'destroy'])
->middleware('throttle:6,1,account-delete')
->name('profile.destroy');
Route::get('settings/password', [PasswordController::class, 'edit'])->name('password.edit');
// Same again: update() verifies `current_password` before it writes.
Route::put('settings/password', [PasswordController::class, 'update'])
->middleware('throttle:6,1,password-update')
->name('password.update');
Route::get('settings/two-factor', [TwoFactorEnrollmentController::class, 'show'])->name('two-factor.show');
// Staff and clients alike: which providers you have connected is a
// property of your account, not of your role.
Route::get('settings/connected-accounts', [ConnectedAccountsController::class, 'edit'])
->name('connected-accounts.edit');
// Behind password.confirm for the same reason as the two-factor block
// and the API tokens below: a SocialAccount row "*is* the
// authorization to sign in as that account", so it outlives the
// session that created it. It survives a password change, it survives
// Auth::logoutOtherDevices(), and it survives every session being
// invalidated -- which makes "attach my provider identity to your
// account" the most durable thing a stolen session can do. Starting
// the flow is what binds it, because the callback finishes with
// whichever provider account signed in at the other end, not
// necessarily the victim's.
Route::post('settings/connected-accounts/{provider}', [SocialLoginController::class, 'connect'])
->middleware(['password.confirm', 'throttle:20,1,social-connect'])
->name('connected-accounts.connect');
// Deliberately without it. Disconnecting removes a way in
// rather than adding one, and destroy() already refuses to remove the
// last one ("This is the only way you can sign in"). Requiring a
// password confirmation here would fall hardest on exactly the
// accounts that have no local password to confirm with -- the ones
// provisioned by a provider -- and leave them unable to disconnect
// anything.
Route::delete('settings/connected-accounts/{provider}', [ConnectedAccountsController::class, 'destroy'])
->name('connected-accounts.destroy');
// Changing the state of the second factor re-proves the first one. A
// stolen session is exactly the situation 2FA exists to survive, so
// "disable 2FA" must not be reachable with nothing but that session.
// `confirm` is deliberately outside this group: it is mid-enrollment,
// already proves possession of the TOTP secret, and the enrollment it
// completes was itself password-confirmed by `store`.
Route::middleware('password.confirm')->group(function () {
Route::post('settings/two-factor', [TwoFactorEnrollmentController::class, 'store'])->name('two-factor.enable');
Route::post('settings/two-factor/recovery-codes', [TwoFactorEnrollmentController::class, 'regenerateRecoveryCodes'])
->name('two-factor.recovery-codes');
Route::delete('settings/two-factor', [TwoFactorEnrollmentController::class, 'destroy'])->name('two-factor.disable');
});
// Named bucket — see the note in routes/auth.php. A bare `throttle:`
// shares one counter with every other bare one, so confirming an
// enrolment code used to draw on the same six as re-sending a
// verification email.
Route::post('settings/two-factor/confirm', [TwoFactorEnrollmentController::class, 'confirm'])
->middleware('throttle:6,1,two-factor-confirm')->name('two-factor.confirm');
// API tokens are account-level credentials, so they live in the personal
// settings section — but staff-only, because /api/v1 is staff-only.
// Creating and revoking re-prove the password for the same reason the
// two-factor block above does: a token outlives the session that minted
// it, so a stolen session must not be enough to mint one.
Route::middleware('staff')->group(function () {
// `create` before `{token}`, or the literal segment is swallowed by
// the id parameter — the same ordering rule as files/orphans in
// routes/web.php.
Route::get('settings/api-tokens/create', [ApiTokensController::class, 'create'])->name('api-tokens.create');
Route::get('settings/api-tokens', [ApiTokensController::class, 'index'])->name('api-tokens.index');
Route::get('settings/api-tokens/{token}/edit', [ApiTokensController::class, 'edit'])->name('api-tokens.edit');
Route::middleware('password.confirm')->group(function () {
Route::post('settings/api-tokens', [ApiTokensController::class, 'store'])->name('api-tokens.store');
// Editing can widen what an already-issued secret may do, so it
// re-proves the password exactly as minting does.
Route::patch('settings/api-tokens/{token}', [ApiTokensController::class, 'update'])->name('api-tokens.update');
Route::delete('settings/api-tokens/{token}', [ApiTokensController::class, 'destroy'])->name('api-tokens.destroy');
});
});
Route::get('settings/notifications', [NotificationPreferencesController::class, 'edit'])->name('notification-preferences.edit');
Route::put('settings/notifications', [NotificationPreferencesController::class, 'update'])->name('notification-preferences.update');
// Read-only, so `staff` alone rather than the `can:edit_settings`
// group below: the release you are running, its licence and where
// its source lives are not a configuration secret, and the sidebar
// footer links every staff member here regardless of what they may
// edit.
Route::middleware('staff')->get('system/about', AboutController::class)->name('system.about');
// Where a new installation's administrator is sent on their first
// visit, and a page any staff member can come back to. `staff` alone:
// it is a list of links to screens they can already reach, and
// QuickStart filters it to the ones they may actually use.
Route::middleware('staff')
->get('system/getting-started', GettingStartedController::class)
->name('system.getting-started');
// Where an administrator is sent after an update, and a page anyone
// who may read About's environment block can revisit afterwards. The
// capability keeps it off managed installations, where nobody signed
// in here performed the update it thanks them for.
Route::middleware(['staff', 'capability:system.updates', 'can:view_system_info'])
->get('system/whats-new', WhatsNewController::class)
->name('system.whats-new');
// System-wide configuration — deliberately outside the account
// settings section; it lives under its own sidebar entry, one route
// per section (general today; clients, email, … as modules land).
Route::middleware(['staff', 'can:edit_settings'])->group(function () {
Route::redirect('system/settings', '/system/settings/general');
Route::get('system/settings/general', [SystemSettingsController::class, 'edit'])->name('system-settings.edit');
Route::patch('system/settings/general', [SystemSettingsController::class, 'update'])->name('system-settings.update');
// Its own throttle bucket, like every other action route here: a
// bare `throttle:5,1` is keyed on the domain and the address, so
// it would share one allowance with everything else that omits a
// name. The controller enforces manage_updates and the edition,
// and applies an installation-wide cooldown of its own.
Route::post('system/settings/check-for-updates', [SystemSettingsController::class, 'checkForUpdates'])
->middleware('throttle:5,1,check-for-updates-now')
->name('system-settings.check-for-updates');
Route::get('system/settings/security', [SecuritySettingsController::class, 'edit'])->name('system-settings.security.edit');
Route::patch('system/settings/security', [SecuritySettingsController::class, 'update'])->name('system-settings.security.update');
Route::get('system/settings/clients', [ClientSettingsController::class, 'edit'])->name('system-settings.clients.edit');
Route::patch('system/settings/clients', [ClientSettingsController::class, 'update'])->name('system-settings.clients.update');
Route::get('system/settings/uploads', [UploadSettingsController::class, 'edit'])->name('system-settings.uploads.edit');
Route::patch('system/settings/uploads', [UploadSettingsController::class, 'update'])->name('system-settings.uploads.update');
Route::get('system/settings/downloads', [DownloadSettingsController::class, 'edit'])->name('system-settings.downloads.edit');
Route::patch('system/settings/downloads', [DownloadSettingsController::class, 'update'])->name('system-settings.downloads.update');
Route::get('system/settings/file-retention', [FileRetentionSettingsController::class, 'edit'])->name('system-settings.file-retention.edit');
Route::patch('system/settings/file-retention', [FileRetentionSettingsController::class, 'update'])->name('system-settings.file-retention.update');
Route::get('system/settings/virus-scanning', [VirusScanningSettingsController::class, 'edit'])->name('system-settings.virus-scanning.edit');
Route::patch('system/settings/virus-scanning', [VirusScanningSettingsController::class, 'update'])->name('system-settings.virus-scanning.update');
// Named buckets, like the email and CAPTCHA test buttons beside
// them: both of these reach out or start work, and neither should
// be a button somebody can sit on.
Route::post('system/settings/virus-scanning/test', [VirusScanningSettingsController::class, 'test'])
->middleware('throttle:12,1,scanner-test')->name('system-settings.virus-scanning.test');
// Polled every few seconds while the Activity tab is open, so it
// is a plain JSON read with its own generous bucket rather than
// an Inertia render.
Route::get('system/settings/virus-scanning/activity', [VirusScanningSettingsController::class, 'activity'])
->middleware('throttle:120,1,scanner-activity')->name('system-settings.virus-scanning.activity');
Route::post('system/settings/virus-scanning/scan-existing', [VirusScanningSettingsController::class, 'scanExisting'])
->middleware('throttle:6,1,scanner-backfill')->name('system-settings.virus-scanning.scan-existing');
Route::get('system/settings/comments', [CommentSettingsController::class, 'edit'])->name('system-settings.comments.edit');
Route::patch('system/settings/comments', [CommentSettingsController::class, 'update'])->name('system-settings.comments.update');
Route::get('system/settings/email', [EmailSettingsController::class, 'edit'])->name('system-settings.email.edit');
Route::patch('system/settings/email', [EmailSettingsController::class, 'update'])->name('system-settings.email.update');
Route::post('system/settings/email/test', [EmailSettingsController::class, 'sendTest'])->name('system-settings.email.test');
// The OAuth mailbox behind the Microsoft 365 provider. Its own
// throttle buckets like every action route here; the callback is
// a GET because it is the provider redirecting the admin's
// browser back, session and all.
Route::post('system/settings/email/oauth/connect', [EmailOAuthController::class, 'connect'])
->middleware('throttle:20,1,mail-oauth-connect')
->name('system-settings.email.oauth.connect');
Route::get('system/settings/email/oauth/callback', [EmailOAuthController::class, 'callback'])
->middleware('throttle:20,1,mail-oauth-callback')
->name('system-settings.email.oauth.callback');
Route::delete('system/settings/email/oauth', [EmailOAuthController::class, 'disconnect'])
->name('system-settings.email.oauth.disconnect');
// Deliberately outside any capability: group. LDAP is an
// administrator's setting, available in every edition, not an
// edition difference.
Route::get('system/settings/ldap', [LdapSettingsController::class, 'edit'])->name('system-settings.ldap.edit');
Route::patch('system/settings/ldap', [LdapSettingsController::class, 'update'])->name('system-settings.ldap.update');
Route::post('system/settings/ldap/test', [LdapSettingsController::class, 'test'])->name('system-settings.ldap.test');
// Outside any capability: group for the same reason as LDAP above.
Route::get('system/settings/social-login', [SocialLoginSettingsController::class, 'edit'])
->name('system-settings.social-login.edit');
Route::patch('system/settings/social-login/{provider}', [SocialLoginSettingsController::class, 'update'])
->name('system-settings.social-login.update');
// Unlike LDAP and social login above, this one does get a
// capability: group — present in both editions, so a self-hosted
// installation keeps the screen it has always had, and only
// removed when an operator names captcha.configure in
// PROJECTSEND_CAPABILITIES_DISABLED.
//
// The reason a managed platform would: its tenants share one
// parent domain and one sending reputation, so an administrator
// switching their own CAPTCHA off spends everybody else's. Same
// shape as Storage below — all-or-nothing on the route, read
// included. Per-field gating in the controller would leave the
// hole open, because turning the CAPTCHA off (provider `none`, or
// just unticking the four per-form switches) needs none of the
// gated fields. The middleware covers the PATCH as well as the
// GET, which is what closes it.
//
// Which keys the screen may offer is a second, narrower question,
// still answered per field inside the controller by
// Capability::CaptchaManagedKeys.
Route::middleware('capability:captcha.configure')->group(function () {
Route::get('system/settings/captcha', [CaptchaSettingsController::class, 'edit'])->name('system-settings.captcha.edit');
Route::patch('system/settings/captcha', [CaptchaSettingsController::class, 'update'])->name('system-settings.captcha.update');
Route::post('system/settings/captcha/test', [CaptchaSettingsController::class, 'test'])->name('system-settings.captcha.test');
});
Route::get('system/settings/privacy', [PrivacySettingsController::class, 'edit'])->name('system-settings.privacy.edit');
Route::patch('system/settings/privacy', [PrivacySettingsController::class, 'update'])->name('system-settings.privacy.update');
Route::get('system/settings/public-listing', [PublicListingSettingsController::class, 'edit'])->name('system-settings.public-listing.edit');
Route::patch('system/settings/public-listing', [PublicListingSettingsController::class, 'update'])->name('system-settings.public-listing.update');
Route::get('system/settings/languages', [LanguageSettingsController::class, 'edit'])->name('system-settings.languages.edit');
Route::patch('system/settings/languages', [LanguageSettingsController::class, 'update'])->name('system-settings.languages.update');
Route::get('system/settings/theming', [ThemingSettingsController::class, 'edit'])->name('system-settings.theming.edit');
Route::patch('system/settings/theming', [ThemingSettingsController::class, 'update'])->name('system-settings.theming.update');
Route::get('system/settings/theming/email-preview/{key}', [ThemingSettingsController::class, 'previewEmail'])
->name('system-settings.theming.email-preview');
// Every field on this page is Community-only (Capability::StorageConfigure)
// — unlike email settings, there's no capability-independent
// sub-field to keep serving in Cloud, so the whole surface is
// gated at the route level instead of per-field in the controller.
Route::middleware('capability:storage.configure')->group(function () {
Route::get('system/settings/storage', [ExternalStorageSettingsController::class, 'edit'])->name('system-settings.storage.edit');
Route::patch('system/settings/storage', [ExternalStorageSettingsController::class, 'update'])->name('system-settings.storage.update');
Route::post('system/settings/storage/test', [ExternalStorageSettingsController::class, 'testConnection'])->name('system-settings.storage.test');
});
// Both editions since 2026-08-28, and gated all-or-nothing like
// Storage above: read included, so a staff member who may not
// change the logo never sees the page either. A managed
// installation on a plan without branding has the capability
// subtracted from its environment, and these 404 for it — the
// instance refusing is the enforcement, not the portal's screen.
Route::middleware('capability:branding.customize')->group(function () {
Route::get('system/settings/branding', [BrandingController::class, 'edit'])->name('branding.edit');
Route::post('system/settings/branding', [BrandingController::class, 'store'])->name('branding.store');
Route::delete('system/settings/branding', [BrandingController::class, 'destroy'])->name('branding.destroy');
// POST rather than PATCH: the form carries a file, so it is
// multipart, and PHP only populates $_FILES for POST.
Route::post('system/settings/branding/watermark', [BrandingController::class, 'updateWatermark'])->name('branding.watermark.update');
Route::delete('system/settings/branding/watermark', [BrandingController::class, 'destroyWatermark'])->name('branding.watermark.destroy');
// The live sample on the settings screen: a GET with the values
// currently in the form, so it updates as they are adjusted
// rather than only after a save.
Route::get('system/settings/branding/watermark/sample', [BrandingController::class, 'watermarkSample'])->name('branding.watermark.sample');
});
// Same all-or-nothing shape as Storage above (Capability::SchedulerMonitoring).
Route::middleware('capability:scheduler.monitoring')->group(function () {
Route::get('system/settings/scheduler', [SchedulerMonitoringController::class, 'index'])->name('system-settings.scheduler.index');
Route::post('system/settings/scheduler/failed-jobs/{uuid}/retry', [SchedulerMonitoringController::class, 'retryFailedJob'])->name('system-settings.scheduler.retry');
Route::delete('system/settings/scheduler/failed-jobs/{uuid}', [SchedulerMonitoringController::class, 'destroyFailedJob'])->name('system-settings.scheduler.destroy');
Route::delete('system/settings/scheduler/failed-jobs', [SchedulerMonitoringController::class, 'destroyAllFailedJobs'])->name('system-settings.scheduler.destroy-all');
Route::patch('system/settings/scheduler/retention', [SchedulerMonitoringController::class, 'updateRetention'])->name('system-settings.scheduler.retention');
});
});
// Distinct from edit_settings, matching v1's exact permission split.
Route::middleware(['staff', 'can:edit_email_templates'])->group(function () {
Route::get('system/settings/email-templates', [EmailTemplatesController::class, 'index'])->name('email-templates.index');
Route::get('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'edit'])->name('email-templates.edit');
Route::patch('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'update'])->name('email-templates.update');
Route::delete('system/settings/email-templates/{slot}', [EmailTemplatesController::class, 'destroy'])->name('email-templates.destroy');
Route::get('system/settings/email-templates/{slot}/preview', [EmailTemplatesController::class, 'preview'])->name('email-templates.preview');
});
});