Count a share's download limit per recipient, not per file

With a download limit of 1, downloading one file of a share with
several files deleted the share and the files not yet downloaded.
ShareService::recordDownload() ran at the end of every download
request, one file or the ZIP alike, and deleted the share as soon as
download_count reached max_downloads. It has worked that way since
the first commit.

- One recipient's visit is one download. The first file or ZIP a
  session downloads is counted when it starts, in one conditional
  UPDATE that also checks the limit, so two recipients starting at
  once can't both take the last download. The session remembers the
  time, and for ShareService::DOWNLOAD_WINDOW_MINUTES (60) it may
  start more downloads of the share without counting them, even once
  the limit is reached. The claim happens in the controller before
  streaming, because the session is saved before the body is sent,
  and after the share key is resolved, so a request without the key
  uses nothing.
- A share at its limit is closed to everyone else at once. The hourly
  cleanup deletes it 24 hours after shares.last_downloaded_at (new
  column), since a ZIP opens each file only when it reaches it and a
  large download can outlast the hour.
- The download page of a limited share says how many downloads are
  left, switches to "You have 1 hour" on the first press (Alpine, as
  a download link does not render the page again), and shows the time
  left on the next visit.
- The admin dashboard shows "2 of 3 downloads", marks shares at their
  limit "Download limit reached" and leaves them out of Active Shares.
- Tests: the regression (3 files, limit 1: every file and the ZIP
  download, counted once), another recipient, the end of the hour,
  the last download going to one of two recipients, requests refused
  before streaming, unlimited shares, the page notes in PHP and in
  Chromium, the dashboard, and the cleanup at 23 and 25 hours. The
  tests of recordDownload() and of the instant deletion are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-17 15:35:13 +02:00
co-authored by Claude Opus 5
parent c1906d2009
commit e057cada3d
15 changed files with 344 additions and 59 deletions
+14 -1
View File
@@ -14,6 +14,12 @@ class CleanupExpiredShares extends Command
*/
private const ABANDONED_AFTER_HOURS = 4;
/**
* How long a share at its download limit is kept after its last download, so that downloads its
* last recipients started can finish: a ZIP opens each file only when it reaches it.
*/
private const DELETE_AFTER_LIMIT_HOURS = 24;
protected $signature = 'shares:cleanup';
protected $description = 'Delete expired shares, shares that have reached their download limit, abandoned uploads and old temporary uploads';
@@ -23,7 +29,14 @@ class CleanupExpiredShares extends Command
$expiredShares = Share::query()
->where(function ($query): void {
$query->where('expires_at', '<', now())
->orWhereRaw('max_downloads IS NOT NULL AND download_count >= max_downloads');
->orWhere(function ($query): void {
$query->whereNotNull('max_downloads')
->whereColumn('download_count', '>=', 'max_downloads')
->where(function ($query): void {
$query->whereNull('last_downloaded_at')
->orWhere('last_downloaded_at', '<', now()->subHours(self::DELETE_AFTER_LIMIT_HOURS));
});
});
})
->get();
+11 -9
View File
@@ -7,6 +7,7 @@ use App\Models\ShareFile;
use App\Services\FileEncryptionService;
use App\Services\ShareService;
use GuzzleHttp\Psr7\PumpStream;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -24,13 +25,16 @@ class DownloadController extends Controller
* Download all files as a ZIP archive, streamed file by file as it is decrypted: stored without
* compression, with ZIP64 for files over 4 GB, and never held in memory or written to disk.
*/
public function download(Share $share): StreamedResponse
public function download(Request $request, Share $share): StreamedResponse
{
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
abort_if(! $share->isCompleted() || $share->isExpired(), 404);
$share->load('files');
$key = $this->resolveDecryptionKey($share);
// Counted before the body streams: the session is saved by then.
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
return new StreamedResponse(function () use ($share, $key): void {
$zip = new ZipStream(
defaultCompressionMethod: CompressionMethod::STORE,
@@ -62,8 +66,6 @@ class DownloadController extends Controller
}
$zip->finish();
$this->shareService->recordDownload($share);
}, 200, [
'Content-Type' => 'application/zip',
'Content-Disposition' => HeaderUtils::makeDisposition('attachment', 'share-'.$share->token.'.zip'),
@@ -73,13 +75,15 @@ class DownloadController extends Controller
/**
* Download a single file.
*/
public function downloadFile(Share $share, ShareFile $shareFile): StreamedResponse
public function downloadFile(Request $request, Share $share, ShareFile $shareFile): StreamedResponse
{
abort_if(! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit(), 404);
abort_if(! $share->isCompleted() || $share->isExpired(), 404);
abort_if($shareFile->share_id !== $share->id, 404);
$key = $this->resolveDecryptionKey($share);
abort_unless($this->shareService->claimDownload($share, $request->session()), 404);
$encryptedPath = Storage::disk('shares')->path($share->token.'/'.basename($shareFile->stored_path));
$mimeType = $shareFile->mime_type ?? 'application/octet-stream';
@@ -96,10 +100,8 @@ class DownloadController extends Controller
$headers['Content-Length'] = $shareFile->file_size;
}
return new StreamedResponse(function () use ($encryptedPath, $key, $share): void {
return new StreamedResponse(function () use ($encryptedPath, $key): void {
$this->encryptionService->streamDecryptedFile($encryptedPath, $key);
$this->shareService->recordDownload($share);
}, 200, $headers);
}
+2
View File
@@ -71,6 +71,8 @@ class AdminDashboard extends Component
'totalShares' => Share::query()->whereNotNull('completed_at')->count(),
'activeShares' => Share::query()->whereNotNull('completed_at')->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})->where(function ($q) {
$q->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads');
})->count(),
'totalFiles' => ShareFile::query()->whereHas('share', fn ($query) => $query->whereNotNull('completed_at'))->count(),
'usedSpace' => $shareService->getTotalUsedSpace(),
+12 -3
View File
@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Models\Share;
use App\Services\ShareService;
use Carbon\CarbonInterval;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
@@ -19,11 +20,13 @@ class ShareDownload extends Component
#[Validate('required|string')]
public string $password = '';
public function mount(Share $share): void
public function mount(Share $share, ShareService $shareService): void
{
$this->share = $share->load('files');
if (! $share->isCompleted() || $share->isExpired() || $share->hasReachedDownloadLimit()) {
// A share at its download limit stays open for the recipient who took its last download.
if (! $share->isCompleted() || $share->isExpired()
|| ($share->hasReachedDownloadLimit() && $shareService->downloadWindowEndsAt($share, session()->driver()) === null)) {
abort(404);
}
@@ -65,6 +68,12 @@ class ShareDownload extends Component
public function render(): mixed
{
return view('livewire.share-download');
$shareService = app(ShareService::class);
return view('livewire.share-download', [
'downloadWindowEndsAt' => $shareService->downloadWindowEndsAt($this->share, session()->driver()),
'remainingDownloads' => $this->share->max_downloads ? max($this->share->max_downloads - $this->share->download_count, 0) : null,
'downloadWindow' => CarbonInterval::minutes(ShareService::DOWNLOAD_WINDOW_MINUTES)->cascade()->forHumans(),
]);
}
}
+1
View File
@@ -32,6 +32,7 @@ class Share extends Model
'expires_at' => 'datetime',
'max_downloads' => 'integer',
'download_count' => 'integer',
'last_downloaded_at' => 'datetime',
'total_size' => 'integer',
'encryption_key' => 'encrypted',
'completed_at' => 'datetime',
+53 -5
View File
@@ -5,8 +5,11 @@ namespace App\Services;
use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile;
use Carbon\CarbonInterface;
use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
@@ -21,6 +24,11 @@ use RuntimeException;
*/
class ShareService
{
/**
* How long a recipient may keep starting downloads of a share after their download was counted.
*/
public const DOWNLOAD_WINDOW_MINUTES = 60;
public function __construct(
private FileEncryptionService $encryptionService,
) {}
@@ -318,15 +326,55 @@ class ShareService
}
/**
* Record a download and auto-delete if limit reached.
* When this session's download window for a share ends, or null while it has none open. The
* window opens with the session's counted download; until it ends, the session may start more
* downloads of the share without counting them, even once the share has reached its limit.
*/
public function recordDownload(Share $share): void
public function downloadWindowEndsAt(Share $share, Session $session): ?CarbonInterface
{
$share->increment('download_count');
$countedAt = $session->get($this->downloadSessionKey($share));
if ($share->hasReachedDownloadLimit()) {
$this->deleteShare($share);
if (! is_int($countedAt)) {
return null;
}
$endsAt = Carbon::createFromTimestamp($countedAt)->addMinutes(self::DOWNLOAD_WINDOW_MINUTES);
return $endsAt->isFuture() ? $endsAt : null;
}
/**
* Let this session download from a share: one recipient's visit is one download, so a session
* without an open window counts one and opens its window. The limit is checked in the same
* update that counts, so two recipients who start at once cannot both take the last download.
* False when no download is left for this session.
*/
public function claimDownload(Share $share, Session $session): bool
{
if ($this->downloadWindowEndsAt($share, $session) !== null) {
return true;
}
$counted = Share::query()
->whereKey($share->id)
->where(fn ($query) => $query->whereNull('max_downloads')->orWhereColumn('download_count', '<', 'max_downloads'))
->increment('download_count', 1, ['last_downloaded_at' => now()]);
if ($counted === 0) {
return false;
}
$session->put($this->downloadSessionKey($share), now()->getTimestamp());
return true;
}
/**
* The session key holding when this session's download of a share was counted.
*/
private function downloadSessionKey(Share $share): string
{
return 'share_download_'.$share->token;
}
/**