feat(media): add reference-aware S3 image delivery
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/lib/auth/server";
|
||||||
|
import { completePendingMedia } from "@/lib/media/repository";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
const { id } = await context.params;
|
||||||
|
if (!z.string().uuid().safeParse(id).success) {
|
||||||
|
return Response.json({ error: "invalid-id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const result = await completePendingMedia(id);
|
||||||
|
if (result.status === "ready") {
|
||||||
|
return Response.json({
|
||||||
|
media: {
|
||||||
|
id: result.media.id,
|
||||||
|
fileName: result.media.fileName,
|
||||||
|
mimeType: result.media.mimeType,
|
||||||
|
byteSize: result.media.byteSize,
|
||||||
|
url: `/media/${result.media.id}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const status = result.status === "not-found" ? 404 : result.status === "missing" ? 409 : 422;
|
||||||
|
return Response.json({ error: result.status }, { status });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { requireAdmin } from "@/lib/auth/server";
|
||||||
|
import { discardUnreferencedMedia } from "@/lib/media/repository";
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
const { id } = await context.params;
|
||||||
|
if (!z.string().uuid().safeParse(id).success) {
|
||||||
|
return Response.json({ error: "invalid-id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const deleted = await discardUnreferencedMedia(id);
|
||||||
|
return deleted
|
||||||
|
? new Response(null, { status: 204 })
|
||||||
|
: Response.json({ error: "media-is-referenced-or-missing" }, { status: 409 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { requireAdmin } from "@/lib/auth/server";
|
||||||
|
import { createPendingMedia } from "@/lib/media/repository";
|
||||||
|
import { mediaUploadSchema } from "@/lib/media/validation";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
let input: unknown;
|
||||||
|
try {
|
||||||
|
input = await request.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "invalid-json" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const parsed = mediaUploadSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "invalid-media", issues: parsed.error.issues.map((issue) => issue.message) },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const upload = await createPendingMedia(parsed.data, admin.user.id);
|
||||||
|
return Response.json({
|
||||||
|
mediaId: upload.id,
|
||||||
|
uploadUrl: upload.uploadUrl,
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": parsed.data.mimeType },
|
||||||
|
expiresIn: upload.expiresIn,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "storage-unavailable" }, { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
|
||||||
|
import { getAdminSession } from "@/lib/auth/server";
|
||||||
|
import { getMediaVisibility } from "@/lib/content/queries";
|
||||||
|
import { getReadyMedia } from "@/lib/media/repository";
|
||||||
|
import { getMediaStorage } from "@/lib/media/storage";
|
||||||
|
import { parseByteRange } from "@/lib/media/validation";
|
||||||
|
|
||||||
|
async function serveMedia(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
headOnly: boolean,
|
||||||
|
) {
|
||||||
|
const { id } = await context.params;
|
||||||
|
if (!z.string().uuid().safeParse(id).success) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
const record = await getReadyMedia(id);
|
||||||
|
if (!record) return new Response("Not found", { status: 404 });
|
||||||
|
const visibility = await getMediaVisibility(id);
|
||||||
|
if (!visibility.public && !(await getAdminSession())) {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = getMediaStorage().file(record.objectKey);
|
||||||
|
let stat: Awaited<ReturnType<typeof file.stat>>;
|
||||||
|
try {
|
||||||
|
stat = await file.stat();
|
||||||
|
} catch {
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
const rangeHeader = request.headers.get("range");
|
||||||
|
const range = parseByteRange(rangeHeader, stat.size);
|
||||||
|
if (rangeHeader && !range) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 416,
|
||||||
|
headers: { "Content-Range": `bytes */${stat.size}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const headers = new Headers({
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Type": record.mimeType,
|
||||||
|
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(record.fileName)}`,
|
||||||
|
"Cache-Control": visibility.public
|
||||||
|
? "public, max-age=60, must-revalidate"
|
||||||
|
: "private, no-store",
|
||||||
|
ETag: stat.etag,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
});
|
||||||
|
if (range) {
|
||||||
|
const length = range.end - range.start + 1;
|
||||||
|
headers.set("Content-Length", String(length));
|
||||||
|
headers.set("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
|
||||||
|
return new Response(
|
||||||
|
headOnly ? null : file.slice(range.start, range.end + 1).stream(),
|
||||||
|
{ status: 206, headers },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
headers.set("Content-Length", String(stat.size));
|
||||||
|
return new Response(headOnly ? null : file.stream(), { status: 200, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
return serveMedia(request, context, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function HEAD(
|
||||||
|
request: Request,
|
||||||
|
context: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
return serveMedia(request, context, true);
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import { useMemo, useState } from "react";
|
|||||||
|
|
||||||
import { PublicBlockRenderer } from "@/components/public/block-renderer";
|
import { PublicBlockRenderer } from "@/components/public/block-renderer";
|
||||||
import { blockLabels } from "@/components/admin/block-canvas";
|
import { blockLabels } from "@/components/admin/block-canvas";
|
||||||
|
import {
|
||||||
|
MediaUploader,
|
||||||
|
type UploadedMedia,
|
||||||
|
} from "@/components/admin/media-uploader";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -60,6 +64,7 @@ export function EditorInspector({
|
|||||||
onDataSourceChange,
|
onDataSourceChange,
|
||||||
onSaveDataSource,
|
onSaveDataSource,
|
||||||
sourceSaving,
|
sourceSaving,
|
||||||
|
onMediaReady,
|
||||||
}: {
|
}: {
|
||||||
snapshot: PageSnapshot;
|
snapshot: PageSnapshot;
|
||||||
draft: EditorDraft;
|
draft: EditorDraft;
|
||||||
@@ -69,6 +74,7 @@ export function EditorInspector({
|
|||||||
onDataSourceChange: (source: DataSourceSnapshot) => void;
|
onDataSourceChange: (source: DataSourceSnapshot) => void;
|
||||||
onSaveDataSource: (source: DataSourceSnapshot) => void;
|
onSaveDataSource: (source: DataSourceSnapshot) => void;
|
||||||
sourceSaving: boolean;
|
sourceSaving: boolean;
|
||||||
|
onMediaReady: (media: UploadedMedia) => void;
|
||||||
}) {
|
}) {
|
||||||
const selected = draft.blocks.find((block) => block.id === selectedId);
|
const selected = draft.blocks.find((block) => block.id === selectedId);
|
||||||
const preview = {
|
const preview = {
|
||||||
@@ -109,6 +115,7 @@ export function EditorInspector({
|
|||||||
<Badge variant="outline">{blockLabels[selected.type] ?? selected.type}</Badge>
|
<Badge variant="outline">{blockLabels[selected.type] ?? selected.type}</Badge>
|
||||||
<p className="mt-2 font-mono text-xs text-muted-foreground">{selected.id}</p>
|
<p className="mt-2 font-mono text-xs text-muted-foreground">{selected.id}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<MediaUploader onReady={onMediaReady} />
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel>
|
<FieldLabel htmlFor="block-width">ความกว้าง</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "@/components/admin/admin-sidebar";
|
} from "@/components/admin/admin-sidebar";
|
||||||
import { BlockCanvas } from "@/components/admin/block-canvas";
|
import { BlockCanvas } from "@/components/admin/block-canvas";
|
||||||
import { EditorInspector } from "@/components/admin/editor-inspector";
|
import { EditorInspector } from "@/components/admin/editor-inspector";
|
||||||
|
import type { UploadedMedia } from "@/components/admin/media-uploader";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -173,6 +174,28 @@ export function EditorWorkspace({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addUploadedMedia(media: UploadedMedia) {
|
||||||
|
const block: SerializedBlock = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "image",
|
||||||
|
schemaVersion: 1,
|
||||||
|
config: {
|
||||||
|
mediaId: media.id,
|
||||||
|
alt: media.fileName.replace(/\.[^.]+$/u, ""),
|
||||||
|
fit: "cover",
|
||||||
|
},
|
||||||
|
responsiveLayout: {
|
||||||
|
width: "wide",
|
||||||
|
mobileSpan: 4,
|
||||||
|
tabletSpan: 8,
|
||||||
|
desktopSpan: 12,
|
||||||
|
hideOnMobile: false,
|
||||||
|
},
|
||||||
|
sortOrder: draft.blocks.length,
|
||||||
|
};
|
||||||
|
insert(block);
|
||||||
|
}
|
||||||
|
|
||||||
const inspector = (
|
const inspector = (
|
||||||
<EditorInspector
|
<EditorInspector
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
@@ -183,6 +206,7 @@ export function EditorWorkspace({
|
|||||||
onDataSourceChange={(source) => setDataSources((current) => ({ ...current, [source.id]: source }))}
|
onDataSourceChange={(source) => setDataSources((current) => ({ ...current, [source.id]: source }))}
|
||||||
onSaveDataSource={saveSource}
|
onSaveDataSource={saveSource}
|
||||||
sourceSaving={sourceSaving}
|
sourceSaving={sourceSaving}
|
||||||
|
onMediaReady={addUploadedMedia}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ImagePlusIcon, UploadCloudIcon } from "lucide-react";
|
||||||
|
import { useId, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { IMAGE_MIME_TYPES, MAX_MEDIA_BYTES } from "@/lib/media/validation";
|
||||||
|
|
||||||
|
export interface UploadedMedia {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
byteSize: number;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaUploader({
|
||||||
|
onReady,
|
||||||
|
}: {
|
||||||
|
onReady: (media: UploadedMedia) => void;
|
||||||
|
}) {
|
||||||
|
const inputId = useId();
|
||||||
|
const [state, setState] = useState<"idle" | "preparing" | "uploading" | "checking">("idle");
|
||||||
|
const busy = state !== "idle";
|
||||||
|
|
||||||
|
async function upload(file: File) {
|
||||||
|
if (!IMAGE_MIME_TYPES.includes(file.type as (typeof IMAGE_MIME_TYPES)[number])) {
|
||||||
|
toast.error("รองรับเฉพาะ PNG, JPEG, WebP และ GIF");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size <= 0 || file.size > MAX_MEDIA_BYTES) {
|
||||||
|
toast.error("ไฟล์ต้องมีขนาดไม่เกิน 20 MB");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaId: string | undefined;
|
||||||
|
try {
|
||||||
|
setState("preparing");
|
||||||
|
const presignResponse = await fetch("/api/admin/media/presign", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fileName: file.name, mimeType: file.type, byteSize: file.size }),
|
||||||
|
});
|
||||||
|
if (!presignResponse.ok) throw new Error("presign");
|
||||||
|
const upload = await presignResponse.json() as {
|
||||||
|
mediaId: string;
|
||||||
|
uploadUrl: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
};
|
||||||
|
mediaId = upload.mediaId;
|
||||||
|
|
||||||
|
setState("uploading");
|
||||||
|
const putResponse = await fetch(upload.uploadUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: upload.headers,
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
if (!putResponse.ok) throw new Error("upload");
|
||||||
|
|
||||||
|
setState("checking");
|
||||||
|
const completeResponse = await fetch(`/api/admin/media/${mediaId}/complete`, { method: "POST" });
|
||||||
|
if (!completeResponse.ok) throw new Error("complete");
|
||||||
|
const result = await completeResponse.json() as { media: UploadedMedia };
|
||||||
|
onReady(result.media);
|
||||||
|
toast.success("อัปโหลดรูปภาพแล้ว");
|
||||||
|
} catch {
|
||||||
|
if (mediaId) {
|
||||||
|
await fetch(`/api/admin/media/${mediaId}`, { method: "DELETE" }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
toast.error("อัปโหลดไม่สำเร็จ ไฟล์ชั่วคราวถูกเก็บกวาดแล้ว");
|
||||||
|
} finally {
|
||||||
|
setState("idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={inputId}>คลังสื่อ</FieldLabel>
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="file"
|
||||||
|
className="sr-only"
|
||||||
|
accept={IMAGE_MIME_TYPES.join(",")}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (file) void upload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
disabled={busy}
|
||||||
|
render={<label htmlFor={inputId} />}
|
||||||
|
>
|
||||||
|
{busy ? <Spinner /> : <ImagePlusIcon />}
|
||||||
|
{state === "preparing" && "กำลังเตรียม…"}
|
||||||
|
{state === "uploading" && "กำลังอัปโหลด…"}
|
||||||
|
{state === "checking" && "กำลังตรวจสอบไฟล์…"}
|
||||||
|
{state === "idle" && "อัปโหลดและเพิ่มรูปภาพ"}
|
||||||
|
{!busy ? <UploadCloudIcon className="ml-auto" /> : null}
|
||||||
|
</Button>
|
||||||
|
<FieldDescription>PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 20 MB</FieldDescription>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
+25
-9
@@ -8,6 +8,7 @@ import {
|
|||||||
characters,
|
characters,
|
||||||
dataSources,
|
dataSources,
|
||||||
dataSourceVersions,
|
dataSourceVersions,
|
||||||
|
media,
|
||||||
pageDataSources,
|
pageDataSources,
|
||||||
pages,
|
pages,
|
||||||
slugAliases,
|
slugAliases,
|
||||||
@@ -307,11 +308,18 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
|||||||
retained: boolean;
|
retained: boolean;
|
||||||
}> {
|
}> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const referencedBlocks = await db
|
const [referencedBlocks, portraitCharacters, [mediaRecord]] = await Promise.all([
|
||||||
.select({ block: blocks, page: pages, character: characters })
|
db
|
||||||
.from(blocks)
|
.select({ block: blocks, page: pages, character: characters })
|
||||||
.innerJoin(pages, eq(pages.id, blocks.pageId))
|
.from(blocks)
|
||||||
.innerJoin(characters, eq(characters.id, pages.characterId));
|
.innerJoin(pages, eq(pages.id, blocks.pageId))
|
||||||
|
.innerJoin(characters, eq(characters.id, pages.characterId)),
|
||||||
|
db
|
||||||
|
.select({ character: characters })
|
||||||
|
.from(characters)
|
||||||
|
.where(eq(characters.portraitMediaId, mediaId)),
|
||||||
|
db.select().from(media).where(eq(media.id, mediaId)).limit(1),
|
||||||
|
]);
|
||||||
|
|
||||||
const hasMediaId = (value: unknown): boolean => {
|
const hasMediaId = (value: unknown): boolean => {
|
||||||
if (Array.isArray(value)) return value.some(hasMediaId);
|
if (Array.isArray(value)) return value.some(hasMediaId);
|
||||||
@@ -325,9 +333,17 @@ export async function getMediaVisibility(mediaId: string): Promise<{
|
|||||||
hasMediaId(block.config),
|
hasMediaId(block.config),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
public: references.some(
|
public:
|
||||||
({ page, character }) => page.visible && character.visible,
|
references.some(
|
||||||
),
|
({ page, character }) => page.visible && character.visible,
|
||||||
retained: references.length > 0,
|
) || portraitCharacters.some(({ character }) => character.visible),
|
||||||
|
retained:
|
||||||
|
references.length > 0 ||
|
||||||
|
portraitCharacters.length > 0 ||
|
||||||
|
Boolean(
|
||||||
|
mediaRecord &&
|
||||||
|
(mediaRecord.currentReferenceCount > 0 ||
|
||||||
|
mediaRecord.revisionReferenceCount > 0),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const protectedEntryPoints = [
|
||||||
|
"app/admin/actions.ts",
|
||||||
|
"app/api/admin/media/presign/route.ts",
|
||||||
|
"app/api/admin/media/[id]/complete/route.ts",
|
||||||
|
"app/api/admin/media/[id]/route.ts",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
describe("administrative security boundaries", () => {
|
||||||
|
for (const path of protectedEntryPoints) {
|
||||||
|
it(`${path} repeats server-side admin authorization`, async () => {
|
||||||
|
const source = await Bun.file(new URL(`../${path}`, import.meta.url)).text();
|
||||||
|
expect(source).toContain("requireAdmin");
|
||||||
|
expect(source).toMatch(/await requireAdmin\(\)/u);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("same-origin media checks visibility before falling back to admin auth", async () => {
|
||||||
|
const source = await Bun.file(
|
||||||
|
new URL("../app/media/[id]/route.ts", import.meta.url),
|
||||||
|
).text();
|
||||||
|
expect(source).toContain("getMediaVisibility");
|
||||||
|
expect(source).toContain("getAdminSession");
|
||||||
|
expect(source.indexOf("getMediaVisibility")).toBeLessThan(
|
||||||
|
source.lastIndexOf("getAdminSession"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user