diff --git a/app/api/admin/media/[id]/complete/route.ts b/app/api/admin/media/[id]/complete/route.ts new file mode 100644 index 0000000..980c8c7 --- /dev/null +++ b/app/api/admin/media/[id]/complete/route.ts @@ -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 }); +} diff --git a/app/api/admin/media/[id]/route.ts b/app/api/admin/media/[id]/route.ts new file mode 100644 index 0000000..b6cc504 --- /dev/null +++ b/app/api/admin/media/[id]/route.ts @@ -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 }); +} diff --git a/app/api/admin/media/presign/route.ts b/app/api/admin/media/presign/route.ts new file mode 100644 index 0000000..c2f353a --- /dev/null +++ b/app/api/admin/media/presign/route.ts @@ -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 }); + } +} diff --git a/app/media/[id]/route.ts b/app/media/[id]/route.ts new file mode 100644 index 0000000..a588082 --- /dev/null +++ b/app/media/[id]/route.ts @@ -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>; + 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); +} diff --git a/components/admin/editor-inspector.tsx b/components/admin/editor-inspector.tsx index 41f2ed7..d0717bf 100644 --- a/components/admin/editor-inspector.tsx +++ b/components/admin/editor-inspector.tsx @@ -5,6 +5,10 @@ import { useMemo, useState } from "react"; import { PublicBlockRenderer } from "@/components/public/block-renderer"; import { blockLabels } from "@/components/admin/block-canvas"; +import { + MediaUploader, + type UploadedMedia, +} from "@/components/admin/media-uploader"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -60,6 +64,7 @@ export function EditorInspector({ onDataSourceChange, onSaveDataSource, sourceSaving, + onMediaReady, }: { snapshot: PageSnapshot; draft: EditorDraft; @@ -69,6 +74,7 @@ export function EditorInspector({ onDataSourceChange: (source: DataSourceSnapshot) => void; onSaveDataSource: (source: DataSourceSnapshot) => void; sourceSaving: boolean; + onMediaReady: (media: UploadedMedia) => void; }) { const selected = draft.blocks.find((block) => block.id === selectedId); const preview = { @@ -109,6 +115,7 @@ export function EditorInspector({ {blockLabels[selected.type] ?? selected.type}

{selected.id}

+ ความกว้าง { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void upload(file); + }} + /> + + PNG, JPEG, WebP หรือ GIF ขนาดไม่เกิน 20 MB + + ); +} diff --git a/lib/content/queries.ts b/lib/content/queries.ts index 529855e..3e5bbea 100644 --- a/lib/content/queries.ts +++ b/lib/content/queries.ts @@ -8,6 +8,7 @@ import { characters, dataSources, dataSourceVersions, + media, pageDataSources, pages, slugAliases, @@ -307,11 +308,18 @@ export async function getMediaVisibility(mediaId: string): Promise<{ retained: boolean; }> { const db = getDb(); - const referencedBlocks = await db - .select({ block: blocks, page: pages, character: characters }) - .from(blocks) - .innerJoin(pages, eq(pages.id, blocks.pageId)) - .innerJoin(characters, eq(characters.id, pages.characterId)); + const [referencedBlocks, portraitCharacters, [mediaRecord]] = await Promise.all([ + db + .select({ block: blocks, page: pages, character: characters }) + .from(blocks) + .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 => { if (Array.isArray(value)) return value.some(hasMediaId); @@ -325,9 +333,17 @@ export async function getMediaVisibility(mediaId: string): Promise<{ hasMediaId(block.config), ); return { - public: references.some( - ({ page, character }) => page.visible && character.visible, - ), - retained: references.length > 0, + public: + references.some( + ({ page, character }) => page.visible && character.visible, + ) || portraitCharacters.some(({ character }) => character.visible), + retained: + references.length > 0 || + portraitCharacters.length > 0 || + Boolean( + mediaRecord && + (mediaRecord.currentReferenceCount > 0 || + mediaRecord.revisionReferenceCount > 0), + ), }; } diff --git a/lib/media/repository.ts b/lib/media/repository.ts new file mode 100644 index 0000000..b8857e3 --- /dev/null +++ b/lib/media/repository.ts @@ -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[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 { + 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; +} diff --git a/lib/media/storage.ts b/lib/media/storage.ts new file mode 100644 index 0000000..1e97d09 --- /dev/null +++ b/lib/media/storage.ts @@ -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; +} diff --git a/lib/media/validation.test.ts b/lib/media/validation.test.ts new file mode 100644 index 0000000..d10c3a4 --- /dev/null +++ b/lib/media/validation.test.ts @@ -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(); + }); +}); diff --git a/lib/media/validation.ts b/lib/media/validation.ts new file mode 100644 index 0000000..7ba3083 --- /dev/null +++ b/lib/media/validation.ts @@ -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; + +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) }; +} diff --git a/tests/security-boundaries.test.ts b/tests/security-boundaries.test.ts new file mode 100644 index 0000000..6ec89df --- /dev/null +++ b/tests/security-boundaries.test.ts @@ -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"), + ); + }); +});