mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-04 21:43:57 +00:00
bccf3d1f29
Reported from the shared free instance. A client deleted their own account on 2026-09-18. Their five files kept serving through share links with no expiry for the whole 30-day grace period. Somebody who asked to leave stayed published. Rule 1: once an account is soft-deleted, its uploads are served to nobody but staff. That covers the client scope (assignment, group, shared folder), share links, the public listing, public comments and zips. Staff keep them, because the grace period exists to undo a mistake. Nothing is deleted and share links are kept, so a restored account is served again. A withdrawn share link answers like a token that never existed. In practice this only meets self-deleted accounts: an administrator deleting an account that owns anything must already choose to delete or reassign it. Rule 2, new in Privacy settings: when someone deletes their own account, their files are deleted "when the grace period ends" (the default, and today's behaviour) or "right away". "Right away" uses DeletedAccountContent's cascade: their own uploads, and their folders only if nothing else is left inside. It runs in the same transaction as the account delete. A platform can force "right away" through the new ResolvingSelfDeletion hook. The screen then shows the choice as set by the hosting plan instead of offering a switch. A second setting decides whose deletion both rules apply to: any account (the default) or clients only. A staff member's uploads are often the organization's work for its clients. The delete-account screen now says what happens to the files before the person confirms. New strings are in all sixteen locales.
139 lines
5.8 KiB
PHP
139 lines
5.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\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;
|
|
|
|
// A withdrawn file answers exactly as a link that never existed.
|
|
// Its uploader deleted their account, and "this was here once" is
|
|
// itself something they asked to stop saying. The link row stays,
|
|
// so an account that is restored is served again. See
|
|
// SelfDeletion.
|
|
if ($shareLink === null || ! $file instanceof File || $file->isWithdrawn()) {
|
|
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 || $file->isWithdrawn() || $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);
|
|
}
|
|
}
|