Merge pull request #1787 from projectsend/public-unscanned-notice

Tell whoever opens a public link that nothing checked the file
This commit is contained in:
Ignacio Nelson
2026-09-18 02:08:57 -03:00
committed by GitHub
26 changed files with 246 additions and 22 deletions
@@ -12,6 +12,7 @@ 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;
@@ -32,6 +33,7 @@ class PublicShareController extends Controller
private readonly DownloadAllowance $allowance,
private readonly StoredFileResponse $bytes,
private readonly FileAvailability $availability,
private readonly ScanningConfig $scanning,
) {}
public function show(string $token): InertiaResponse
@@ -84,6 +86,11 @@ class PublicShareController extends Controller
])->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(),
]);
}
+26 -5
View File
@@ -319,6 +319,21 @@ class File extends Model
});
}
/**
* Why a file went out unchecked. Deliberately not
* NotScannedReason::BeforeScanning: a file stored while this
* installation did not scan at all is not a scanner letting something
* past, and on an installation that has never scanned it would mean
* saying it about every file there is.
*
* @var list<string>
*/
private const LET_THROUGH_REASONS = [
NotScannedReason::ScannerUnavailable->value,
NotScannedReason::TooLarge->value,
NotScannedReason::Encrypted->value,
];
/**
* Files people can download that nothing checked: let through while
* the scanner was down, or because it could not open them.
@@ -334,11 +349,17 @@ class File extends Model
public function scopeLetThrough(Builder $query): void
{
$query->where('scan_status', ScanStatus::NotScanned)
->whereIn('scan_note', [
NotScannedReason::ScannerUnavailable->value,
NotScannedReason::TooLarge->value,
NotScannedReason::Encrypted->value,
]);
->whereIn('scan_note', self::LET_THROUGH_REASONS);
}
/**
* Whether this particular file is one of those the row's own answer
* to scopeLetThrough(), for a page that already has the file.
*/
public function wasLetThrough(): bool
{
return $this->scan_status === ScanStatus::NotScanned
&& in_array((string) $this->scan_note, self::LET_THROUGH_REASONS, true);
}
/**
@@ -14,6 +14,7 @@ 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\Models\Folder;
use App\Modules\Files\Preview\PreviewKind;
use App\Modules\Files\Preview\PreviewLog;
@@ -228,6 +229,9 @@ class PublicGroupsController extends Controller
// is allowed.
'preview_url' => $this->previewUrlFor($file, $publicSlug),
'download_url' => route('public.download', [$publicSlug, $file->slug]),
// See PublicShareController::show — the same sentence, to the
// same person, on the other public surface.
'unscanned' => app(ScanningConfig::class)->enabled() && $file->wasLetThrough(),
// Same decided shape the listings send, so a theme's single
// file page disables its button for the same reason a row
// does — see DownloadAllowance::summaryFor.
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Un fitxer d'aquest arxiu ja no està disponible. Torna a descarregar la selecció.",
"A scan is already running. Wait for it to finish.": "Ja hi ha una anàlisi en curs. Espera que acabi.",
"Something answered at :address, but it is not a ClamAV scanner.": "Alguna cosa ha respost a :address, però no és un escàner ClamAV.",
"Available until :date": "Disponible fins al :date"
"Available until :date": "Disponible fins al :date",
"This file was not checked for viruses.": "Aquest fitxer no s'ha analitzat a la recerca de virus."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Soubor z tohoto archivu už není k dispozici. Stáhněte výběr znovu.",
"A scan is already running. Wait for it to finish.": "Kontrola už probíhá. Počkejte, až skončí.",
"Something answered at :address, but it is not a ClamAV scanner.": "Na adrese :address něco odpovědělo, ale není to skener ClamAV.",
"Available until :date": "K dispozici do :date"
"Available until :date": "K dispozici do :date",
"This file was not checked for viruses.": "Tento soubor nebyl zkontrolován na viry."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Eine Datei in diesem Archiv ist nicht mehr verfügbar. Lade die Auswahl erneut herunter.",
"A scan is already running. Wait for it to finish.": "Es läuft bereits ein Scan. Warte, bis er fertig ist.",
"Something answered at :address, but it is not a ClamAV scanner.": "Unter :address hat etwas geantwortet, aber es ist kein ClamAV-Scanner.",
"Available until :date": "Verfügbar bis :date"
"Available until :date": "Verfügbar bis :date",
"This file was not checked for viruses.": "Diese Datei wurde nicht auf Viren geprüft."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Un archivo de este comprimido ya no está disponible. Vuelve a descargar la selección.",
"A scan is already running. Wait for it to finish.": "Ya hay un análisis en curso. Espera a que termine.",
"Something answered at :address, but it is not a ClamAV scanner.": "Algo respondió en :address, pero no es un analizador ClamAV.",
"Available until :date": "Disponible hasta el :date"
"Available until :date": "Disponible hasta el :date",
"This file was not checked for viruses.": "Este archivo no fue analizado en busca de virus."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Un fichier de cette archive n'est plus disponible. Téléchargez à nouveau la sélection.",
"A scan is already running. Wait for it to finish.": "Une analyse est déjà en cours. Attendez qu'elle se termine.",
"Something answered at :address, but it is not a ClamAV scanner.": "Quelque chose a répondu à :address, mais ce n'est pas un scanner ClamAV.",
"Available until :date": "Disponible jusqu'au :date"
"Available until :date": "Disponible jusqu'au :date",
"This file was not checked for viruses.": "Ce fichier n'a pas été analysé à la recherche de virus."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Sebuah file dalam arsip ini sudah tidak tersedia. Unduh ulang pilihan tersebut.",
"A scan is already running. Wait for it to finish.": "Pemindaian sudah berjalan. Tunggu hingga selesai.",
"Something answered at :address, but it is not a ClamAV scanner.": "Ada yang menjawab di :address, tetapi itu bukan pemindai ClamAV.",
"Available until :date": "Tersedia hingga :date"
"Available until :date": "Tersedia hingga :date",
"This file was not checked for viruses.": "Berkas ini belum dipindai dari virus."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Un file di questo archivio non è più disponibile. Scarica di nuovo la selezione.",
"A scan is already running. Wait for it to finish.": "È già in corso una scansione. Attendi che finisca.",
"Something answered at :address, but it is not a ClamAV scanner.": "Qualcosa ha risposto su :address, ma non è uno scanner ClamAV.",
"Available until :date": "Disponibile fino al :date"
"Available until :date": "Disponibile fino al :date",
"This file was not checked for viruses.": "Questo file non è stato controllato alla ricerca di virus."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "このアーカイブ内のファイルは利用できなくなりました。もう一度選択をダウンロードしてください。",
"A scan is already running. Wait for it to finish.": "スキャンはすでに実行中です。終了するまでお待ちください。",
"Something answered at :address, but it is not a ClamAV scanner.": ":address で応答がありましたが、ClamAV スキャナーではありません。",
"Available until :date": ":date まで利用可能"
"Available until :date": ":date まで利用可能",
"This file was not checked for viruses.": "このファイルはウイルス検査を受けていません。"
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Een bestand in dit archief is niet meer beschikbaar. Download de selectie opnieuw.",
"A scan is already running. Wait for it to finish.": "Er loopt al een scan. Wacht tot die klaar is.",
"Something answered at :address, but it is not a ClamAV scanner.": "Er antwoordde iets op :address, maar het is geen ClamAV-scanner.",
"Available until :date": "Beschikbaar tot :date"
"Available until :date": "Beschikbaar tot :date",
"This file was not checked for viruses.": "Dit bestand is niet op virussen gecontroleerd."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Plik z tego archiwum nie jest już dostępny. Pobierz zaznaczenie ponownie.",
"A scan is already running. Wait for it to finish.": "Skanowanie już trwa. Poczekaj, aż się zakończy.",
"Something answered at :address, but it is not a ClamAV scanner.": "Coś odpowiedziało pod adresem :address, ale to nie jest skaner ClamAV.",
"Available until :date": "Dostępny do :date"
"Available until :date": "Dostępny do :date",
"This file was not checked for viruses.": "Ten plik nie został sprawdzony pod kątem wirusów."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Um arquivo deste pacote não está mais disponível. Baixe a seleção novamente.",
"A scan is already running. Wait for it to finish.": "Já há uma verificação em andamento. Aguarde até que termine.",
"Something answered at :address, but it is not a ClamAV scanner.": "Algo respondeu em :address, mas não é um scanner ClamAV.",
"Available until :date": "Disponível até :date"
"Available until :date": "Disponível até :date",
"This file was not checked for viruses.": "Este arquivo não foi verificado em busca de vírus."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Один из файлов этого архива больше недоступен. Скачайте выбранное заново.",
"A scan is already running. Wait for it to finish.": "Проверка уже идёт. Дождитесь её завершения.",
"Something answered at :address, but it is not a ClamAV scanner.": "По адресу :address что-то ответило, но это не сканер ClamAV.",
"Available until :date": "Доступен до :date"
"Available until :date": "Доступен до :date",
"This file was not checked for viruses.": "Этот файл не проверялся на вирусы."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Faili moja katika kumbukumbu hii halipatikani tena. Pakua uteuzi upya.",
"A scan is already running. Wait for it to finish.": "Uchanganuzi tayari unaendelea. Subiri umalizike.",
"Something answered at :address, but it is not a ClamAV scanner.": "Kitu kimejibu kwenye :address, lakini si kichanganuzi cha ClamAV.",
"Available until :date": "Inapatikana hadi :date"
"Available until :date": "Inapatikana hadi :date",
"This file was not checked for viruses.": "Faili hii haijakaguliwa dhidi ya virusi."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Bu arşivdeki bir dosya artık kullanılamıyor. Seçimi yeniden indirin.",
"A scan is already running. Wait for it to finish.": "Zaten bir tarama çalışıyor. Bitmesini bekleyin.",
"Something answered at :address, but it is not a ClamAV scanner.": ":address adresinde bir şey yanıt verdi, ancak bu bir ClamAV tarayıcısı değil.",
"Available until :date": ":date tarihine kadar kullanılabilir"
"Available until :date": ":date tarihine kadar kullanılabilir",
"This file was not checked for viruses.": "Bu dosya virüslere karşı taranmadı."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "Một tệp trong gói nén này không còn khả dụng. Hãy tải lại phần đã chọn.",
"A scan is already running. Wait for it to finish.": "Đã có một lượt quét đang chạy. Hãy đợi nó hoàn tất.",
"Something answered at :address, but it is not a ClamAV scanner.": "Có dịch vụ trả lời tại :address, nhưng đó không phải là trình quét ClamAV.",
"Available until :date": "Có sẵn đến :date"
"Available until :date": "Có sẵn đến :date",
"This file was not checked for viruses.": "Tệp này chưa được quét virus."
}
+2 -1
View File
@@ -2249,5 +2249,6 @@
"A file in this archive is no longer available. Download the selection again.": "此压缩包中的某个文件已不可用。请重新下载所选内容。",
"A scan is already running. Wait for it to finish.": "已有扫描正在进行。请等待其完成。",
"Something answered at :address, but it is not a ClamAV scanner.": ":address 上有程序应答,但它不是 ClamAV 扫描器。",
"Available until :date": "可用至 :date"
"Available until :date": "可用至 :date",
"This file was not checked for viruses.": "此文件未经过病毒检查。"
}
@@ -0,0 +1,38 @@
import { ShieldQuestion } from 'lucide-react';
import { useTranslation } from '@/hooks/use-translation';
import { cn } from '@/lib/utils';
interface UnscannedNoticeProps {
/** Whether this file went out without being checked. Decided by the server. */
unscanned?: boolean;
className?: string;
}
/**
* Told to whoever opens a public link: this file was never checked.
*
* It happens on an installation that scans and chose to let files through
* anyway too large for the scanner, an archive it could not open, or an
* upload that arrived while the scanner was down. The uploader and the
* staff library both see that state on the file; the person following the
* link neither chose the policy nor can see the setting, and until this
* they were the only one with no signal at all.
*
* Stated plainly and without alarm: nothing is known to be wrong with the
* file. What is known is that nothing looked.
*/
export function UnscannedNotice({ unscanned, className }: UnscannedNoticeProps) {
const { t } = useTranslation();
if (!unscanned) {
return null;
}
return (
<p className={cn('text-muted-foreground flex items-center justify-center gap-1.5 text-xs', className)}>
<ShieldQuestion className="size-3.5 shrink-0" />
{t('This file was not checked for viruses.')}
</p>
);
}
@@ -3,6 +3,7 @@ import { File as FileIcon } from 'lucide-react';
import { CommentsShellCompact } from '@/components/comments/shells/comments-shell-compact';
import { DownloadAction } from '@/components/download-action';
import { UnscannedNotice } from '@/components/files/unscanned-notice';
import { PreviewAction } from '@/components/preview-action';
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
import { type VersionLinks } from '@/components/files/version-badge';
@@ -33,6 +34,8 @@ interface PublicFileShowProps {
preview_url: string | null;
download_url: string;
download_limit: DownloadLimit;
/** Whether the file went out unchecked — see UnscannedNotice. */
unscanned?: boolean;
comments_enabled: boolean;
comments_endpoint: string;
}
@@ -43,6 +46,7 @@ export default function PublicFileShowCompact({
preview_url,
download_url,
download_limit,
unscanned,
comments_enabled,
comments_endpoint,
}: PublicFileShowProps) {
@@ -86,6 +90,8 @@ export default function PublicFileShowCompact({
/>
<DownloadAction href={download_url} limit={download_limit} size="sm" />
</div>
<UnscannedNotice unscanned={unscanned} className="justify-start" />
</div>
</div>
@@ -3,6 +3,7 @@ import { File as FileIcon } from 'lucide-react';
import { CommentsShellDefault } from '@/components/comments/shells/comments-shell-default';
import { DownloadAction } from '@/components/download-action';
import { UnscannedNotice } from '@/components/files/unscanned-notice';
import { PreviewAction } from '@/components/preview-action';
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
import { type VersionLinks } from '@/components/files/version-badge';
@@ -33,6 +34,8 @@ interface PublicFileShowProps {
preview_url: string | null;
download_url: string;
download_limit: DownloadLimit;
/** Whether the file went out unchecked — see UnscannedNotice. */
unscanned?: boolean;
comments_enabled: boolean;
comments_endpoint: string;
}
@@ -43,6 +46,7 @@ export default function PublicFileShow({
preview_url,
download_url,
download_limit,
unscanned,
comments_enabled,
comments_endpoint,
}: PublicFileShowProps) {
@@ -81,6 +85,8 @@ export default function PublicFileShow({
<DownloadAction href={download_url} limit={download_limit} size="default" />
</div>
<UnscannedNotice unscanned={unscanned} />
{comments_enabled && (
<div className="w-full max-w-lg">
<CommentsShellDefault fileName={file.name} endpoint={comments_endpoint} inline />
@@ -2,6 +2,7 @@ import { Head } from '@inertiajs/react';
import { CommentsShellDrive } from '@/components/comments/shells/comments-shell-drive';
import { DownloadAction } from '@/components/download-action';
import { UnscannedNotice } from '@/components/files/unscanned-notice';
import { PreviewAction } from '@/components/preview-action';
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
import { type VersionLinks } from '@/components/files/version-badge';
@@ -33,6 +34,8 @@ interface PublicFileShowProps {
preview_url: string | null;
download_url: string;
download_limit: DownloadLimit;
/** Whether the file went out unchecked — see UnscannedNotice. */
unscanned?: boolean;
comments_enabled: boolean;
comments_endpoint: string;
}
@@ -43,6 +46,7 @@ export default function PublicFileShowDrive({
preview_url,
download_url,
download_limit,
unscanned,
comments_enabled,
comments_endpoint,
}: PublicFileShowProps) {
@@ -92,6 +96,8 @@ export default function PublicFileShowDrive({
/>
</div>
<UnscannedNotice unscanned={unscanned} />
{comments_enabled && (
<div className="w-full">
<CommentsShellDrive fileName={file.name} endpoint={comments_endpoint} inline />
@@ -3,6 +3,7 @@ import { File as FileIcon } from 'lucide-react';
import { CommentsShellGallery } from '@/components/comments/shells/comments-shell-gallery';
import { DownloadAction } from '@/components/download-action';
import { UnscannedNotice } from '@/components/files/unscanned-notice';
import { PreviewAction } from '@/components/preview-action';
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
import { type VersionLinks } from '@/components/files/version-badge';
@@ -33,6 +34,8 @@ interface PublicFileShowProps {
preview_url: string | null;
download_url: string;
download_limit: DownloadLimit;
/** Whether the file went out unchecked — see UnscannedNotice. */
unscanned?: boolean;
comments_enabled: boolean;
comments_endpoint: string;
}
@@ -43,6 +46,7 @@ export default function PublicFileShowGallery({
preview_url,
download_url,
download_limit,
unscanned,
comments_enabled,
comments_endpoint,
}: PublicFileShowProps) {
@@ -84,6 +88,8 @@ export default function PublicFileShowGallery({
<DownloadAction href={download_url} limit={download_limit} size="lg" />
</div>
<UnscannedNotice unscanned={unscanned} />
{comments_enabled && (
<div className="w-full">
<CommentsShellGallery fileName={file.name} endpoint={comments_endpoint} inline />
+6 -1
View File
@@ -2,6 +2,7 @@ import { Head } from '@inertiajs/react';
import { Download } from 'lucide-react';
import { CategoryBadges, type CategoryTag } from '@/components/files/category-badges';
import { UnscannedNotice } from '@/components/files/unscanned-notice';
import { Button } from '@/components/ui/button';
import { useTranslation } from '@/hooks/use-translation';
import AuthLayout from '@/layouts/auth-layout';
@@ -15,9 +16,11 @@ interface ShareShowProps {
categories: CategoryTag[];
};
download_url?: string;
/** Whether the file went out unchecked — see UnscannedNotice. */
unscanned?: boolean;
}
export default function ShareShow({ status, file, download_url }: ShareShowProps) {
export default function ShareShow({ status, file, download_url, unscanned }: ShareShowProps) {
const { t } = useTranslation();
if (status !== 'active' || !file || !download_url) {
@@ -56,6 +59,8 @@ export default function ShareShow({ status, file, download_url }: ShareShowProps
<Download className="size-4" /> {t('Download')}
</a>
</Button>
<UnscannedNotice unscanned={unscanned} className="mt-4" />
</AuthLayout>
);
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Scanning\NotScannedReason;
use App\Modules\Files\Scanning\ScanStatus;
use App\Modules\Files\Sharing\CreateShareLink;
use App\Modules\Groups\Models\Group;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
/**
* What somebody following a public link is told about a file nothing
* checked.
*
* An installation that scans can still let files through too large for
* the scanner, an archive it could not open, or an upload that arrived
* while the scanner was down. The uploader sees that on their own file and
* staff see it in the library. The person holding the link sees the same
* page as for a file that passed, and they neither chose the policy nor
* can see the setting.
*/
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
$this->settings = app(Settings::class);
$this->settings->set(Setting::VirusScanningEnabled, true);
$this->settings->set(Setting::VirusScannerAddress, 'tcp://scanner.test:3310');
$this->settings->set(Setting::PublicListingEnabled, true);
$this->settings->set(Setting::PublicListingSlug, 'public');
$this->settings->set(Setting::Theme, 'default');
});
/** The `unscanned` prop on the share page for a file in this state. */
function sharedFileNotice(array $scan): bool
{
$file = File::factory()->create(array_merge(['uploaded_by' => test()->admin->id], $scan));
$link = app(CreateShareLink::class)->for($file, test()->admin);
$notice = null;
test()->get("/s/{$link->token}")->assertInertia(function (AssertableInertia $page) use (&$notice) {
$notice = $page->toArray()['props']['unscanned'];
});
return $notice;
}
test('a link to a file nothing checked says so', function () {
expect(sharedFileNotice([
'scan_status' => ScanStatus::NotScanned,
'scan_note' => NotScannedReason::TooLarge->value,
]))->toBeTrue();
});
test('the same for a file that went out while the scanner was down, or that it could not open', function () {
foreach ([NotScannedReason::ScannerUnavailable, NotScannedReason::Encrypted] as $reason) {
expect(sharedFileNotice(['scan_status' => ScanStatus::NotScanned, 'scan_note' => $reason->value]))
->toBeTrue($reason->value);
}
});
test('a file that passed says nothing', function () {
expect(sharedFileNotice(['scan_status' => ScanStatus::Clean]))->toBeFalse();
});
test('a file from before this installation scanned says nothing', function () {
// Every file on an installation that has only just switched scanning
// on is in this state. Saying it about all of them says nothing about
// any of them.
expect(sharedFileNotice([
'scan_status' => ScanStatus::NotScanned,
'scan_note' => NotScannedReason::BeforeScanning->value,
]))->toBeFalse();
expect(sharedFileNotice(['scan_status' => ScanStatus::NotScanned, 'scan_note' => null]))->toBeFalse();
});
test('an installation that does not scan says nothing about any of it', function () {
$this->settings->set(Setting::VirusScanningEnabled, false);
expect(sharedFileNotice([
'scan_status' => ScanStatus::NotScanned,
'scan_note' => NotScannedReason::TooLarge->value,
]))->toBeFalse();
});
test('the public file page says it too, in every theme', function (string $theme) {
$this->settings->set(Setting::Theme, $theme);
$group = Group::query()->create(['name' => 'Showcase', 'public' => true]);
$file = File::factory()->public()->create([
'uploaded_by' => $this->admin->id,
'scan_status' => ScanStatus::NotScanned,
'scan_note' => NotScannedReason::ScannerUnavailable->value,
]);
shareFileWithGroup($file, $group);
$this->get("/public/files/{$file->slug}")->assertInertia(
fn (AssertableInertia $page) => $page
->component("public/themes/{$theme}/file")
->where('unscanned', true),
);
})->with(['default', 'compact', 'drive', 'gallery']);