import { TRPCError } from "@trpc/server"; import { desc, eq, or } from "drizzle-orm"; import matter from "gray-matter"; import { UTApi, UTFile } from "uploadthing/server"; import z from "zod"; import { isAdmin } from "~/app/actions"; import { env } from "~/env.js"; import { db } from "~/server/db"; import { blogPost } from "~/server/dbschema/schema"; import { publicProcedure, router } from "~/server/trpc"; const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN }); const blogPostInput = z.object({ slug: z.string().min(1), title: z.string().min(1), date: z.string().optional(), description: z.string().optional(), tags: z.array(z.string()).optional(), content: z.string(), }); type BlogPostInput = z.infer; type UploadThingFile = Awaited>["files"][number]; function cleanPrefix(): string { return env.BLOG_MDX_PREFIX.trim().replace(/^\/+|\/+$/g, ""); } function blogFileName(slug: string): string { const prefix = cleanPrefix(); return prefix ? `${prefix}-${slug}.mdx` : `${slug}.mdx`; } function blogCustomId(slug: string): string { const prefix = cleanPrefix(); const id = crypto.randomUUID(); return prefix ? `${prefix}:${slug}:${id}` : `${slug}:${id}`; } function optionalText(value: string | undefined): string | null { const trimmed = value?.trim(); return trimmed ? trimmed : null; } function frontmatterText(value: unknown): string | null { if (value instanceof Date) return value.toISOString().slice(0, 10); return optionalText(typeof value === "string" ? value : undefined); } function normalizeTags(tags: unknown): string[] { const values = Array.isArray(tags) ? tags : typeof tags === "string" ? tags.split(",") : []; return Array.from( new Set( values .map((tag) => String(tag).trim()) .filter(Boolean), ), ); } function createMdxContent(input: BlogPostInput): string { const date = optionalText(input.date); const description = optionalText(input.description); const tags = normalizeTags(input.tags); const frontmatter: Record = { slug: input.slug, title: input.title }; if (date) frontmatter.date = date; if (description) frontmatter.description = description; if (tags.length > 0) frontmatter.tags = tags; return matter.stringify(input.content, frontmatter); } function summaryFromInput(input: BlogPostInput) { return { slug: input.slug, title: input.title, date: optionalText(input.date), description: optionalText(input.description), tags: normalizeTags(input.tags), }; } async function assertAdmin() { const admin = await isAdmin(); if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" }); } async function uploadMdx(input: BlogPostInput) { const mdxContent = createMdxContent(input); const customId = blogCustomId(input.slug); const file = new UTFile([mdxContent], blogFileName(input.slug), { customId, type: "text/plain", }); const result = await utapi.uploadFiles(file); if (result.error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message }); } if (!result.data.ufsUrl) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "UploadThing did not return a file URL" }); } return { fileKey: result.data.key, fileUrl: result.data.ufsUrl, fileName: result.data.name, customId: result.data.customId ?? customId, }; } async function fetchMdx(fileUrl: string): Promise { const res = await fetch(fileUrl, { next: { revalidate: 3600 } }); if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" }); return res.text(); } function fileUrl(file: Pick): string { return `https://utfs.io/f/${file.key}`; } function slugFromFileName(name: string): string { const prefix = cleanPrefix(); const withoutExtension = name.replace(/\.mdx?$/, ""); if (prefix && withoutExtension.startsWith(`${prefix}-`)) return withoutExtension.slice(prefix.length + 1); if (prefix && withoutExtension.startsWith(`${prefix}/`)) return withoutExtension.slice(prefix.length + 1); return withoutExtension; } function slugFromCustomId(customId: string): string { const prefix = cleanPrefix(); const value = prefix && customId.startsWith(`${prefix}:`) ? customId.slice(prefix.length + 1) : customId; return value.split(":")[0] ?? value; } function fileMatchesPrefix(file: Pick): boolean { if (!/\.mdx?$/.test(file.name)) return false; const prefix = cleanPrefix(); if (!prefix) return true; return file.name.startsWith(`${prefix}-`) || file.name.startsWith(`${prefix}/`); } function metadataFromFile(file: UploadThingFile, raw: string) { const parsed = matter(raw); const fallbackSlug = file.customId ? slugFromCustomId(file.customId) : slugFromFileName(file.name); return { slug: String(parsed.data.slug ?? fallbackSlug), title: String(parsed.data.title ?? fallbackSlug), date: frontmatterText(parsed.data.date), description: frontmatterText(parsed.data.description), tags: normalizeTags(parsed.data.tags), fileKey: file.key, fileUrl: fileUrl(file), fileName: file.name, customId: file.customId ?? blogCustomId(fallbackSlug), }; } async function listAllFiles(): Promise { const files: UploadThingFile[] = []; let offset = 0; const limit = 500; let hasMore = true; while (hasMore) { const page = await utapi.listFiles({ limit, offset }); files.push(...page.files); hasMore = page.hasMore; offset += limit; } return files; } export const blogRouter = router({ insert: publicProcedure .input(blogPostInput) .mutation(async ({ input }) => { await assertAdmin(); const existing = await db.query.blogPost.findFirst({ where(fields, operators) { return operators.eq(fields.slug, input.slug); }, }); if (existing) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` }); const uploaded = await uploadMdx(input); try { await db.insert(blogPost).values({ slug: input.slug, title: input.title, date: optionalText(input.date), description: optionalText(input.description), tags: normalizeTags(input.tags), ...uploaded, }); return [summaryFromInput(input)]; } catch (error) { await utapi.deleteFiles(uploaded.fileKey); throw error; } }), update: publicProcedure .input(blogPostInput.extend({ originalSlug: z.string().min(1) })) .mutation(async ({ input }) => { await assertAdmin(); const existing = await db.query.blogPost.findFirst({ where(fields, operators) { return operators.eq(fields.slug, input.originalSlug); }, }); if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.originalSlug}" not found` }); if (input.slug !== input.originalSlug) { const slugConflict = await db.query.blogPost.findFirst({ where(fields, operators) { return operators.eq(fields.slug, input.slug); }, }); if (slugConflict) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` }); } const uploaded = await uploadMdx(input); try { await db.update(blogPost).set({ slug: input.slug, title: input.title, date: optionalText(input.date), description: optionalText(input.description), tags: normalizeTags(input.tags), ...uploaded, }).where(eq(blogPost.id, existing.id)); await utapi.deleteFiles(existing.fileKey); return [summaryFromInput(input)]; } catch (error) { await utapi.deleteFiles(uploaded.fileKey); throw error; } }), delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { await assertAdmin(); const post = await db.query.blogPost.findFirst({ where(fields, operators) { return operators.eq(fields.slug, input.id); }, }); if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` }); await db.delete(blogPost).where(eq(blogPost.id, post.id)); await utapi.deleteFiles(post.fileKey); return []; }), list: publicProcedure.query(async () => { return db.select({ slug: blogPost.slug, title: blogPost.title, date: blogPost.date, description: blogPost.description, tags: blogPost.tags, }).from(blogPost).orderBy(desc(blogPost.date), desc(blogPost.createdAt)); }), bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => { const post = await db.query.blogPost.findFirst({ where(fields, operators) { return operators.eq(fields.slug, slug); }, }); if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` }); const raw = await fetchMdx(post.fileUrl); const { content, data } = matter(raw); return { slug: post.slug, content, title: (data.title as string | undefined) ?? post.title, date: frontmatterText(data.date) ?? post.date, description: frontmatterText(data.description) ?? post.description, tags: normalizeTags(data.tags).length > 0 ? normalizeTags(data.tags) : (post.tags ?? []), }; }), syncFromUploadThing: publicProcedure.mutation(async () => { await assertAdmin(); const files = (await listAllFiles()).filter(fileMatchesPrefix); const seenFileKeys = new Set(); const seenSlugs = new Set(); let created = 0; let updated = 0; let skipped = 0; let deleted = 0; for (const file of files) { try { const raw = await fetchMdx(fileUrl(file)); const metadata = metadataFromFile(file, raw); seenFileKeys.add(file.key); seenSlugs.add(metadata.slug); const existing = await db.query.blogPost.findFirst({ where(fields, operators) { return or(operators.eq(fields.fileKey, file.key), operators.eq(fields.slug, metadata.slug)); }, }); if (existing) { await db.update(blogPost).set(metadata).where(eq(blogPost.id, existing.id)); updated += 1; } else { await db.insert(blogPost).values(metadata); created += 1; } } catch { skipped += 1; } } const posts = await db.select({ id: blogPost.id, fileKey: blogPost.fileKey, slug: blogPost.slug, }).from(blogPost); const stalePostIds = posts .filter((post) => !seenFileKeys.has(post.fileKey) && !seenSlugs.has(post.slug)) .map((post) => post.id); for (const id of stalePostIds) { await db.delete(blogPost).where(eq(blogPost.id, id)); deleted += 1; } return { created, updated, skipped, deleted }; }), });