Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/integrations/avatarStorage.server.ts
T

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.
}
}