84 lines
3.1 KiB
TypeScript
84 lines
3.1 KiB
TypeScript
// Avatar write-path (AD-23). Shared by the tenant profile and the admin profile
|
|
// so both store the picture the same way: scanned bytes under a deterministic
|
|
// key in the private media bucket, never a public object URL. The readable side
|
|
// is the /avatars/:userId auth-proxy; see utils/app/avatarPaths.ts.
|
|
|
|
import { avatarStorageKey, isAvatarProxyPath } from "../app/avatarPaths";
|
|
import { auditRejectedUpload } from "./storageAudit.server";
|
|
import { StorageScanRejectedError, UploadContentReadError, deleteFile, resolveStorageBucket, storeFile } from "./storageService";
|
|
|
|
/**
|
|
* An avatar runs through the magic-byte allow-list (png/jpeg/webp) and the
|
|
* fail-closed malware scan before it is stored. Every rejection is an expected
|
|
* outcome, not a crash: map it to a Klartext message (Bauprinzip A.1) so the
|
|
* user learns what to do instead of getting a bare 500. A wrong file type, an
|
|
* oversized image and an unreachable scanner are three different situations.
|
|
* Genuine infrastructure faults (storage outage) return undefined on purpose so
|
|
* the caller rethrows them.
|
|
*/
|
|
export function avatarUploadFailure(error: unknown): { key: string; status: number } | undefined {
|
|
if (error instanceof StorageScanRejectedError) {
|
|
if (error.scanResult.reason === "scanner_error") {
|
|
return { key: "settings.profile.errors.avatarScanUnavailable", status: 503 };
|
|
}
|
|
if (error.scanResult.reason === "malware_detected") {
|
|
return { key: "settings.profile.errors.avatarRejected", status: 422 };
|
|
}
|
|
return { key: "settings.profile.errors.avatarInvalid", status: 422 };
|
|
}
|
|
|
|
if (error instanceof UploadContentReadError) {
|
|
return error.code === "too_large"
|
|
? { key: "settings.profile.errors.avatarTooLarge", status: 413 }
|
|
: { key: "settings.profile.errors.avatarInvalid", status: 422 };
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Scan and store freshly picked avatar bytes. Rejections (wrong type, oversized,
|
|
* malware, scanner down) surface as StorageScanRejectedError /
|
|
* UploadContentReadError — the caller maps them to a Klartext message.
|
|
*/
|
|
export async function storeAvatar({
|
|
request,
|
|
userId,
|
|
dataUri,
|
|
auditContext,
|
|
}: {
|
|
request: Request;
|
|
userId: string;
|
|
dataUri: string;
|
|
auditContext: string;
|
|
}): Promise<void> {
|
|
await storeFile({
|
|
bucket: resolveStorageBucket(),
|
|
content: dataUri,
|
|
id: avatarStorageKey(userId),
|
|
scan: {
|
|
context: "avatar",
|
|
requireMalwareScan: true,
|
|
onReject: auditRejectedUpload(request, auditContext),
|
|
filename: `${userId}.avatar`,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Drop the stored object when the user clears their picture. */
|
|
export async function removeStoredAvatar(currentAvatar: string | null | undefined, userId: string): Promise<void> {
|
|
// Nothing is stored behind a legacy data: URI / absolute URL, or behind no
|
|
// picture at all.
|
|
if (!isAvatarProxyPath(currentAvatar)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await deleteFile({ bucket: resolveStorageBucket(), key: avatarStorageKey(userId) });
|
|
} catch {
|
|
// Clearing the reference is what removes the picture from every screen. A
|
|
// failed object delete must not block that; the orphan bytes are no longer
|
|
// reachable through the proxy.
|
|
}
|
|
}
|