feat(media): add reference-aware S3 image delivery
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user