feat(domain): virus-scan and PDF-check uploaded diplomas with ClamAV (closes #192) #194

Open
not wants to merge 13 commits from feat/192-scan-uploads into main
2 changed files with 24 additions and 10 deletions
Showing only changes of commit 5ceace718a - Show all commits
@@ -14,10 +14,8 @@
@if (documentsProvided()) {
<p utrecht-paragraph role="status">Uw documenten zijn aangeleverd.</p>
} @else {
@if (provideDocumentsFailed()) {
<p utrecht-paragraph role="alert">
Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.
</p>
@if (provideDocumentsError(); as error) {
<p utrecht-paragraph role="alert">{{ error }}</p>
}
<p utrecht-paragraph>Lever uw diploma aan (PDF).</p>
<label utrecht-form-label for="diploma">Diploma</label>
@@ -1,5 +1,6 @@
import { Component, inject, type OnInit, signal } from '@angular/core';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
import { HttpErrorResponse } from '@angular/common/http';
import { BffApiV1Service, type CurrentRegistration, type Refusal, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui';
@@ -31,7 +32,8 @@ export class RegistrationPage implements OnInit {
protected readonly withdrawFailed = signal(false);
protected readonly providingDocuments = signal(false);
protected readonly documentsProvided = signal(false);
protected readonly provideDocumentsFailed = signal(false);
/** Why the last upload failed, worded for the citizen; undefined while there is nothing to report. */
protected readonly provideDocumentsError = signal<string | undefined>(undefined);
protected readonly selectedFile = signal<File | undefined>(undefined);
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
@@ -80,12 +82,12 @@ export class RegistrationPage implements OnInit {
return;
}
this.providingDocuments.set(true);
this.provideDocumentsFailed.set(false);
this.provideDocumentsError.set(undefined);
let contentBase64: string;
try {
contentBase64 = await readAsBase64(file);
} catch {
this.provideDocumentsFailed.set(true);
this.provideDocumentsError.set(uploadFailure());
this.providingDocuments.set(false);
return;
}
@@ -101,8 +103,8 @@ export class RegistrationPage implements OnInit {
this.providingDocuments.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.provideDocumentsFailed.set(true);
error: (err: unknown) => {
this.provideDocumentsError.set(uploadFailure(err));
this.providingDocuments.set(false);
},
});
@@ -129,6 +131,20 @@ export class RegistrationPage implements OnInit {
}
}
/** Word a failed upload for the citizen: the BFF says why a file was refused (422 + reason) or that
* the virus scanner was unreachable (503, S-29); anything else is a generic retry. */
function uploadFailure(err?: unknown): string {
if (err instanceof HttpErrorResponse && err.status === 422) {
return (err.error as Refusal | null)?.reason === 'infected'
? 'Er is een virus gevonden in dit bestand. Het is niet opgeslagen; lever een ander bestand aan.'
: 'Dit bestand is geen PDF. Lever uw diploma aan als PDF-bestand.';
}
if (err instanceof HttpErrorResponse && err.status === 503) {
return 'Uw bestand kan tijdelijk niet worden gecontroleerd. Probeer het later opnieuw.';
}
return 'Het aanleveren van uw documenten is niet gelukt. Probeer het opnieuw.';
}
/** Read a file's bytes as a base64 string (without the `data:...;base64,` prefix). */
function readAsBase64(file: File): Promise<string> {
return new Promise<string>((resolve, reject) => {