Files
projectsend/app/Modules/Audit/Http/Controllers/DashboardController.php
T
ignacionelson 85b1650ef0 Tell a self-hosted installation when nothing is checking its uploads
The dashboard's System card now says so when no scanner is configured
at all, not only when a configured one is failing: "Anything uploaded
here — by staff, by clients, or through an upload link — is passed on
unchecked", with a link to set it up.

Said only where somebody can act on it. Connecting a scanner is a new
capability, scanning.connect, community only — on a hosted installation
the scanner is infrastructure the platform runs, so its address is not a
tenant's to set and its absence is not a tenant's to fix. The two
policies stay on both editions, because what to do with a file nobody
could scan is a decision about somebody's own files. An edition
difference through the registry, never an edition check.

Also: PROJECTSEND_SCANNER_DEFAULT_ADDRESS, seeded into the settings on
first boot by the command that already does this for two-factor
enforcement. It is the opposite of PROJECTSEND_SCANNER_ADDRESS — a
starting value rather than a policy, so a Docker install that brings up
the optional scanner container arrives configured while the address and
the switch stay on the settings screen. Both are seeded together or
neither: an address with scanning off would look configured and check
nothing.

Nothing changes for an existing installation on upgrade: scanning stays
off, existing files are marked "never scanned", and the scanner
container is still opt-in.
2026-09-16 15:34:49 -03:00

621 lines
29 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Audit\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Modules\Api\ApiUsage;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Audit\ActivityLogScope;
use App\Modules\Audit\ActivityPresenter;
use App\Modules\Audit\DashboardWidgetPreferences;
use Illuminate\Support\Facades\Event;
use App\Modules\Clients\ClientStorageUsage;
use App\Modules\Files\Access\StaffLibraryScope;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\ScanningConfig;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Scanning\VirusScanner;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\UserType;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Installation\Installation;
use App\Modules\Platform\Localization\TimezoneRegistry;
use App\Modules\Platform\News\NewsItems;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Modules\Platform\Storage\StorageDurability;
use App\Modules\Platform\System\SystemEnvironment;
use App\Modules\Platform\Updates\LatestReleaseInfo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
/**
* The dashboard (brief §6.11: dashboard & reporting live in Audit).
* Staff widgets are permission-gated individually (v1 keys:
* view_dashboard_counters, view_statistics, view_actions_log,
* view_system_info); clients get their own portal variant.
*/
class DashboardController extends Controller
{
public function __construct(
private readonly CapabilityRegistry $capabilities,
private readonly ClientStorageUsage $storageUsage,
private readonly LatestReleaseInfo $latestRelease,
private readonly NewsItems $newsItems,
private readonly DashboardWidgetPreferences $widgetPrefs,
private readonly Settings $settings,
private readonly ApiUsage $apiUsage,
private readonly StorageDurability $storageDurability,
private readonly FileDelivery $fileDelivery,
private readonly Installation $installation,
private readonly TimezoneRegistry $timezones,
private readonly SystemEnvironment $environment,
private readonly ActivityPresenter $presenter,
private readonly ActivityLogScope $scope,
private readonly StaffLibraryScope $library,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
assert($user !== null);
if ($user->isClient()) {
return $this->clientDashboard($user);
}
$timezone = $this->timezones->resolve($user);
[$from, $to, $preset] = $this->resolveTransferRange($request, $timezone);
$canCounters = $user->can('view_dashboard_counters');
$canStatistics = $user->can('view_statistics');
$canActionsLog = $user->can('view_actions_log');
$canSystem = $user->can('view_system_info') && $this->capabilities->has(Capability::SystemUpdates);
$canNews = $user->can('view_news');
$canExpiredFiles = $user->can('view_statistics');
$prefs = $this->widgetPrefs;
return Inertia::render('dashboard', [
'counters' => $canCounters && $prefs->isEnabled($user, 'counters') ? $this->counters() : null,
'transfers' => $canStatistics && $prefs->isEnabled($user, 'transfers') ? $this->transferSeries($from, $to, $timezone) : null,
'transfers_range' => $canStatistics && $prefs->isEnabled($user, 'transfers')
? ['preset' => $preset, 'from' => $from->toDateString(), 'to' => $to->toDateString()]
: null,
'top_clients_by_storage' => $canStatistics && $prefs->isEnabled($user, 'top_clients_by_storage')
? $this->topClientsByStorage($user)
: null,
'largest_files' => $canStatistics && $prefs->isEnabled($user, 'largest_files') ? $this->largestFiles($user) : null,
'recent' => $canActionsLog && $prefs->isEnabled($user, 'recent') ? $this->recentActivity($user) : null,
'system' => $canSystem && $prefs->isEnabled($user, 'system') ? $this->systemInfo() : null,
// Both editions — informational content, not an update action,
// so no Capability check alongside the permission (unlike
// 'system' above).
'news' => $canNews && $prefs->isEnabled($user, 'news') ? $this->newsItems->current() : null,
'expired_files' => $canExpiredFiles && $prefs->isEnabled($user, 'expired_files') ? $this->expiredFiles($user) : null,
// Not permission-gated: every staff member has tokens to look
// after, and it scopes itself to the viewer's own — the same
// rule the API dashboard applies.
'api' => $prefs->isEnabled($user, 'api') ? $this->apiUsage($user) : null,
// The layout is filtered to only the keys this viewer holds
// permission for — never reveal a permission-hidden widget's
// existence to the Widgets modal, same "don't tease
// unavailable features" convention as EnsureCapability's 404.
'widget_layout' => $prefs->layoutFor($user, $this->permittedWidgetKeys([
'counters' => $canCounters,
'transfers' => $canStatistics,
'top_clients_by_storage' => $canStatistics,
'largest_files' => $canStatistics,
'recent' => $canActionsLog,
'system' => $canSystem,
'news' => $canNews,
'expired_files' => $canExpiredFiles,
'api' => true,
])),
'dashboard_columns' => $prefs->columnsFor($user),
]);
}
/**
* A glance at the viewer's own API usage, with the detail a click away
* on the API dashboard.
*
* @return array<string, mixed>
*/
private function apiUsage(User $user): array
{
return [
'requests_7d' => $this->apiUsage->requestsSince($user, false, now()->subDays(7)),
'tokens' => $user->tokens()->count(),
'expired_tokens' => $user->tokens()->whereNotNull('expires_at')->where('expires_at', '<', now())->count(),
'last_used_at' => $user->tokens()->max('last_used_at'),
];
}
/**
* @param array<string, bool> $permissions
* @return list<string>
*/
private function permittedWidgetKeys(array $permissions): array
{
return array_keys(array_filter($permissions));
}
/**
* Reads the Transfers widget's date-range controls off the query
* string (?range=last_week|last_month|previous_month|custom, plus
* ?from=&to= for custom). Defaults to last_month — the same rolling
* 30-day window the widget always showed before the selector existed.
*
* Every boundary is built in the viewer's zone, so "last week" ends
* when their evening does and not at whatever hour UTC midnight falls
* on for them. The instants are absolute, but they carry that zone —
* and a Carbon handed to the query builder is formatted in its own
* zone, offset discarded, so comparing one against a UTC column asks
* a question nine hours out for a viewer in Tokyo. transferSeries()
* converts before it compares; the day cursor there keeps them as
* they are, because that half really is about the viewer's calendar.
*
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
private function resolveTransferRange(Request $request, string $timezone): array
{
$preset = $request->query('range');
$now = fn (): Carbon => now()->setTimezone($timezone);
return match ($preset) {
'last_week' => [$now()->subDays(6)->startOfDay(), $now()->endOfDay(), 'last_week'],
'previous_month' => (function () use ($now): array {
$previousMonth = $now()->subMonth();
return [$previousMonth->copy()->startOfMonth(), $previousMonth->copy()->endOfMonth(), 'previous_month'];
})(),
'custom' => $this->resolveCustomTransferRange($request, $timezone),
default => [$now()->subDays(29)->startOfDay(), $now()->endOfDay(), 'last_month'],
};
}
/**
* @return array{0: Carbon, 1: Carbon, 2: string}
*/
private function resolveCustomTransferRange(Request $request, string $timezone): array
{
$now = fn (): Carbon => now()->setTimezone($timezone);
try {
$from = $request->query('from') !== null ? Carbon::parse($request->query('from'), $timezone)->startOfDay() : null;
$to = $request->query('to') !== null ? Carbon::parse($request->query('to'), $timezone)->endOfDay() : null;
} catch (\Throwable) {
return [$now()->subDays(29)->startOfDay(), $now()->endOfDay(), 'last_month'];
}
$from ??= $now()->subDays(29)->startOfDay();
$to ??= $now()->endOfDay();
if ($from->gt($to)) {
[$from, $to] = [$to->copy()->startOfDay(), $from->copy()->endOfDay()];
}
// A year is plenty for a "custom range" on a daily-granularity
// chart — anything longer just clips to the most recent year
// rather than rendering an unreadable multi-year line.
if ($from->diffInDays($to) > 366) {
$from = $to->copy()->subDays(366)->startOfDay();
}
return [$from, $to, 'custom'];
}
/**
* @return array<string, int>
*/
private function counters(): array
{
// Deliberately installation-wide, unlike the three widgets below.
// A total carries no names — "417 files" tells a scoped viewer
// nothing about whose they are — and the same reasoning leaves
// transferSeries() alone. If that ever stops being the line, both
// move together.
return [
'files' => File::query()->count(),
'files_bytes' => (int) File::query()->sum('size'),
'clients' => User::query()->where('type', UserType::Client)->count(),
'groups' => Group::query()->count(),
'users' => User::query()->where('type', UserType::Staff)->count(),
];
}
/**
* Uploads vs downloads per day across the given range, zero-filled.
* Downloads split into "by clients" and "anonymous" — anonymous
* covers unauthenticated share-link/public-listing downloads, the
* traffic an admin has no other visibility into. Staff downloads
* (an admin grabbing a file to check it) count toward neither
* bucket; they're not the audience-facing traffic this chart tracks.
*
* @return list<array{date: string, uploads: int, downloads_clients: int, downloads_anonymous: int}>
*/
private function transferSeries(Carbon $from, Carbon $to, string $timezone): array
{
// Two forms of the same set: string values for the SQL whereIn,
// enum cases for filtering the already-cast `action` attribute
// once loaded — ActivityLog::casts() turns it into an Action
// instance, which a raw string never loosely equals.
$downloadActions = [Action::FileDownloaded, Action::ShareLinkDownloaded, Action::PublicFileDownloaded];
$rows = ActivityLog::query()
->whereIn('action', [Action::FileUploaded->value, ...array_map(fn (Action $a): string => $a->value, $downloadActions)])
// In UTC, because that is what the column is. The query
// builder formats a Carbon in whatever zone the object holds
// and drops the offset, so passing the viewer's midnight
// straight in compares "2026-08-22 00:00:00" against a UTC
// column — nine hours of somebody else's day, at both ends,
// for a viewer in Tokyo.
->whereBetween('created_at', [$from->copy()->utc(), $to->copy()->utc()])
->get(['action', 'actor_type', 'created_at'])
// Bucketed by the viewer's calendar day. Grouping on the UTC
// one puts an evening upload from anywhere west of Greenwich
// on tomorrow's bar, which the person who made it reads as
// the chart being a day out.
->groupBy(fn (ActivityLog $entry): string => $entry->created_at->copy()->setTimezone($timezone)->format('Y-m-d'));
$series = [];
// $from and $to already carry the viewer's zone (see
// resolveTransferRange), so these day keys line up with the
// grouping above.
$cursor = $from->copy()->startOfDay();
$lastDay = $to->copy()->startOfDay();
while ($cursor->lte($lastDay)) {
$date = $cursor->format('Y-m-d');
$entries = $rows->get($date, collect());
$downloads = $entries->whereIn('action', $downloadActions);
$series[] = [
'date' => $date,
'uploads' => $entries->where('action', Action::FileUploaded)->count(),
'downloads_clients' => $downloads->where('actor_type', UserType::Client->value)->count(),
'downloads_anonymous' => $downloads->whereNull('actor_type')->count(),
];
$cursor->addDay();
}
return $series;
}
/**
* The 5 clients using the most storage, each against their own
* effective quota (own override, or the site default — see
* ClientStorageUsage::quotaMb()) so the widget reads the same way
* the client-facing usage box does.
*
* @return list<array{id: int, name: string, used_bytes: int, quota_mb: int}>
*/
private function topClientsByStorage(User $viewer): array
{
// Narrowed by roster, not by library. This widget names *clients*,
// and files() is the wrong lens for that: a stranger client's
// upload can be inside a scoped viewer's library — shared with a
// group one of their own clients is in — which put the stranger's
// name on the widget. Measured: a client called "Stranger Client
// Ltd", on nobody's roster, ranked on a scoped dashboard.
// assignableClientIds is the question actually being asked.
$clientIds = $this->library->assignableClientIds($viewer);
$rows = File::query()
->select('uploaded_by', DB::raw('SUM(size) as total_bytes'))
->whereHas('uploader', fn ($query) => $query->where('type', UserType::Client))
->when($clientIds !== null, fn (Builder $query) => $query->whereIn('uploaded_by', $clientIds))
->groupBy('uploaded_by')
->orderByDesc('total_bytes')
->limit(5)
->get();
if ($rows->isEmpty()) {
return [];
}
$clients = User::query()->whereIn('id', $rows->pluck('uploaded_by'))->get()->keyBy('id');
$result = [];
foreach ($rows as $row) {
$client = $clients->get($row->uploaded_by);
if ($client === null) {
continue;
}
$result[] = [
'id' => (int) $row->uploaded_by,
'name' => $client->name,
'used_bytes' => (int) $row->getAttribute('total_bytes'),
'quota_mb' => $this->storageUsage->quotaMb($client),
];
}
return $result;
}
/**
* The 10 largest individual files on the installation, regardless of
* uploader — catches a single space hog that per-client aggregates
* (topClientsByStorage()) can't surface on their own.
*
* Edit/download/uploader links are coarse-gated on the viewer's own
* permissions only (same level of precision ActivityLogController's
* own link resolver uses) — not a per-file StaffLibraryScope check,
* so a client-scoped staff member could still see a link here that
* 403s if clicked. Accepted, matching existing precedent, rather
* than adding per-row scope checks to a 10-row dashboard widget.
*
* @return list<array{id: int, name: string, size: int, uploader_name: ?string, created_at: string, edit_url: ?string, download_url: ?string, uploader_edit_url: ?string}>
*/
private function largestFiles(User $viewer): array
{
// FilePolicy::view() — the ability both files.edit and
// files.download authorize against — requires one of these for
// staff. Client-facing files never appear here as an uploader
// (clients don't have "view" gated the same way), so this is the
// one check both links share.
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
$canClients = $viewer->can('edit_clients');
$staffModule = $this->capabilities->has(Capability::UsersManage) && $viewer->can('manage_users');
$canStaffUsers = $staffModule && $viewer->can('edit_users');
// Narrowed to the viewer's library, not just its links. The
// note above is about a link that 403s; a row that should not be
// here at all is a different problem, and the file's *name* is
// the part that leaks — "Q3 delinquent accounts" says plenty
// without being downloadable. Scoping the query costs one call:
// StaffLibraryScope builds a scoped user's query once per
// request, so this is not a per-row check.
return array_values($this->library->files($viewer)
->with('uploader:id,name,type')
->orderByDesc('size')
->limit(10)
->get(['id', 'name', 'size', 'uploaded_by', 'created_at'])
->map(function (File $file) use ($canFiles, $canClients, $canStaffUsers): array {
$uploader = $file->uploader; // uploaded_by is nullOnDelete — may be gone.
return [
'id' => $file->id,
'name' => $file->name,
'size' => $file->size,
'uploader_name' => $uploader?->name,
'created_at' => $file->created_at?->toIso8601String() ?? '',
'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null,
'download_url' => $canFiles ? route('files.download', $file->id, false) : null,
'uploader_edit_url' => match (true) {
$uploader === null => null,
$uploader->type === UserType::Client && $canClients => route('clients.edit', $uploader->id, false),
$uploader->type === UserType::Staff && $canStaffUsers => route('users.edit', $uploader->id, false),
default => null,
},
];
})->all());
}
/**
* The 10 soonest-expired files still awaiting the daily purge, plus
* enough context (is auto-delete even on, when does the job next run)
* for the widget to explain what's about to happen to them.
*
* @return array{count: int, files: list<array{id: int, name: string, expires_at: ?string, edit_url: ?string}>, auto_delete_enabled: bool, next_run_at: string}
*/
private function expiredFiles(User $viewer): array
{
// Same coarse gate largestFiles() uses for its edit links.
$canFiles = $viewer->can('upload') || $viewer->can('edit_files') || $viewer->can('edit_others_files');
return [
// Both the count and the list read the viewer's library, so
// the number cannot describe files the list is not allowed to
// name. Same reason largestFiles() is scoped.
//
// Narrower than it looks for a client-scoped viewer:
// File::scopeVisibleToClient ends in notExpired(), so an
// expired file belonging to one of their clients is not in
// their library, and only their own expired uploads reach
// this list. Rather than widen the boundary — which would
// mean a library query that keeps expired rows, and
// scopeVisibleToClient is the single source of truth for
// client file access — the widget says what it is showing.
// `scoped` is how it knows to.
'count' => $this->library->files($viewer)->expired()->count(),
'files' => array_values($this->library->files($viewer)->expired()->orderBy('expires_at')->limit(10)
->get(['id', 'name', 'expires_at'])
->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'expires_at' => $file->expires_at?->toIso8601String(),
'edit_url' => $canFiles ? route('files.edit', $file->id, false) : null,
])->all()),
// Whether this list is "everything expired" or "everything of
// yours that expired" — a widget whose whole job is warning
// about what is due to be deleted has to say which it means.
'scoped' => $viewer->isClientScoped(),
'auto_delete_enabled' => (bool) $this->settings->get(Setting::ExpiredFilesAutoDeleteEnabled),
// Schedule::command('projectsend:purge-expired-files')->daily()
// runs at 00:00 — always "tonight" from whenever this loads.
'next_run_at' => now()->copy()->startOfDay()->addDay()->toIso8601String(),
];
}
/**
* @return array<int, array<string, mixed>>
*/
private function recentActivity(User $viewer): array
{
// Narrowed through ActivityLogScope, exactly as the activity page and
// the download history are. `view_actions_log` is not the whole
// answer for a client-scoped viewer: a log entry carries the
// subject's name, so an unscoped one reads out the name of every
// file in the installation and who touched it, to somebody who gets
// a 403 on the files themselves. The Client Manager role ships with
// the permission, so this is the default configuration.
//
// Presented through the shared ActivityPresenter, not rebuilt inline —
// the same sentence-ready shape the activity page and detail panels
// use. Rebuilding it here once dropped `origin`, which is the only
// thing that tells an actorless "Anonymous" entry from a "System" one.
return $this->scope->apply(ActivityLog::query(), $viewer)
->orderByDesc('created_at')
->orderByDesc('id')
->limit(8)
->get()
->map(fn (ActivityLog $entry): array => $this->presenter->present($entry))
->all();
}
/**
* @return array<string, array<string, bool|int|string|null>|bool|int|string|null>
*/
private function systemInfo(): array
{
$freeBytes = @disk_free_space(storage_path('app/files'));
// Cached by CheckForUpdatesCommand (daily) — never a live HTTP
// call from the request path. null means either no successful
// check yet, or the current version is already the latest.
$release = $this->latestRelease->current();
return [
...$this->environment->toArray(),
'storage_used_bytes' => (int) File::query()->sum('size'),
'storage_free_bytes' => $freeBytes === false ? -1 : (int) $freeBytes,
'update_available' => $release !== null,
'latest_version' => $release['version'] ?? null,
'release_url' => $release['url'] ?? null,
// Null unless this is a container whose uploads still go to the
// local disk — see StorageDurability.
'storage_durability' => $this->storageDurability->inspect(),
// Decides which upgrade instructions the card prints — see
// Installation. Always present, unlike storage_durability, which
// is null whenever the durability question does not apply.
'install_kind' => $this->installation->kind()->value,
// How downloads leave the server, and whether that was
// detected or stated. Reported even when it is the fast path:
// "my downloads are handed to the web server" is worth being
// able to confirm at a glance, not only worth warning about
// when it is false — the same reasoning as storage_durability.
'file_delivery' => $this->fileDelivery->describe(),
// Null when scanning is off, so the card says nothing about a
// feature this installation does not use. When it is on, this
// is the only place an administrator finds out that the
// scanner stopped answering — every other screen looks exactly
// as it did, because files keep flowing by design.
'scanning' => $this->scanningWarning(),
];
}
/**
* What is wrong with virus scanning right now, or null.
*
* Deliberately only the bad news. The settings screen reports the
* healthy state; the dashboard exists here to interrupt somebody who
* was not looking for it.
*
* @return array{configured: bool, reachable: bool, engine: string|null, definitions_age_hours: int|null, let_through_24h: int, pending: int}|null
*/
private function scanningWarning(): ?array
{
$config = app(ScanningConfig::class);
if (! $config->enabled()) {
// Nothing is checking what this installation accepts. Said
// only where somebody could act on it: an installation that
// connects its own scanner (community — see
// Capability::VirusScanningConnect). On a hosted one the
// scanner is the platform's to run, and a tenant reading
// "not configured" would be reading about somebody else's
// job.
return $this->capabilities->has(Capability::VirusScanningConnect)
? [
'configured' => false,
'reachable' => false,
'engine' => null,
'definitions_age_hours' => null,
'let_through_24h' => 0,
'pending' => 0,
]
: null;
}
$scanner = app(VirusScanner::class)->status();
$letThrough = ActivityLog::query()
->where('action', Action::FileNotScanned)
->where('created_at', '>=', now()->subDay())
->count();
$pending = File::query()
->where('scan_status', ScanStatus::Pending)
->where('created_at', '<=', now()->subHour())
->count();
$stale = $scanner->definitionsAgeHours();
// Nothing to say when the scanner is there, current, and nothing
// has gone out unchecked.
if ($scanner->reachable && $letThrough === 0 && $pending === 0 && ($stale === null || $stale < 72)) {
return null;
}
return [
'configured' => true,
'reachable' => $scanner->reachable,
'engine' => $scanner->engine,
'definitions_age_hours' => $stale,
'let_through_24h' => $letThrough,
// Files that have been waiting more than an hour: on an
// installation set to hold, this is what an outage looks like.
'pending' => $pending,
];
}
private function clientDashboard(User $client): Response
{
// File::scopeVisibleToClient is the single source of truth for
// client file access, and this page has to agree with the portal it
// introduces. Restating the assignment half here made it disagree
// in both directions: it counted expired files, which the scope
// ends by excluding and /my-files therefore never shows, and it
// missed everything that reaches a client another way — a file in a
// folder shared with them, their own portal upload, and a revision,
// which owns no assignment row and inherits its original's
// recipients.
$visibleFiles = File::query()->visibleToClient($client);
return Inertia::render('portal/dashboard', [
'files_count' => (clone $visibleFiles)->count(),
'groups_count' => $client->memberOfGroups()->where('public', true)->count(),
'storage' => [
'used_bytes' => $this->storageUsage->usedBytes($client),
'quota_bytes' => $this->storageUsage->quotaBytes($client) ?: null,
],
'latest_files' => $visibleFiles->orderByDesc('created_at')->limit(5)->get()
->map(fn (File $file): array => [
'id' => $file->id,
'name' => $file->name,
'size' => $file->size,
'created_at' => $file->created_at?->toIso8601String(),
])->all(),
]);
}
}