265 lines
8.3 KiB
TypeScript
265 lines
8.3 KiB
TypeScript
import "server-only";
|
|
|
|
import { and, eq, isNull } from "drizzle-orm";
|
|
import { createHash } from "node:crypto";
|
|
|
|
import { getDb } from "@/db";
|
|
import {
|
|
extraSectionRows,
|
|
guides,
|
|
guidePosterBleeding,
|
|
guideSections,
|
|
media,
|
|
} from "@/db/schema";
|
|
import { getMediaStorage, publicMediaUrl } from "@/lib/media/storage";
|
|
import { inspectImage } from "@/lib/media/inspect";
|
|
import {
|
|
hasValidImageSignature,
|
|
MAX_MEDIA_BYTES,
|
|
mediaUploadSchema,
|
|
safeObjectFileName,
|
|
type MediaUploadInput,
|
|
} from "@/lib/media/validation";
|
|
import { writeAuditLog, type AuditActor } from "@/lib/audit-log";
|
|
|
|
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 = (await getMediaStorage()).presign(objectKey, {
|
|
method: "PUT",
|
|
expiresIn,
|
|
type: input.mimeType,
|
|
acl: "private",
|
|
});
|
|
return { id, objectKey, uploadUrl, expiresIn };
|
|
}
|
|
|
|
export async function uploadPublicMedia(
|
|
file: File,
|
|
actor: AuditActor,
|
|
): Promise<{ id: string; url: string; fileName: string; width: number; height: number }> {
|
|
const parsed = mediaUploadSchema.parse({
|
|
fileName: file.name,
|
|
mimeType: file.type,
|
|
byteSize: file.size,
|
|
});
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const { width, height } = await inspectImage(bytes, parsed.mimeType);
|
|
|
|
const id = crypto.randomUUID();
|
|
const objectKey = `uploads/${id}/${safeObjectFileName(parsed.fileName)}`;
|
|
const storage = await getMediaStorage();
|
|
await storage.write(objectKey, bytes, { type: parsed.mimeType, acl: "public-read" });
|
|
try {
|
|
await getDb().transaction(async (tx) => {
|
|
await tx.insert(media).values({
|
|
id,
|
|
objectKey,
|
|
fileName: parsed.fileName,
|
|
mimeType: parsed.mimeType,
|
|
byteSize: parsed.byteSize,
|
|
width,
|
|
height,
|
|
status: "ready",
|
|
currentReferenceCount: 0,
|
|
createdById: actor.id,
|
|
});
|
|
await writeAuditLog(tx, actor, {
|
|
action: "media.uploaded",
|
|
targetType: "media",
|
|
targetId: id,
|
|
metadata: {
|
|
fileName: parsed.fileName,
|
|
mimeType: parsed.mimeType,
|
|
byteSize: parsed.byteSize,
|
|
},
|
|
});
|
|
});
|
|
} catch (cause) {
|
|
await storage.delete(objectKey).catch(() => undefined);
|
|
throw cause;
|
|
}
|
|
return {
|
|
id,
|
|
url: publicMediaUrl(objectKey),
|
|
fileName: parsed.fileName,
|
|
width,
|
|
height,
|
|
};
|
|
}
|
|
|
|
export async function completePendingMedia(mediaId: string, actor: AuditActor) {
|
|
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 storage = await getMediaStorage();
|
|
const file = storage.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 bytes = await file.slice(0, MAX_MEDIA_BYTES + 1).bytes();
|
|
let dimensions;
|
|
try {
|
|
if (bytes.byteLength !== record.byteSize) throw new Error("size mismatch");
|
|
dimensions = await inspectImage(bytes, record.mimeType as Parameters<typeof inspectImage>[1]);
|
|
} catch {
|
|
await file.delete().catch(() => undefined);
|
|
await getDb().update(media).set({ status: "failed" }).where(eq(media.id, record.id));
|
|
return { status: "invalid" as const };
|
|
}
|
|
// A still-valid presigned PUT must not be able to overwrite verified bytes.
|
|
const objectKey = `media/${record.id}/verified/${crypto.randomUUID()}/${safeObjectFileName(record.fileName)}`;
|
|
await storage.write(objectKey, bytes, { type: record.mimeType, acl: "private" });
|
|
let updated: typeof record | undefined;
|
|
try {
|
|
updated = await getDb().transaction(async (tx) => {
|
|
const [ready] = await tx
|
|
.update(media)
|
|
.set({
|
|
status: "ready",
|
|
byteSize: bytes.byteLength,
|
|
checksum: createHash("sha256").update(bytes).digest("hex"),
|
|
objectKey,
|
|
...dimensions,
|
|
})
|
|
.where(and(eq(media.id, record.id), eq(media.status, "pending")))
|
|
.returning();
|
|
if (ready) {
|
|
await writeAuditLog(tx, actor, {
|
|
action: "media.uploaded",
|
|
targetType: "media",
|
|
targetId: ready.id,
|
|
metadata: {
|
|
fileName: ready.fileName,
|
|
mimeType: ready.mimeType,
|
|
byteSize: ready.byteSize,
|
|
},
|
|
});
|
|
}
|
|
return ready;
|
|
});
|
|
} catch (cause) {
|
|
await storage.delete(objectKey).catch(() => undefined);
|
|
throw cause;
|
|
}
|
|
if (!updated) await storage.delete(objectKey).catch(() => undefined);
|
|
else await file.delete().catch(() => undefined);
|
|
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,
|
|
actor: AuditActor,
|
|
): Promise<boolean> {
|
|
// Delete atomically under the FK/reference-count constraints before storage.
|
|
const record = await getDb().transaction(async (tx) => {
|
|
const [deleted] = await tx.delete(media)
|
|
.where(and(eq(media.id, mediaId), eq(media.currentReferenceCount, 0))).returning();
|
|
if (deleted) {
|
|
await writeAuditLog(tx, actor, {
|
|
action: "media.deleted",
|
|
targetType: "media",
|
|
targetId: deleted.id,
|
|
metadata: {
|
|
fileName: deleted.fileName,
|
|
mimeType: deleted.mimeType,
|
|
byteSize: deleted.byteSize,
|
|
},
|
|
});
|
|
}
|
|
return deleted;
|
|
});
|
|
if (!record) return false;
|
|
await (await getMediaStorage()).delete(record.objectKey).catch(() => undefined);
|
|
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;
|
|
}
|
|
|
|
export async function getMediaVisibility(mediaId: string): Promise<{
|
|
public: boolean;
|
|
retained: boolean;
|
|
}> {
|
|
const db = getDb();
|
|
const [covers, posters, bleeding, extras, [record]] = await Promise.all([
|
|
db.select({ id: guides.id }).from(guides).where(and(
|
|
eq(guides.coverMediaId, mediaId),
|
|
eq(guides.isPublic, true),
|
|
isNull(guides.trashedAt),
|
|
)).limit(1),
|
|
db.select({ id: guides.id }).from(guides).where(and(
|
|
eq(guides.posterArtMediaId, mediaId),
|
|
eq(guides.isPublic, true),
|
|
isNull(guides.trashedAt),
|
|
)).limit(1),
|
|
db
|
|
.select({ id: guides.id })
|
|
.from(guidePosterBleeding)
|
|
.innerJoin(guides, eq(guides.id, guidePosterBleeding.guideId))
|
|
.where(and(
|
|
eq(guidePosterBleeding.mediaId, mediaId),
|
|
eq(guides.isPublic, true),
|
|
isNull(guides.trashedAt),
|
|
))
|
|
.limit(1),
|
|
db
|
|
.select({ id: guides.id })
|
|
.from(extraSectionRows)
|
|
.innerJoin(guideSections, eq(guideSections.id, extraSectionRows.sectionId))
|
|
.innerJoin(guides, eq(guides.id, guideSections.guideId))
|
|
.where(and(
|
|
eq(extraSectionRows.mediaId, mediaId),
|
|
eq(guideSections.enabled, true),
|
|
eq(guides.isPublic, true),
|
|
isNull(guides.trashedAt),
|
|
))
|
|
.limit(1),
|
|
db.select({
|
|
currentReferenceCount: media.currentReferenceCount,
|
|
}).from(media).where(eq(media.id, mediaId)).limit(1),
|
|
]);
|
|
return {
|
|
public: Boolean(covers.length || posters.length || bleeding.length || extras.length),
|
|
retained: Boolean(record && record.currentReferenceCount > 0),
|
|
};
|
|
}
|