mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-17 09:05:08 +00:00
Show staff where a file stands, and say the same through the API
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.
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` <br/> Waiting to be scanned, or being scanned right now. |\n| `clean` <br/> Scanned, nothing found. |\n| `infected` <br/> A threat was found. Quarantined; `scan_note` is the threat name. |\n| `released` <br/> Was infected, and an administrator decided to allow it anyway. |\n| `not_scanned` <br/> Not checked, and allowed through. `scan_note` is a NotScannedReason. |\n| `unscannable_blocked` <br/> 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": {
|
||||
|
||||
@@ -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 (
|
||||
<Badge variant="outline" className="text-[11px] font-normal" title={t('Nobody can download it until this finishes.')}>
|
||||
{t('Checking')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (scan.status === 'infected' || scan.status === 'unscannable_blocked') {
|
||||
return (
|
||||
<Badge variant="destructive" className="text-[11px] font-normal" title={scan.note ?? undefined}>
|
||||
{t('Quarantined')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (scan.status === 'released') {
|
||||
return (
|
||||
<Badge variant="secondary" className="text-[11px] font-normal" title={t('An administrator released this file from quarantine.')}>
|
||||
{t('Released')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="text-[11px] font-normal" title={scan.note ?? undefined}>
|
||||
{t('Not scanned')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
</Badge>
|
||||
)}
|
||||
<ScanBadge scan={row.scan} />
|
||||
<VersionBadge version={row.version} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -960,6 +964,7 @@ function FileCard({
|
||||
{t('Expired')}
|
||||
</Badge>
|
||||
)}
|
||||
<ScanBadge scan={row.scan} />
|
||||
<VersionBadge version={row.version} />
|
||||
</div>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user