mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-20 18:43:20 +00:00
7ce1e3487f
An installation that scans can still let files through: too large for the scanner, an archive it could not open, or an upload that arrived while the scanner was down. Both policies default to letting those through, and the count of them is on the settings screen and the dashboard. Everyone could see that except the one person it matters to. The uploader sees the state on their own file and staff see it in the library; whoever follows a public link saw the page a file that passed gets, having neither chosen the policy nor any way to see the setting. On the hosted free plan, where every upload is published behind a link, that is the whole audience. The share page and the public file page in all four themes now carry one line: "This file was not checked for viruses." Said plainly and without alarm — nothing is known to be wrong with the file; what is known is that nothing looked. Only where this installation scans, and only for the three reasons that mean a scanner let something past. A file from before scanning was switched on says nothing: on an installation that has only just switched it on that is every file, and saying it about all of them says it about none of them.
134 lines
5.5 KiB
PHP
134 lines
5.5 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\ScanningConfig;
|
|
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,
|
|
private readonly ScanningConfig $scanning,
|
|
) {}
|
|
|
|
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),
|
|
// Said to the one person who can neither see the setting nor
|
|
// chose it. The uploader and the staff library both show this
|
|
// file as "not scanned"; whoever follows the link had no way
|
|
// of knowing.
|
|
'unscanned' => $this->scanning->enabled() && $file->wasLetThrough(),
|
|
]);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|