diff --git a/frontend/src/app/models/dtos/FileScanResultDto.ts b/frontend/src/app/models/dtos/FileScanResultDto.ts new file mode 100644 index 00000000..7803cc94 --- /dev/null +++ b/frontend/src/app/models/dtos/FileScanResultDto.ts @@ -0,0 +1,7 @@ +export type ScanVerdict = 'CLEAN' | 'INFECTED' | 'ERROR'; + +export interface FileScanResultDto { + path: string; + verdict: ScanVerdict; + detail?: string; +} diff --git a/frontend/src/app/models/dtos/ScanJobDto.ts b/frontend/src/app/models/dtos/ScanJobDto.ts new file mode 100644 index 00000000..f9c287c1 --- /dev/null +++ b/frontend/src/app/models/dtos/ScanJobDto.ts @@ -0,0 +1,15 @@ +import { FileScanResultDto } from './FileScanResultDto'; + +export type ScanStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED'; + +export interface ScanJobDto { + jobId: string; + path: string; + status: ScanStatus; + createdAt?: string; + finishedAt?: string; + filesScanned: number; + infectedCount: number; + error?: string; + findings?: FileScanResultDto[]; +} diff --git a/frontend/src/app/pages/cloud/cloud.component.html b/frontend/src/app/pages/cloud/cloud.component.html index 16bd8cd4..fc8534cd 100644 --- a/frontend/src/app/pages/cloud/cloud.component.html +++ b/frontend/src/app/pages/cloud/cloud.component.html @@ -17,6 +17,15 @@

Cloud Page

+ Cloud Page > + + +
+

+ Scanning {{ scanFolderLabel }} +

+ + +
+ + {{ scanError }} +
+ + + +
+ +
+ {{ + job.status === "PENDING" ? "Queued…" : "Scanning…" + }} + + {{ job.filesScanned }} file{{ + job.filesScanned === 1 ? "" : "s" + }} + scanned · {{ job.infectedCount }} infected + +
+
+ + +
+ + {{ + job.error || "The scan failed to complete." + }} +
+ + +
+ + {{ scanUnavailableMessage }} +
+ + +
+ +
+ No threats found. + {{ job.filesScanned }} file{{ + job.filesScanned === 1 ? "" : "s" + }} + scanned. +
+
+ + +
+
+ + + {{ job.infectedCount }} infected file{{ + job.infectedCount === 1 ? "" : "s" + }} + found out of {{ job.filesScanned }} scanned. + +
+
    +
  • + {{ + finding.path + }} + {{ + finding.detail || "Threat detected" + }} +
  • +
+
+ + +
+

+ {{ scanErroredFindings.length }} file{{ + scanErroredFindings.length === 1 ? "" : "s" + }} + could not be scanned +

+
    +
  • + {{ + finding.path + }} + {{ + finding.detail || "Could not scan" + }} +
  • +
+
+
+
+ + + + + +
diff --git a/frontend/src/app/pages/cloud/cloud.component.scss b/frontend/src/app/pages/cloud/cloud.component.scss index 9d474276..376a00c7 100644 --- a/frontend/src/app/pages/cloud/cloud.component.scss +++ b/frontend/src/app/pages/cloud/cloud.component.scss @@ -359,6 +359,122 @@ gap: 0.35rem; } +.cloud-scan-body { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.cloud-scan-target { + margin: 0; + color: var(--color-text-secondary); + + strong { + color: var(--color-text-primary); + word-break: break-word; + } +} + +.cloud-scan-state { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.7rem 0.85rem; + border-radius: 0.55rem; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-primary); + + i { + font-size: 1.1rem; + flex-shrink: 0; + } +} + +.cloud-scan-running-text { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.cloud-scan-counters { + color: var(--color-text-secondary); +} + +.cloud-scan-state--running i { + color: var(--color-primary); +} + +.cloud-scan-state--clean { + border-color: color-mix(in srgb, #10b981 45%, var(--color-border)); + background: color-mix(in srgb, #10b981 10%, transparent); + + i { + color: #059669; + } +} + +.cloud-scan-state--warn { + border-color: color-mix(in srgb, #f59e0b 45%, var(--color-border)); + background: color-mix(in srgb, #f59e0b 12%, transparent); + + i { + color: #d97706; + } +} + +.cloud-scan-state--error, +.cloud-scan-state--infected { + border-color: color-mix(in srgb, #ef4444 45%, var(--color-border)); + background: color-mix(in srgb, #ef4444 10%, transparent); + + i { + color: #dc2626; + } +} + +.cloud-scan-findings { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.cloud-scan-subhead { + margin: 0; + color: var(--color-text-secondary); +} + +.cloud-scan-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 15rem; + overflow-y: auto; + + li { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.5rem 0.65rem; + border-radius: 0.45rem; + border: 1px solid var(--color-border); + background: var(--color-surface); + } +} + +.cloud-scan-file { + color: var(--color-text-primary); + word-break: break-all; +} + +.cloud-scan-threat { + color: var(--color-text-secondary); + word-break: break-word; +} + .cloud-editor { width: 100%; min-height: 14rem; diff --git a/frontend/src/app/pages/cloud/cloud.component.spec.ts b/frontend/src/app/pages/cloud/cloud.component.spec.ts new file mode 100644 index 00000000..45863cbb --- /dev/null +++ b/frontend/src/app/pages/cloud/cloud.component.spec.ts @@ -0,0 +1,244 @@ +import { of, throwError, Subject } from 'rxjs'; +import { CloudComponent } from './cloud.component'; +import { CloudService } from '../../services/cloud.service'; +import { ScanJobDto } from '../../models/dtos/ScanJobDto'; + +/** + * Exercises the folder virus-scan state machine end to end with a mocked + * CloudService and Jasmine's fake clock driving the poll timer. Covers the + * running/completed transitions, disabled/unreachable-scanner detection, the + * clean result, rate-limit and expired-job errors, and that polling stops on a + * terminal status or when the dialog closes (including a close that races the + * initial start request). + */ +describe('CloudComponent virus scan', () => { + let component: CloudComponent; + let cloudMock: jasmine.SpyObj; + + const runningJob: ScanJobDto = { + jobId: 'job-1', + path: '', + status: 'RUNNING', + filesScanned: 2, + infectedCount: 0, + }; + + const noopToast = { + success: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }; + + beforeEach(() => { + jasmine.clock().install(); + cloudMock = jasmine.createSpyObj('CloudService', [ + 'startFolderScan', + 'getScanJob', + ]); + component = new CloudComponent( + cloudMock, + {} as never, + noopToast as never, + {} as never, + {} as never, + ); + component.rootPath = '/root'; + component.currentFolder = { path: '/root', name: 'root' } as never; + }); + + afterEach(() => jasmine.clock().uninstall()); + + it('polls until COMPLETED, then stops and exposes infected findings', () => { + cloudMock.startFolderScan.and.returnValue(of({ ...runningJob })); + const stillRunning: ScanJobDto = { ...runningJob, filesScanned: 3 }; + const completed: ScanJobDto = { + jobId: 'job-1', + path: '', + status: 'COMPLETED', + filesScanned: 3, + infectedCount: 1, + findings: [{ path: 'a/evil.exe', verdict: 'INFECTED', detail: 'Eicar' }], + }; + cloudMock.getScanJob.and.returnValues(of(stillRunning), of(completed)); + + component.scanCurrentFolder(); + expect(component.scanning).toBeTrue(); + expect(component.scanJob?.status).toBe('RUNNING'); + + jasmine.clock().tick(1200); // first poll -> still running -> reschedule + expect(component.scanning).toBeTrue(); + + jasmine.clock().tick(1200); // second poll -> completed + expect(component.scanning).toBeFalse(); + expect(component.scanJob?.status).toBe('COMPLETED'); + expect(component.scanInfectedFindings.length).toBe(1); + expect(component.scanNoThreats).toBeFalse(); + + const callsSoFar = cloudMock.getScanJob.calls.count(); + jasmine.clock().tick(6000); // terminal: must not poll again + expect(cloudMock.getScanJob.calls.count()).toBe(callsSoFar); + }); + + it('detects a disabled scanner (every file errored) with a clear message', () => { + cloudMock.startFolderScan.and.returnValue(of({ ...runningJob })); + cloudMock.getScanJob.and.returnValue( + of({ + jobId: 'job-1', + path: '', + status: 'COMPLETED', + filesScanned: 2, + infectedCount: 0, + findings: [ + { + path: 'a.txt', + verdict: 'ERROR', + detail: 'virus scanning is disabled', + }, + { + path: 'b.txt', + verdict: 'ERROR', + detail: 'virus scanning is disabled', + }, + ], + }), + ); + + component.scanCurrentFolder(); + jasmine.clock().tick(1200); + + expect(component.scanScannerUnavailable).toBeTrue(); + expect(component.scanUnavailableMessage).toContain('turned off'); + expect(component.scanNoThreats).toBeFalse(); + }); + + it('treats an unreachable scanner as unavailable (no "disabled" detail)', () => { + cloudMock.startFolderScan.and.returnValue(of({ ...runningJob })); + cloudMock.getScanJob.and.returnValue( + of({ + jobId: 'job-1', + path: '', + status: 'COMPLETED', + filesScanned: 1, + infectedCount: 0, + findings: [ + { path: 'a.txt', verdict: 'ERROR', detail: 'connection refused' }, + ], + }), + ); + + component.scanCurrentFolder(); + jasmine.clock().tick(1200); + + expect(component.scanScannerUnavailable).toBeTrue(); + expect(component.scanUnavailableMessage).toContain('unavailable'); + }); + + it('reports a clean scan as no threats', () => { + cloudMock.startFolderScan.and.returnValue(of({ ...runningJob })); + cloudMock.getScanJob.and.returnValue( + of({ + jobId: 'job-1', + path: '', + status: 'COMPLETED', + filesScanned: 4, + infectedCount: 0, + findings: [], + }), + ); + + component.scanCurrentFolder(); + jasmine.clock().tick(1200); + + expect(component.scanNoThreats).toBeTrue(); + expect(component.scanScannerUnavailable).toBeFalse(); + expect(component.scanInfectedFindings.length).toBe(0); + }); + + it('surfaces a rate-limit (429) on start and never polls', () => { + cloudMock.startFolderScan.and.returnValue( + throwError(() => ({ status: 429 })), + ); + + component.scanCurrentFolder(); + + expect(component.scanning).toBeFalse(); + expect(component.scanError).toContain('wait'); + jasmine.clock().tick(6000); + expect(cloudMock.getScanJob).not.toHaveBeenCalled(); + }); + + it('keeps the scan alive when polling is rate limited, and still completes', () => { + cloudMock.startFolderScan.and.returnValue(of({ ...runningJob })); + // throttled once, then the backend lets us through again + cloudMock.getScanJob.and.returnValues( + throwError(() => ({ status: 429 })), + of({ ...runningJob, status: 'COMPLETED', findings: [] }), + ); + + component.scanCurrentFolder(); + jasmine.clock().tick(1200); // first poll -> 429 + + // a throttled poll says nothing about the scan: it must not be reported as + // failed, and the dialog must stay in its running state + expect(component.scanning).toBeTrue(); + expect(component.scanError).toBeUndefined(); + + jasmine.clock().tick(5000); // backoff elapses -> poll succeeds + expect(component.scanning).toBeFalse(); + expect(component.scanError).toBeUndefined(); + expect(component.scanJob?.status).toBe('COMPLETED'); + }); + + it('gives up on a persistently rate-limited scan without claiming it failed', () => { + cloudMock.startFolderScan.and.returnValue(of({ ...runningJob })); + cloudMock.getScanJob.and.returnValue(throwError(() => ({ status: 429 }))); + + component.scanCurrentFolder(); + jasmine.clock().tick(1200); // first poll + jasmine.clock().tick(5000 * 5); // exhaust the retries + + expect(component.scanning).toBeFalse(); + expect(component.scanError).toContain('may still be running'); + expect(cloudMock.getScanJob).toHaveBeenCalledTimes(6); // initial + 5 retries + }); + + it('handles an expired job (404) during polling', () => { + cloudMock.startFolderScan.and.returnValue(of({ ...runningJob })); + cloudMock.getScanJob.and.returnValue(throwError(() => ({ status: 404 }))); + + component.scanCurrentFolder(); + jasmine.clock().tick(1200); + + expect(component.scanning).toBeFalse(); + expect(component.scanError).toContain('no longer available'); + }); + + it('stops polling once the dialog is closed mid-scan', () => { + cloudMock.startFolderScan.and.returnValue(of({ ...runningJob })); + cloudMock.getScanJob.and.returnValue(of({ ...runningJob })); // never terminal + + component.scanCurrentFolder(); + jasmine.clock().tick(1200); + const calls = cloudMock.getScanJob.calls.count(); + + component.onScanDialogHide(); + jasmine.clock().tick(6000); + + expect(cloudMock.getScanJob.calls.count()).toBe(calls); + }); + + it('does not start polling if closed while the start request is in flight', () => { + const start$ = new Subject(); + cloudMock.startFolderScan.and.returnValue(start$.asObservable()); + cloudMock.getScanJob.and.returnValue(of({ ...runningJob })); + + component.scanCurrentFolder(); // POST in flight + component.onScanDialogHide(); // user closes before it resolves + start$.next({ ...runningJob }); // POST resolves late + start$.complete(); + jasmine.clock().tick(6000); + + expect(cloudMock.getScanJob).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/app/pages/cloud/cloud.component.ts b/frontend/src/app/pages/cloud/cloud.component.ts index f0d55258..74c01908 100644 --- a/frontend/src/app/pages/cloud/cloud.component.ts +++ b/frontend/src/app/pages/cloud/cloud.component.ts @@ -1,5 +1,11 @@ import { CommonModule } from '@angular/common'; -import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; @@ -20,6 +26,8 @@ import { FileDto } from '../../models/dtos/FileDto'; import { FolderDto } from '../../models/dtos/FolderDto'; import { FolderContentItemDto } from '../../models/dtos/FolderContentItemDto'; import { SearchResultDto } from '../../models/dtos/SearchResultDto'; +import { ScanJobDto } from '../../models/dtos/ScanJobDto'; +import { FileScanResultDto } from '../../models/dtos/FileScanResultDto'; import { CloudService } from '../../services/cloud.service'; import { finalize, firstValueFrom } from 'rxjs'; import { UiToastService } from '../../core/services/ui-toast.service'; @@ -66,7 +74,7 @@ type CloudSort = templateUrl: './cloud.component.html', styleUrls: ['./cloud.component.scss'], }) -export class CloudComponent implements OnInit { +export class CloudComponent implements OnInit, OnDestroy { @ViewChild('fileUploadInput') fileUploadInput?: ElementRef; currentFolder?: FolderDto; @@ -102,6 +110,25 @@ export class CloudComponent implements OnInit { searchActive = false; searching = false; + showScanDialog = false; + scanning = false; + scanJob?: ScanJobDto; + scanError?: string; + scanFolderLabel = ''; + private scanJobId?: string; + private scanPollHandle?: ReturnType; + private readonly scanPollIntervalMs = 1200; + // A 429 while polling means we asked too often, not that the scan died — it + // keeps running on the server. Back off and keep waiting instead of tearing + // the dialog down, giving up only if the backend stays rate limited. + private scanRateLimitedPolls = 0; + private readonly maxRateLimitedPolls = 5; + private readonly scanRateLimitBackoffMs = 5000; + // Bumped whenever a scan starts or the dialog closes; in-flight start/poll + // callbacks compare against it and bail if they've been superseded (mirrors + // the contentRequestId guard used for folder loads/searches). + private scanRunId = 0; + pageSize = 50; totalElements = 0; contentFirst = 0; @@ -365,6 +392,175 @@ export class CloudComponent implements OnInit { this.router.navigate(['/cloud/trash']); } + ngOnDestroy(): void { + // Invalidate in-flight callbacks and drop any pending poll timer. + this.scanRunId++; + this.stopScanPolling(); + } + + private stopScanPolling(): void { + if (this.scanPollHandle) { + clearTimeout(this.scanPollHandle); + this.scanPollHandle = undefined; + } + this.scanJobId = undefined; + } + + private isTerminalScan(status: string): boolean { + return status === 'COMPLETED' || status === 'FAILED'; + } + + scanCurrentFolder(): void { + const folder = this.currentFolder; + this.scanFolderLabel = + folder && folder.path !== this.rootPath + ? this.getNameFromPath(folder.path) + : 'your Cloud folder'; + + this.stopScanPolling(); + const runId = ++this.scanRunId; + this.showScanDialog = true; + this.scanJob = undefined; + this.scanError = undefined; + this.scanRateLimitedPolls = 0; + this.scanning = true; + + const relativePath = this.getRelativePath(folder?.path || this.rootPath); + this.cloudService.startFolderScan(relativePath).subscribe({ + next: (job) => { + // A closed dialog or a newer scan supersedes this start. + if (runId !== this.scanRunId) return; + this.scanJob = job; + this.scanJobId = job.jobId; + if (this.isTerminalScan(job.status)) { + this.scanning = false; + } else { + this.pollScanJob(runId); + } + }, + error: (err) => { + if (runId !== this.scanRunId) return; + this.scanning = false; + this.scanError = this.scanStartErrorMessage(err); + }, + }); + } + + private pollScanJob(runId: number, delayMs = this.scanPollIntervalMs): void { + this.scanPollHandle = setTimeout(() => { + if (runId !== this.scanRunId) return; + const jobId = this.scanJobId; + if (!jobId) return; + this.cloudService.getScanJob(jobId).subscribe({ + next: (job) => { + if (runId !== this.scanRunId) return; + this.scanRateLimitedPolls = 0; + this.scanJob = job; + if (this.isTerminalScan(job.status)) { + this.scanning = false; + } else { + this.pollScanJob(runId); + } + }, + error: (err) => { + if (runId !== this.scanRunId) return; + const status = (err as { status?: number })?.status; + if (status === 429 && this.canRetryRateLimitedPoll()) { + // The scan is untouched by our polling being throttled: wait longer + // and ask again rather than reporting a failure that didn't happen. + this.pollScanJob(runId, this.scanRateLimitBackoffMs); + return; + } + this.scanning = false; + this.scanError = this.scanPollErrorMessage(err); + }, + }); + }, delayMs); + } + + private canRetryRateLimitedPoll(): boolean { + this.scanRateLimitedPolls++; + return this.scanRateLimitedPolls <= this.maxRateLimitedPolls; + } + + onScanDialogHide(): void { + // Fired by the dialog on any close (footer button, header X, or ESC); + // invalidate the run so polling stops and late responses are ignored. + this.scanRunId++; + this.stopScanPolling(); + this.scanning = false; + } + + private scanStartErrorMessage(err: unknown): string { + const status = (err as { status?: number })?.status; + if (status === 429) { + return 'Too many scans were started recently. Please wait a moment and try again.'; + } + if (status === 400) { + return this.getErrorMessage(err); + } + return 'Could not start the scan. Please try again.'; + } + + private scanPollErrorMessage(err: unknown): string { + const status = (err as { status?: number })?.status; + if (status === 404) { + return 'This scan is no longer available. Start a new scan to see results.'; + } + if (status === 429) { + // Only reached once the backoff above is exhausted; the scan itself may + // well still be running, so don't tell the user it failed. + return 'The server is busy, so we stopped checking on this scan. It may still be running — reopen the scan in a moment.'; + } + return 'Lost track of the scan. Please try again.'; + } + + get scanInfectedFindings(): FileScanResultDto[] { + return (this.scanJob?.findings ?? []).filter( + (f) => f.verdict === 'INFECTED', + ); + } + + get scanErroredFindings(): FileScanResultDto[] { + return (this.scanJob?.findings ?? []).filter((f) => f.verdict === 'ERROR'); + } + + /** + * True when a completed scan could not actually scan anything — every file + * came back with an error verdict. This is how the backend surfaces a + * disabled or unreachable scanner (each file yields an ERROR result), so the + * UI can show a clear "unavailable" message instead of a wall of errors. + */ + get scanScannerUnavailable(): boolean { + const job = this.scanJob; + if (!job || job.status !== 'COMPLETED' || job.filesScanned <= 0) { + return false; + } + return ( + job.infectedCount === 0 && + this.scanErroredFindings.length === job.filesScanned + ); + } + + get scanUnavailableMessage(): string { + const disabled = this.scanErroredFindings.some((f) => + (f.detail ?? '').toLowerCase().includes('disabled'), + ); + return disabled + ? 'Virus scanning is turned off on the server, so nothing was scanned.' + : 'The virus scanner is currently unavailable, so nothing was scanned. Please try again later.'; + } + + get scanNoThreats(): boolean { + const job = this.scanJob; + return ( + !!job && + job.status === 'COMPLETED' && + job.infectedCount === 0 && + !this.scanScannerUnavailable + ); + } + navigateToFolder(folderPath?: string) { this.searchActive = false; this.searchQuery = ''; diff --git a/frontend/src/app/services/cloud.service.ts b/frontend/src/app/services/cloud.service.ts index c4634401..9a0fb73c 100644 --- a/frontend/src/app/services/cloud.service.ts +++ b/frontend/src/app/services/cloud.service.ts @@ -6,6 +6,7 @@ import { FolderContentItemDto } from '../models/dtos/FolderContentItemDto'; import { PageResponseDto } from '../models/dtos/PageResponseDto'; import { TrashEntryDto } from '../models/dtos/TrashEntryDto'; import { SearchResultDto } from '../models/dtos/SearchResultDto'; +import { ScanJobDto } from '../models/dtos/ScanJobDto'; import { environment } from '../../environments/environment'; @Injectable({ @@ -168,4 +169,21 @@ export class CloudService { `${this.apiUrl}/files/trash/${encodeURIComponent(id)}`, ); } + + startFolderScan(relativePath?: string): Observable { + // The backend treats a blank path as "scan the whole root", so the folder + // root ('/') is sent as an empty value rather than a literal slash. + const path = + !relativePath || relativePath === '/' ? '' : relativePath.trim(); + const params = new HttpParams().set('path', path); + return this.http.post(`${this.apiUrl}/files/scan`, null, { + params, + }); + } + + getScanJob(jobId: string): Observable { + return this.http.get( + `${this.apiUrl}/files/scan/${encodeURIComponent(jobId)}`, + ); + } }