mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 17:15:08 +00:00
bab90c0ad8
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.
127 lines
5.1 KiB
PHP
127 lines
5.1 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\Audit\ActivityLogger;
|
|
use App\Modules\Files\Access\DownloadAllowance;
|
|
use App\Modules\Files\Delivery\StoredFileResponse;
|
|
use App\Modules\Files\Models\Category;
|
|
use App\Modules\Files\Models\File;
|
|
use App\Modules\Files\Scanning\FileAvailability;
|
|
use App\Modules\Files\Scanning\ScanStatus;
|
|
use App\Modules\Files\Models\ShareLink;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Inertia\Inertia;
|
|
use Inertia\Response as InertiaResponse;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
/**
|
|
* The public, unauthenticated side of a share link: no Gate/policy is
|
|
* involved (FilePolicy::view() requires a real User, so it auto-denies
|
|
* guests) — the token itself, checked for expiry and download limit, is
|
|
* the entire authorization.
|
|
*/
|
|
class PublicShareController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly ActivityLogger $activity,
|
|
private readonly DownloadAllowance $allowance,
|
|
private readonly StoredFileResponse $bytes,
|
|
private readonly FileAvailability $availability,
|
|
) {}
|
|
|
|
public function show(string $token): InertiaResponse
|
|
{
|
|
$shareLink = ShareLink::query()->where('token', $token)->first();
|
|
$file = $shareLink?->shareable;
|
|
|
|
if ($shareLink === null || ! $file instanceof File) {
|
|
return Inertia::render('share/show', ['status' => 'not_found']);
|
|
}
|
|
|
|
// The file's own expiry counts too, not just the link's: expires_at
|
|
// is how access is revoked everywhere else (clients, public listing),
|
|
// so a link outliving it would be a way around that revocation.
|
|
if ($shareLink->isExpired() || $file->isExpired()) {
|
|
return Inertia::render('share/show', ['status' => 'expired']);
|
|
}
|
|
|
|
// A link can be minted the moment a file is stored — the hosted
|
|
// free plan does exactly that — so the link routinely exists
|
|
// before the scanner has finished. It says so rather than 404ing:
|
|
// the visitor was sent a real link and it will work shortly.
|
|
if (! $this->availability->isAvailable($file)) {
|
|
return Inertia::render('share/show', [
|
|
'status' => $file->scan_status === ScanStatus::Pending ? 'checking' : 'unavailable',
|
|
]);
|
|
}
|
|
|
|
// Two separate caps reach the same page: the link's own
|
|
// max_downloads, and the file's. A visitor here has no account,
|
|
// so the file's limit is measured against the whole file — see
|
|
// DownloadAllowance.
|
|
if ($shareLink->hasReachedLimit() || ! $this->allowance->allows($file, null)) {
|
|
return Inertia::render('share/show', ['status' => 'limit_reached']);
|
|
}
|
|
|
|
$file->loadMissing('categories');
|
|
|
|
return Inertia::render('share/show', [
|
|
'status' => 'active',
|
|
'file' => [
|
|
'original_name' => $file->original_name,
|
|
'size' => $file->size,
|
|
// A share link is access to the file, so it shows the same
|
|
// labels every other surface does — see the notice on
|
|
// /categories, which promises exactly that.
|
|
'categories' => $file->categories
|
|
->map(fn (Category $category): array => [
|
|
'id' => $category->id, 'name' => $category->name, 'color' => $category->color,
|
|
])->values()->all(),
|
|
],
|
|
'download_url' => route('share.download', $token),
|
|
]);
|
|
}
|
|
|
|
public function download(string $token): Response|RedirectResponse
|
|
{
|
|
$shareLink = ShareLink::query()->where('token', $token)->first();
|
|
$file = $shareLink?->shareable;
|
|
|
|
if ($shareLink === null || ! $file instanceof File || $shareLink->isExpired() || $file->isExpired()) {
|
|
return redirect()->route('share.show', $token);
|
|
}
|
|
|
|
// Same for a file still being checked, and for the same reason
|
|
// the limit is asked before the counter moves.
|
|
if (! $this->availability->isAvailable($file)) {
|
|
return redirect()->route('share.show', $token);
|
|
}
|
|
|
|
// Before the link's counter moves, not after: a download refused
|
|
// by the file's own limit must not spend one of the link's.
|
|
if (! $this->allowance->allows($file, null)) {
|
|
return redirect()->route('share.show', $token);
|
|
}
|
|
|
|
// Atomic: only increments if still under the limit, closing the
|
|
// race between two simultaneous requests both passing the check.
|
|
$incremented = ShareLink::query()
|
|
->whereKey($shareLink->id)
|
|
->where(fn ($query) => $query->whereNull('max_downloads')->orWhereColumn('downloads_count', '<', 'max_downloads'))
|
|
->increment('downloads_count');
|
|
|
|
if ($incremented === 0) {
|
|
return redirect()->route('share.show', $token);
|
|
}
|
|
|
|
$this->activity->log(Action::ShareLinkDownloaded, subject: $file);
|
|
|
|
return $this->bytes->attachment($file);
|
|
}
|
|
}
|