feat(storage): complete phase 3 branding foundation
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { DomainError } from "../domain/errors";
|
||||
import type { FileKind } from "../domain/types";
|
||||
|
||||
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const allowedImages = {
|
||||
"image/jpeg": { extension: "jpg", matches: isJpeg },
|
||||
"image/png": { extension: "png", matches: isPng },
|
||||
"image/webp": { extension: "webp", matches: isWebp },
|
||||
"image/gif": { extension: "gif", matches: isGif },
|
||||
} as const;
|
||||
|
||||
export type AllowedMimeType = keyof typeof allowedImages;
|
||||
|
||||
export type ValidatedUpload = {
|
||||
bytes: Uint8Array;
|
||||
byteSize: number;
|
||||
mimeType: AllowedMimeType;
|
||||
extension: string;
|
||||
originalName: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
export function validateUpload(input: {
|
||||
kind: FileKind;
|
||||
originalName: string;
|
||||
claimedMimeType: string;
|
||||
bytes: Uint8Array;
|
||||
}): ValidatedUpload {
|
||||
const byteSize = input.bytes.byteLength;
|
||||
if (byteSize === 0) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Boş dosya yüklenemez.");
|
||||
}
|
||||
if (byteSize > MAX_UPLOAD_BYTES) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Dosya boyutu 5 MB sınırını aşıyor.", {
|
||||
maximumBytes: MAX_UPLOAD_BYTES,
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = input.claimedMimeType.toLowerCase() as AllowedMimeType;
|
||||
const policy = allowedImages[mimeType];
|
||||
if (!policy || (input.kind === "branding_icon" && mimeType !== "image/png")) {
|
||||
throw new DomainError(
|
||||
"VALIDATION_ERROR",
|
||||
"Yalnızca JPEG, PNG, WebP ve desteklenen alanlarda GIF görselleri kabul edilir; uygulama ikonu PNG olmalı ve SVG desteklenmez.",
|
||||
);
|
||||
}
|
||||
if (!policy.matches(input.bytes)) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Dosya içeriği bildirilen MIME türüyle uyuşmuyor.");
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: input.bytes,
|
||||
byteSize,
|
||||
mimeType,
|
||||
extension: policy.extension,
|
||||
originalName: normalizeOriginalName(input.originalName),
|
||||
sha256: createHash("sha256").update(input.bytes).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOriginalName(value: string): string {
|
||||
const normalized = value
|
||||
.normalize("NFKC")
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.replace(/[\\/]/g, "-")
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
return normalized || "upload";
|
||||
}
|
||||
|
||||
function isJpeg(bytes: Uint8Array) {
|
||||
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
}
|
||||
|
||||
function isPng(bytes: Uint8Array) {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
return bytes.length >= signature.length && signature.every((value, index) => bytes[index] === value);
|
||||
}
|
||||
|
||||
function isWebp(bytes: Uint8Array) {
|
||||
return bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP";
|
||||
}
|
||||
|
||||
function isGif(bytes: Uint8Array) {
|
||||
const header = ascii(bytes, 0, 6);
|
||||
return header === "GIF87a" || header === "GIF89a";
|
||||
}
|
||||
|
||||
function ascii(bytes: Uint8Array, start: number, end: number): string {
|
||||
return String.fromCharCode(...bytes.slice(start, end));
|
||||
}
|
||||
Reference in New Issue
Block a user