From 5493955bea12e2ee4f440937cff17c5efb286ce3 Mon Sep 17 00:00:00 2001 From: ignacionelson Date: Wed, 16 Sep 2026 14:45:13 -0300 Subject: [PATCH] Show staff where a file stands, and say the same through the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff keep seeing every file they always saw — withholding is about recipients, not about the library — so the library now carries the state on the row: Checking, Quarantined, Released, or Not scanned with the reason behind it. Nothing at all for a clean file, which is the common case. The API says the same in a `scan` object on every file, with an `available` flag so a caller need not learn which of six states mean "you can have it", and `scan_status` is a filter, so an integration can wait for the file it just uploaded or collect what is in quarantine. The download endpoint answers 423 for a file that is not available, which it already did through the shared controller. Re-exported the OpenAPI document. --- .../Http/Controllers/Api/FilesController.php | 9 +++ .../Http/Controllers/FoldersController.php | 34 +++++++++++ .../Files/Http/Resources/Api/FileResource.php | 12 ++++ docs/api/openapi.json | 58 +++++++++++++++++++ resources/js/components/files/scan-badge.tsx | 54 +++++++++++++++++ resources/js/pages/files/index.tsx | 5 ++ tests/Feature/Api/FilesReadTest.php | 22 +++++++ 7 files changed, 194 insertions(+) create mode 100644 resources/js/components/files/scan-badge.tsx diff --git a/app/Modules/Files/Http/Controllers/Api/FilesController.php b/app/Modules/Files/Http/Controllers/Api/FilesController.php index aee8bf64..46fab517 100644 --- a/app/Modules/Files/Http/Controllers/Api/FilesController.php +++ b/app/Modules/Files/Http/Controllers/Api/FilesController.php @@ -18,6 +18,7 @@ use App\Modules\Files\Editing\ApplyFileEdits; use App\Modules\Files\Editing\FileExpiry; use App\Modules\Files\Http\Resources\Api\FileResource; use App\Modules\Files\Models\File; +use App\Modules\Files\Scanning\ScanStatus; use App\Modules\Files\Models\Folder; use App\Modules\Files\Storage\ResolvingUploadDisk; use App\Modules\Files\Uploads\StoreUploadedFile; @@ -103,6 +104,10 @@ class FilesController extends Controller 'search' => ['nullable', 'string', 'max:255'], 'public' => ['nullable', 'boolean'], 'expired' => ['nullable', 'boolean'], + // One of pending, clean, infected, released, not_scanned or + // unscannable_blocked — so an integration can wait for a file + // it just uploaded, or collect what is in quarantine. + 'scan_status' => ['nullable', 'string', Rule::enum(ScanStatus::class)], ]); $query = $this->viewable->for($user) @@ -154,6 +159,10 @@ class FilesController extends Controller $request->boolean('expired') ? $query->expired() : $query->notExpired(); } + if (($filters['scan_status'] ?? null) !== null) { + $query->where('files.scan_status', $filters['scan_status']); + } + return FileResource::collection($this->polling->paginate($request, $query, 'files')); } diff --git a/app/Modules/Files/Http/Controllers/FoldersController.php b/app/Modules/Files/Http/Controllers/FoldersController.php index a1519174..881266be 100644 --- a/app/Modules/Files/Http/Controllers/FoldersController.php +++ b/app/Modules/Files/Http/Controllers/FoldersController.php @@ -18,6 +18,9 @@ use App\Modules\Files\Folders\BreadcrumbBuilder; use App\Modules\Files\Folders\FolderService; use App\Modules\Files\Models\Category; use App\Modules\Files\Models\File; +use App\Modules\Files\Scanning\NotScannedReason; +use App\Modules\Files\Scanning\ScanningConfig; +use App\Modules\Files\Scanning\ScanStatus; use App\Modules\Files\Models\Folder; use App\Modules\Files\Versions\FileVersionLinks; use App\Modules\Groups\Models\Group; @@ -201,6 +204,34 @@ class FoldersController extends Controller ]); } + /** + * What the scanner made of a file, for a staff member's list. + * + * Staff see every file they always saw, with its state on it — + * withholding applies to recipients, not to the library. Null while + * scanning is off so nothing is decorated on an installation that does + * not use it. + * + * @return array{status: string, note: string|null}|null + */ + private function scanState(File $file): ?array + { + if (! app(ScanningConfig::class)->enabled() && $file->scan_status === ScanStatus::NotScanned) { + return null; + } + + $note = $file->scan_note; + + return [ + 'status' => $file->scan_status->value, + // A reason is a key and is translated here; a threat name is + // the scanner's own words and is passed through. + 'note' => $note === null ? null : (NotScannedReason::tryFrom($note)?->label() !== null + ? (string) __(NotScannedReason::from($note)->label()) + : $note), + ]; + } + /** * @return array */ @@ -253,6 +284,9 @@ class FoldersController extends Controller ] : null, 'public' => $file->isEffectivelyPublic(), 'expired' => $file->isExpired(), + // Null while scanning is off, so a library that does not use + // it carries no badge. + 'scan' => $this->scanState($file), // No link at all once expired — the public route 404s past // expiry too (see File::scopeNotExpired's callers), so there's // no point offering a button that leads to a dead page. diff --git a/app/Modules/Files/Http/Resources/Api/FileResource.php b/app/Modules/Files/Http/Resources/Api/FileResource.php index 2c3fdd8f..2a8fdcc3 100644 --- a/app/Modules/Files/Http/Resources/Api/FileResource.php +++ b/app/Modules/Files/Http/Resources/Api/FileResource.php @@ -78,6 +78,18 @@ class FileResource extends JsonResource 'expires_at' => $this->expires_at?->toIso8601String(), 'expired' => $this->isExpired(), + // What the virus scanner made of this file. `pending` and + // `infected` mean the bytes are not available: the download + // endpoint answers 423 for both, and a caller that has just + // uploaded should poll this rather than the download. `note` + // carries the threat name, or why a file was not scanned. + 'scan' => [ + 'status' => $this->scan_status->value, + 'available' => $this->scan_status->isAvailable(), + 'note' => $this->scan_note, + 'scanned_at' => $this->scanned_at?->toIso8601String(), + ], + // Null when the file may be downloaded any number of times. // `download_limit_scope` says what the number counts — // "total" across everyone, or "per_user" for each person diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 9898207f..db1c1064 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -1789,6 +1789,20 @@ "null" ] } + }, + { + "name": "scan_status", + "in": "query", + "schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ScanStatus" + }, + { + "type": "null" + } + ] + } } ], "responses": { @@ -3983,6 +3997,36 @@ "expired": { "type": "boolean" }, + "scan": { + "type": "object", + "description": "What the virus scanner made of this file. `pending` and\n`infected` mean the bytes are not available: the download\nendpoint answers 423 for both, and a caller that has just\nuploaded should poll this rather than the download. `note`\ncarries the threat name, or why a file was not scanned.", + "properties": { + "status": { + "type": "string" + }, + "available": { + "type": "boolean" + }, + "note": { + "type": [ + "string", + "null" + ] + }, + "scanned_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status", + "available", + "note", + "scanned_at" + ] + }, "download_limit": { "type": [ "integer", @@ -4166,6 +4210,7 @@ "commentable", "expires_at", "expired", + "scan", "download_limit", "download_limit_scope", "downloads_used", @@ -4247,6 +4292,19 @@ ], "title": "GroupResource" }, + "ScanStatus": { + "type": "string", + "description": "Where a file stands with the virus scanner. Availability is not a case here on purpose: three of these mean the file may be served and three mean it may not, and asking FileAvailability rather than comparing cases is what keeps that rule in one place. See docs/feature-virus-scanning.md.\n| |\n|---|\n| `pending`
Waiting to be scanned, or being scanned right now. |\n| `clean`
Scanned, nothing found. |\n| `infected`
A threat was found. Quarantined; `scan_note` is the threat name. |\n| `released`
Was infected, and an administrator decided to allow it anyway. |\n| `not_scanned`
Not checked, and allowed through. `scan_note` is a NotScannedReason. |\n| `unscannable_blocked`
Could not be checked, and this installation blocks those. Quarantined. |", + "enum": [ + "pending", + "clean", + "infected", + "released", + "not_scanned", + "unscannable_blocked" + ], + "title": "ScanStatus" + }, "StaffUserResource": { "type": "object", "properties": { diff --git a/resources/js/components/files/scan-badge.tsx b/resources/js/components/files/scan-badge.tsx new file mode 100644 index 00000000..c7b60f26 --- /dev/null +++ b/resources/js/components/files/scan-badge.tsx @@ -0,0 +1,54 @@ +import { Badge } from '@/components/ui/badge'; +import { useTranslation } from '@/hooks/use-translation'; + +export interface ScanState { + status: 'pending' | 'clean' | 'infected' | 'released' | 'not_scanned' | 'unscannable_blocked'; + /** The threat name, or why it was not scanned. Already translated. */ + note: string | null; +} + +/** + * What the virus scanner made of this file, for a staff member's list. + * + * Nothing at all for a clean file, which is the common case: a badge on + * every row would say nothing and cost the eye something. The four that + * do show are the ones somebody may have to act on. + * + * Recipients never see this — a file they should not have simply is not + * there. This is a staff-side affordance only. + */ +export function ScanBadge({ scan }: { scan: ScanState | null | undefined }) { + const { t } = useTranslation(); + + if (!scan || scan.status === 'clean') return null; + + if (scan.status === 'pending') { + return ( + + {t('Checking')} + + ); + } + + if (scan.status === 'infected' || scan.status === 'unscannable_blocked') { + return ( + + {t('Quarantined')} + + ); + } + + if (scan.status === 'released') { + return ( + + {t('Released')} + + ); + } + + return ( + + {t('Not scanned')} + + ); +} diff --git a/resources/js/pages/files/index.tsx b/resources/js/pages/files/index.tsx index b948a597..f98fb061 100644 --- a/resources/js/pages/files/index.tsx +++ b/resources/js/pages/files/index.tsx @@ -13,6 +13,7 @@ import { DetailsPanel, DetailsTarget } from '@/components/details-panel'; import { DragChip, DragData, DropZone, useFolderDrop, useRowDrag } from '@/components/file-dnd'; import { FilePreviewDialog } from '@/components/file-preview-dialog'; import { PreviewAction } from '@/components/preview-action'; +import { ScanBadge, type ScanState } from '@/components/files/scan-badge'; import { VersionBadge, type VersionLinks } from '@/components/files/version-badge'; import Heading from '@/components/heading'; import { FilterField, ListToolbar } from '@/components/list-toolbar'; @@ -60,6 +61,8 @@ interface FileRow { public: boolean; public_url: string | null; expired: boolean; + /** What the virus scanner made of it. Null when scanning is off. */ + scan: ScanState | null; assignments_count: number; downloads_count: number; /** @@ -718,6 +721,7 @@ function FileRow({ {t('Expired')} )} +

@@ -960,6 +964,7 @@ function FileCard({ {t('Expired')} )} +

diff --git a/tests/Feature/Api/FilesReadTest.php b/tests/Feature/Api/FilesReadTest.php index 0094be88..5b575ce7 100644 --- a/tests/Feature/Api/FilesReadTest.php +++ b/tests/Feature/Api/FilesReadTest.php @@ -202,3 +202,25 @@ test('a token cannot download a file outside its scope', function () { $this->withToken($token)->getJson("/api/v1/files/{$unrelated->id}/download")->assertForbidden(); }); + +test('a file says where it stands with the virus scanner, and can be filtered by it', function () { + $pending = File::factory()->create([ + 'uploaded_by' => $this->admin->id, + 'scan_status' => App\Modules\Files\Scanning\ScanStatus::Pending, + ]); + File::factory()->create(['uploaded_by' => $this->admin->id]); + + $this->withToken($this->token)->getJson("/api/v1/files/{$pending->id}") + ->assertOk() + ->assertJsonPath('data.scan.status', 'pending') + ->assertJsonPath('data.scan.available', false); + + // The download says "not yet" rather than "no": 423, and the caller + // can poll the field above. + $this->withToken($this->token)->get("/api/v1/files/{$pending->id}/download")->assertStatus(423); + + $ids = $this->withToken($this->token)->getJson('/api/v1/files?scan_status=pending') + ->assertOk()->json('data.*.id'); + + expect($ids)->toBe([$pending->id]); +});