feat(media): add reference-aware S3 image delivery

This commit is contained in:
2026-08-28 19:45:53 +00:00 Unverified
parent 35a786156a
commit 56460dcf13
13 changed files with 591 additions and 9 deletions
+100
View File
@@ -0,0 +1,100 @@
import "server-only";
import { and, eq } from "drizzle-orm";
import { getDb } from "@/db";
import { media } from "@/db/schema";
import { getMediaStorage } from "@/lib/media/storage";
import {
hasValidImageSignature,
MAX_MEDIA_BYTES,
safeObjectFileName,
type MediaUploadInput,
} from "@/lib/media/validation";
export async function createPendingMedia(
input: MediaUploadInput,
authorId: string,
): Promise<{ id: string; objectKey: string; uploadUrl: string; expiresIn: number }> {
const id = crypto.randomUUID();
const objectKey = `media/${id}/${safeObjectFileName(input.fileName)}`;
const expiresIn = 5 * 60;
await getDb().insert(media).values({
id,
objectKey,
fileName: input.fileName,
mimeType: input.mimeType,
byteSize: input.byteSize,
status: "pending",
createdById: authorId,
});
const uploadUrl = getMediaStorage().presign(objectKey, {
method: "PUT",
expiresIn,
type: input.mimeType,
acl: "private",
});
return { id, objectKey, uploadUrl, expiresIn };
}
export async function completePendingMedia(mediaId: string) {
const [record] = await getDb()
.select()
.from(media)
.where(and(eq(media.id, mediaId), eq(media.status, "pending")))
.limit(1);
if (!record) return { status: "not-found" as const };
const file = getMediaStorage().file(record.objectKey);
try {
const stat = await file.stat();
const prefix = await file.slice(0, 16).bytes();
const valid =
stat.size > 0 &&
stat.size <= MAX_MEDIA_BYTES &&
stat.size === record.byteSize &&
hasValidImageSignature(prefix, record.mimeType as Parameters<typeof hasValidImageSignature>[1]);
if (!valid) {
await file.delete().catch(() => undefined);
await getDb().update(media).set({ status: "failed" }).where(eq(media.id, record.id));
return { status: "invalid" as const };
}
const [updated] = await getDb()
.update(media)
.set({
status: "ready",
byteSize: stat.size,
checksum: stat.etag,
})
.where(and(eq(media.id, record.id), eq(media.status, "pending")))
.returning();
return updated
? { status: "ready" as const, media: updated }
: { status: "not-found" as const };
} catch {
return { status: "missing" as const };
}
}
export async function discardUnreferencedMedia(mediaId: string): Promise<boolean> {
const [record] = await getDb().select().from(media).where(eq(media.id, mediaId)).limit(1);
if (
!record ||
record.currentReferenceCount > 0 ||
record.revisionReferenceCount > 0
) {
return false;
}
await getMediaStorage().delete(record.objectKey).catch(() => undefined);
await getDb().delete(media).where(eq(media.id, mediaId));
return true;
}
export async function getReadyMedia(mediaId: string) {
const [record] = await getDb()
.select()
.from(media)
.where(and(eq(media.id, mediaId), eq(media.status, "ready")))
.limit(1);
return record ?? null;
}
+25
View File
@@ -0,0 +1,25 @@
import "server-only";
import { S3Client } from "bun";
let client: S3Client | undefined;
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required for media storage.`);
return value;
}
export function getMediaStorage(): S3Client {
if (!client) {
client = new S3Client({
accessKeyId: required("S3_ACCESS_KEY_ID"),
secretAccessKey: required("S3_SECRET_ACCESS_KEY"),
bucket: required("S3_BUCKET"),
endpoint: required("S3_ENDPOINT"),
region: process.env.S3_REGION || "auto",
virtualHostedStyle: process.env.S3_VIRTUAL_HOSTED_STYLE === "true",
});
}
return client;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
hasValidImageSignature,
MAX_MEDIA_BYTES,
mediaUploadSchema,
parseByteRange,
safeObjectFileName,
} from "./validation";
describe("media validation", () => {
it("accepts only supported images no larger than 20 MB", () => {
expect(mediaUploadSchema.safeParse({ fileName: "a.png", mimeType: "image/png", byteSize: MAX_MEDIA_BYTES }).success).toBe(true);
expect(mediaUploadSchema.safeParse({ fileName: "a.svg", mimeType: "image/svg+xml", byteSize: 10 }).success).toBe(false);
expect(mediaUploadSchema.safeParse({ fileName: "a.png", mimeType: "image/png", byteSize: MAX_MEDIA_BYTES + 1 }).success).toBe(false);
});
it("sanitizes object names without losing Thai labels", () => {
expect(safeObjectFileName("../ภาพ ตัวอย่าง.png")).toBe("ภาพ-ตัวอย่าง.png");
expect(safeObjectFileName("///")).toBe("image");
});
it("checks magic bytes against the declared MIME type", () => {
expect(hasValidImageSignature(Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), "image/png")).toBe(true);
expect(hasValidImageSignature(Uint8Array.from([0xff, 0xd8, 0xff]), "image/jpeg")).toBe(true);
expect(hasValidImageSignature(Uint8Array.from([0xff, 0xd8, 0xff]), "image/png")).toBe(false);
});
it("parses bounded and suffix byte ranges", () => {
expect(parseByteRange("bytes=10-19", 100)).toEqual({ start: 10, end: 19 });
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
expect(parseByteRange("bytes=95-200", 100)).toEqual({ start: 95, end: 99 });
expect(parseByteRange("bytes=200-", 100)).toBeNull();
});
});
+80
View File
@@ -0,0 +1,80 @@
import * as z from "zod";
export const MAX_MEDIA_BYTES = 20 * 1024 * 1024;
export const IMAGE_MIME_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
] as const;
export const mediaUploadSchema = z.strictObject({
fileName: z.string().trim().min(1).max(255),
mimeType: z.enum(IMAGE_MIME_TYPES),
byteSize: z.number().int().positive().max(MAX_MEDIA_BYTES),
});
export type MediaUploadInput = z.output<typeof mediaUploadSchema>;
export function safeObjectFileName(fileName: string): string {
const normalized = fileName.normalize("NFKC").replace(/[\\/\u0000-\u001f\u007f]+/gu, "-");
const cleaned = normalized.replace(/[^\p{L}\p{M}\p{N}._-]+/gu, "-").replace(/-+/gu, "-");
return cleaned.replace(/^[-.]+|[-.]+$/gu, "").slice(0, 120) || "image";
}
function startsWith(bytes: Uint8Array, signature: number[]): boolean {
return signature.every((value, index) => bytes[index] === value);
}
export function hasValidImageSignature(
bytes: Uint8Array,
mimeType: (typeof IMAGE_MIME_TYPES)[number],
): boolean {
if (mimeType === "image/png") {
return startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
}
if (mimeType === "image/jpeg") {
return startsWith(bytes, [0xff, 0xd8, 0xff]);
}
if (mimeType === "image/webp") {
return (
startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
);
}
return (
startsWith(bytes, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
startsWith(bytes, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
);
}
export function parseByteRange(
rangeHeader: string | null,
size: number,
): { start: number; end: number } | null {
if (!rangeHeader) return null;
const match = /^bytes=(\d*)-(\d*)$/u.exec(rangeHeader.trim());
if (!match) return null;
const [, startText, endText] = match;
if (!startText && !endText) return null;
if (!startText) {
const suffix = Number(endText);
if (!Number.isSafeInteger(suffix) || suffix <= 0) return null;
return { start: Math.max(0, size - suffix), end: size - 1 };
}
const start = Number(startText);
const end = endText ? Number(endText) : size - 1;
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
end < start ||
start >= size
) {
return null;
}
return { start, end: Math.min(end, size - 1) };
}