Files
ignacionelson bab90c0ad8 Scan uploaded files for viruses, and withhold them until they are checked
Every upload now starts as "being checked" and is not served to anyone
until a scanner has looked at it. Infected files are quarantined: kept
on disk, unreachable, waiting for an administrator.

The scanner is ClamAV, reached over a socket, streaming the file
wherever it is stored — no temporary copy for an S3 or GCS disk. What
the scanner answers is a fact; what it means for the file is this
installation's setting, so ClamAvScanner knows nothing about settings
and ScanPolicy knows nothing about sockets. Three of clamd's own alert
options are what make a file it could not open come back as an answer
rather than as "OK"; the client maps those to "too large" and
"encrypted" instead of to a threat.

Both policies default to letting files through, marked "not scanned",
which is the product owner's decision: a scanner that cannot answer must
not stop people working. Every such file is logged, and the screens that
say so come with the rest of this work.

Withholding is two rules. A file that is not available drops out of the
scopes that answer "what may this person see" — recipients and the
public listings, never the uploader's own copy. And every route that
puts bytes on the wire asks FileAvailability first: download, thumbnail,
preview, share link, the four public routes and both ends of a zip
build. A share link minted before the scan finishes says the file is
still being checked rather than 404ing.

Not yet here, and coming next: the quarantine screen and its permission,
the notifications, the settings screen, the hourly retry, the backfill
for existing libraries, and the Docker service.
2026-09-16 14:23:56 -03:00

234 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Files\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Audit\Action;
use App\Modules\Files\Access\DownloadAllowance;
use App\Modules\Files\Delivery\FileDelivery;
use App\Modules\Files\Delivery\StoredFileResponse;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\FileAvailability;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Preview\PreviewLog;
use App\Modules\Files\Thumbnails\Events\ResolvingImageRendering;
use App\Modules\Files\Thumbnails\ImageAudience;
use App\Modules\Files\Thumbnails\ImageRendition;
use App\Modules\Files\Thumbnails\LocalSourceFile;
use App\Modules\Files\Thumbnails\ThumbnailGenerator;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use App\Support\ContentDisposition;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
/**
* Two inline (never `attachment`) views of a file, delivered the same way
* FileDownloadController delivers one: a bounded thumbnail for listing rows,
* and a larger view opened in a new tab when a thumbnail is clicked.
* `thumbnail()` stays unlogged — it fires automatically as an `<img src>`
* for every row on every listing render, not a deliberate action, and
* logging it would flood the activity log with non-events. `preview()`
* logs Action::FilePreviewed, since a user explicitly chose to view the
* file's contents — a real, audit-worthy action, just not a "download."
*
* SECURITY: both methods serve bytes inline, from this app's own origin,
* with the File's stored mime type, so both are restricted to an
* allowlist — but not the same one, because they are asking different
* questions. `thumbnail()` is bounded by
* ThumbnailGenerator::SUPPORTED_MIME_TYPES, the raster formats this app
* decodes and re-encodes itself, since a thumbnail *is* a rendition.
* `preview()` is bounded by PreviewKind, which additionally admits the
* video, audio and PDF types a browser plays natively and this app never
* touches. PreviewKind's docblock carries the rule in full; the short
* version is that neither list may ever grow a type a browser executes
* script from, and neither may be derived from the upload
* allowed-extensions setting, which matches on the *extension* while
* mime_type is detected from the *bytes*
* (ChunkedUploadsController::complete).
*
* Serving media inline is also why `preview()` logs at most one
* Action::FilePreviewed per viewer per file per five minutes: a `<video>`
* seeking through a recording issues a long tail of Range requests
* against this same URL, and one row each would bury the log under a
* single deliberate act.
*
* Renditions always cache on the local "files" disk regardless of where
* the source file lives — they're a derived artifact, not the original,
* so there's no reason to push them to external storage too. Generating
* one from a source on a non-local disk needs a temp local copy first,
* since ThumbnailGenerator needs a real path to read from.
*
* Both methods cache one file per ImageAudience, because both routes are
* reached by the staff file manager and the client portal alike and a
* RenderingImage listener may render the two differently.
*/
class FileThumbnailController extends Controller
{
public function __construct(
private readonly ThumbnailGenerator $thumbnails,
private readonly PreviewLog $previews,
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
private readonly LocalSourceFile $source,
private readonly Settings $settings,
private readonly FileDelivery $delivery,
private readonly FileAvailability $availability,
) {}
public function thumbnail(Request $request, File $file): Response
{
Gate::authorize('view', $file);
// A rendition is made by an image library reading the file, which
// is itself a way in — so an unchecked file is not rendered, not
// even as 300 pixels.
$this->availability->guardDelivery($file);
// This one route serves both the staff file manager and the client
// portal — the same URL, told apart only by who is asking. A client
// and a staff member looking at the same file get different cached
// bytes; see ImageAudience.
$audience = ImageAudience::forViewer($request->user());
$path = $this->render($file, $audience, ImageRendition::Thumbnail);
abort_if($path === null, 404);
return $this->serve($file, $path);
}
/**
* A file opened to be looked at.
*
* For an image, a preview is not the file — it is a rendered view of
* it, which is why it may be decorated at all. But rendering one is
* expensive (decoding and re-encoding a full-size photograph) where
* serving the stored bytes is nearly free, so core only pays that
* cost when a listener says this particular viewer must be served a
* rendering: ResolvingImageRendering asks, and defaults to no. On an
* installation that watermarks, a client gets a bounded, watermarked
* render and staff get the original; on one that does not, everyone
* gets exactly what this endpoint has always returned.
*
* For video, audio and PDF there is no rendering to resolve — this
* app cannot decode any of them, so it has no rendition to cache, no
* watermark to stamp, and nothing to ask about. Those go straight to
* the bytes.
*/
public function preview(Request $request, File $file): Response|RedirectResponse
{
Gate::authorize('view', $file);
$this->availability->guardDelivery($file);
// The inline allowlist. See the class docblock and PreviewKind —
// the stored mime type is sniffed from the bytes, so an allowed
// extension is not evidence of a safe-to-render payload.
$kind = PreviewKind::forMime($file->mime_type);
abort_if($kind === null, 404);
// Staff are never gated: this switch exists so an installation can
// decide what its *clients* may do with a file short of taking it.
// 404 rather than 403 because with the setting off the endpoint is
// not a thing that exists for this viewer.
abort_if(
$request->user()?->isStaff() !== true && ! $this->settings->get(Setting::ClientsCanPreviewFiles),
404,
);
// A preview is not counted as a download, but it is refused once
// the download limit is spent — because unless a listener asks
// for a rendering (nothing does by default, and nothing ever does
// for media), the branches below serve the *original bytes* at
// full size. Without this a cap would be one URL away from
// meaningless for every previewable file on the install.
// thumbnail() needs no such guard: a 300px rendition is not the
// file.
abort_unless($this->allowance->allows($file, $request->user()), 403);
// Debounced, because a browser turns one video into dozens of
// Range requests — see PreviewLog, which the anonymous twin in
// PublicGroupsController::preview shares.
$this->previews->record(Action::FilePreviewed, $file, $request->user());
if ($kind === PreviewKind::Image) {
$audience = ImageAudience::forViewer($request->user());
$decision = new ResolvingImageRendering($audience, ImageRendition::Preview, $file->mime_type);
Event::dispatch($decision);
if ($decision->required) {
$path = $this->render($file, $audience, ImageRendition::Preview);
abort_if($path === null, 404);
return $this->serve($file, $path);
}
}
return $this->bytes->inline($file);
}
/**
* The cached rendition's path on the local disk, generating it first
* if this is the first time anyone has asked for it. Null only when
* the mime type has no rendition at all.
*/
private function render(File $file, ImageAudience $audience, ImageRendition $rendition): ?string
{
$path = ThumbnailGenerator::pathFor($file->id, $file->mime_type, $audience, $rendition);
if ($path === null) {
return null;
}
$disk = Storage::disk('files');
// Existence is the cache, and an empty file is not a rendition: it
// is what a render that died before writing anything leaves behind,
// and serving it hands the viewer a broken image for as long as the
// file lives — nothing invalidates a rendition once it is there.
// ThumbnailGenerator writes through a temporary file now, so this
// state can no longer be created here; it can still be inherited
// from an installation that ran an older version.
if ($disk->exists($path)) {
if ($disk->size($path) > 0) {
return $path;
}
$disk->delete($path);
}
$disk->makeDirectory(dirname($path));
$this->source->use($file, fn (string $sourcePath) => $this->thumbnails->generate(
$sourcePath,
$disk->path($path),
$file->mime_type,
$audience,
$rendition,
));
return $path;
}
private function serve(File $file, string $path): Response
{
// No Content-Length: this is the rendition's size, not the
// original file's, and $file->size is the wrong number for it.
return $this->delivery->serve(
$path,
$file->mime_type,
ContentDisposition::inline($file->original_name),
);
}
}