user(); assert($user !== null); // One build at a time per requester. A zip holds the queue worker // for as long as it takes to write, and everything else — every // notification email — waits behind it, so a queue of them from // one person is everyone else's outage. An hour old is treated as // abandoned rather than in progress: BuildZipDownloadJob::failed() // resolves a row the worker gave up on, but a worker killed hard // enough never runs it, and nobody should be locked out forever by // a row nothing will ever finish. $inFlight = ZipDownload::query() ->where('requested_by', $user->id) ->where('status', ZipDownload::STATUS_PENDING) ->where('created_at', '>', now()->subHour()) ->exists(); abort_if($inFlight, 429, __('A zip download is already being prepared. Wait for that one to finish before starting another.')); $validated = $request->validate([ 'file_ids' => ['array'], 'file_ids.*' => ['integer'], 'folder_ids' => ['array'], 'folder_ids.*' => ['integer'], ]); $requestedFileIds = array_map('intval', $validated['file_ids'] ?? []); $requestedFolderIds = array_map('intval', $validated['folder_ids'] ?? []); abort_if($requestedFileIds === [] && $requestedFolderIds === [], 422, __('Select at least one file or folder.')); // Silently drop anything the requester isn't allowed to see — // never reveal that a hidden item exists, matching this app's // existing listing/visibility conventions. $files = File::query()->whereIn('id', $requestedFileIds)->get() ->filter(fn (File $file): bool => Gate::forUser($user)->allows('view', $file)); $folders = Folder::query()->whereIn('id', $requestedFolderIds)->get() ->filter(fn (Folder $folder): bool => $user->isClient() ? Folder::query()->whereKey($folder->id)->visibleToClient($user)->exists() : Gate::forUser($user)->allows('view', $folder)); abort_if($files->isEmpty() && $folders->isEmpty(), 422, __('None of the selected items could be found.')); // A file whose download limit is spent is visible but not // takeable, so it drops out here rather than at the Gate above. // Told apart from "not found" deliberately: the difference // between a file that isn't there and one they have already had // as many times as they were meant to is the whole point of not // hiding exhausted files. $selected = $files->count(); $files = $files->filter(fn (File $file): bool => $this->allowance->allows($file, $user)); abort_if( $files->isEmpty() && $folders->isEmpty() && $selected > 0, 422, __('Those files have reached their download limit.'), ); // Holding a folder is not the same as being able to read everything // in it, so the count uses the same per-file filter the job applies // when it actually builds the archive — otherwise the MAX_FILES cap // and the "empty folder" check below would both be measuring a set // larger than what the user will receive. $visible = $this->viewable->for($user); $fileCount = $files->count() + $folders->sum( fn (Folder $folder): int => (clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->count(), ); // Measured the same way, and deliberately without the allowance // filter the loose-file branch applies: a folder's total can only // come out at or above what the archive will really weigh, and an // over-estimate is the safe direction for a cap. $totalSize = (int) $files->sum('size') + (int) $folders->sum( fn (Folder $folder): int => (int) (clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->sum('size'), ); abort_if($fileCount === 0, 422, __('The selected folders are empty.')); abort_if($fileCount > self::MAX_FILES, 422, __('Too many files selected. Choose a smaller selection and try again.')); // Bytes, not file count, are what a build costs — worker time, the // temp copies a remote disk needs, and the archive on disk. The // message names both numbers because "too big" without them leaves // someone guessing how much to deselect. $maxBytes = (int) $this->settings->get(Setting::MaxZipDownloadSizeMb) * 1024 * 1024; abort_if( $maxBytes > 0 && $totalSize > $maxBytes, 422, __('That selection is :size. Zip downloads are limited to :limit — select fewer files and try again.', [ 'size' => Number::fileSize($totalSize, precision: 1), 'limit' => Number::fileSize($maxBytes), ]), ); $zipDownload = ZipDownload::query()->create([ 'requested_by' => $user->id, 'status' => ZipDownload::STATUS_PENDING, 'file_ids' => $files->pluck('id')->values()->all(), 'folder_ids' => $folders->pluck('id')->values()->all(), 'file_count' => $fileCount, ]); BuildZipDownloadJob::dispatch($zipDownload->id); return response()->json(['id' => $zipDownload->id]); } public function show(Request $request, ZipDownload $zipDownload): JsonResponse { $user = $request->user(); abort_unless($user !== null && $zipDownload->requested_by === $user->id, 404); return response()->json([ 'status' => $zipDownload->status, 'file_count' => $zipDownload->file_count, 'error' => $zipDownload->status === ZipDownload::STATUS_FAILED ? $zipDownload->error : null, 'skipped_files' => $zipDownload->skipped_files ?? [], ]); } public function download(Request $request, ZipDownload $zipDownload): Response { $user = $request->user(); abort_unless($user !== null && $zipDownload->requested_by === $user->id, 404); $path = $zipDownload->path; abort_unless($zipDownload->status === ZipDownload::STATUS_READY && $path !== null, 404); // Only the first time. Re-fetching one prepared archive is the // same delivery, not a fresh download of everything inside it. if ($zipDownload->delivered_at === null) { $this->logContainedDownloads($zipDownload, $user); $zipDownload->forceFill(['delivered_at' => now()])->save(); } $size = Storage::disk('files')->size($path); return response('', 200, [ 'X-Accel-Redirect' => '/protected-files/'.$path, 'Content-Type' => 'application/zip', 'Content-Disposition' => ContentDisposition::attachment($this->filenameFor($zipDownload)), 'Content-Length' => (string) $size, ]); } /** * Every file actually bundled gets a FileDownloaded entry — otherwise * a file's download history/count would silently miss zip downloads. */ private function logContainedDownloads(ZipDownload $zipDownload, User $requester): void { // Same per-file filter the job used to decide what actually went // into the archive, so the log records what was really downloaded // rather than everything that happened to sit in the folder. $visible = $this->viewable->for($requester); $fileIds = collect($zipDownload->file_ids); foreach ($zipDownload->folder_ids as $folderId) { $folder = Folder::query()->find($folderId); if ($folder !== null) { $fileIds = $fileIds->merge((clone $visible)->whereIn('folder_id', $folder->subtreeFolderIds())->pluck('id')); } } // Whatever the job left out is not in the archive and must not // be logged as delivered — otherwise a file refused for reaching // its limit would be recorded as downloaded again, pushing it // further past it. $skipped = collect($zipDownload->skipped_files ?? [])->pluck('id')->all(); foreach ((clone $visible)->whereIn('id', $fileIds->unique())->whereNotIn('id', $skipped)->get() as $file) { $this->activity->log(Action::FileDownloaded, subject: $file); } } private function filenameFor(ZipDownload $zipDownload): string { if (count($zipDownload->folder_ids) === 1 && $zipDownload->file_ids === []) { $folder = Folder::query()->find($zipDownload->folder_ids[0]); if ($folder !== null) { // Folder names reach this header too, and a client can name // their own folders — same reasoning as // StoreUploadedFile::sanitizeFilename(). Quoted-string // escaping happens in ContentDisposition, not here. return StoreUploadedFile::sanitizeFilename($folder->name).'.zip'; } } return 'download.zip'; } }