diff --git a/docs/cms-hardening-refactor-plan.md b/docs/cms-hardening-refactor-plan.md new file mode 100644 index 0000000..d012858 --- /dev/null +++ b/docs/cms-hardening-refactor-plan.md @@ -0,0 +1,83 @@ +# CMS Hardening + Refactor Plan + +Date: 2026-04-13 +Branch: `codex/cms-hardening-refactor` +Status: in progress + +## Goals +- Make CMS stable under real editing load. +- Prevent invalid content writes and path abuse. +- Reduce maintenance cost by splitting monolithic admin code. +- Improve content loading and rendering behavior for speed and reliability. + +## Scope +1. CMS reliability and data safety +2. Code refactor and architecture cleanup +3. Performance and content loading optimizations + +## Work Plan + +### Phase 1 (P0): CMS Contract and Write Safety +- Add strict server-side validation for case payloads. +- Enforce path allowlist for save/upload endpoints. +- Centralize GitHub write calls with retry, timeout, and normalized errors. +- Add conflict-aware behavior (`sha` based updates). + +Done when: +- Invalid payloads cannot be committed through API. +- Invalid paths are rejected deterministically. +- API error codes are stable and user-facing errors are clear. + +### Phase 2 (P0/P1): Admin Decomposition +- Split `src/app/admin/page.tsx` into composable editors. +- Extract data and side-effect logic into hooks. +- Keep behavior parity, then simplify. + +Done when: +- Admin page no longer has monolithic business logic. +- Component boundaries are explicit and testable. + +### Phase 3 (P0): Rendering Correctness +- Remove unsafe fallback for unknown case slugs. +- Return proper 404 (`notFound`) for missing slugs. +- Keep ISR behavior predictable for published content. + +Done when: +- Unknown slug does not silently render unrelated case. + +### Phase 4 (P1): Content Loading and Performance +- Move repeated content read logic to shared loader helpers. +- Remove sync I/O from hot paths where possible. +- Optimize media rendering strategy and preserve mobile behavior. + +Done when: +- Content loading path is centralized and measurable. +- No performance regression on homepage/case pages. + +## Test Strategy + +### Unit +- Case payload validation (facts, sections, block discriminants). +- Path sanitization and allowlist guards. +- GitHub error mapping and retry policy behavior. + +### API Integration +- `POST /api/save-content`: success, validation fail, conflict, rate limit. +- `POST /api/upload-image`: success, mime/type/path/size failures. +- `GET /api/cases` and `GET /api/cases/[slug]`: valid + not found. + +### E2E (Playwright, planned) +- Open admin, edit case, save, confirm success state. +- Upload media image to block and verify path update. +- Simulate concurrent edits and verify conflict messaging. + +### Regression +- Existing Jest suite must remain green. +- Add smoke tests for case page rendering by slug. +- Verify `/work/[slug]` 404 behavior. + +## Risk Controls +- Draft PR only until P0 hardening tests pass. +- Keep changes incremental in small commits. +- No force-push on shared branches. +- No direct edits on `main`. diff --git a/src/app/admin/lib/__tests__/case-editor.test.ts b/src/app/admin/lib/__tests__/case-editor.test.ts new file mode 100644 index 0000000..8a71c6d --- /dev/null +++ b/src/app/admin/lib/__tests__/case-editor.test.ts @@ -0,0 +1,102 @@ +import { + addBlock, + addFact, + addSection, + createUploadPath, + moveBlock, + removeBlock, + removeFact, + toPublicPath, + updateBlockValue, + updateCaseField, + updateFactField, + updateSectionField, + validateCaseForSave, +} from "@/app/admin/lib/case-editor"; +import type { CaseStudy } from "@/app/admin/types"; + +const buildCase = (): CaseStudy => ({ + slug: "demo-case", + title: "Demo Case", + subtitle: "Subtitle", + coverSrc: "/cases/demo/cover.png", + coverAlt: "Cover alt", + facts: [{ label: "role", value: "Product Designer" }], + sections: [ + { + title: "Context", + blocks: [ + { discriminant: "paragraph", value: { text: "A" } }, + { discriminant: "paragraph", value: { text: "B" } }, + ], + }, + ], +}); + +describe("case-editor", () => { + it("updates top-level field", () => { + const updated = updateCaseField(buildCase(), "title", "Updated"); + expect(updated.title).toBe("Updated"); + }); + + it("adds and removes facts", () => { + const withFact = addFact(buildCase()); + expect(withFact.facts).toHaveLength(2); + + const removed = removeFact(withFact, 1); + expect(removed.facts).toHaveLength(1); + }); + + it("updates fact field", () => { + const updated = updateFactField(buildCase(), 0, "label", "scope"); + expect(updated.facts[0].label).toBe("scope"); + }); + + it("adds and updates sections", () => { + const withSection = addSection(buildCase()); + expect(withSection.sections).toHaveLength(2); + + const updated = updateSectionField(withSection, 1, "title", "Outcome"); + expect(updated.sections[1].title).toBe("Outcome"); + }); + + it("updates, adds, removes and moves blocks", () => { + const updated = updateBlockValue(buildCase(), 0, 0, { text: "Updated" }); + expect(updated.sections[0].blocks[0].value.text).toBe("Updated"); + + const withBlock = addBlock(updated, 0, "media"); + expect(withBlock.sections[0].blocks).toHaveLength(3); + + const moved = moveBlock(withBlock, 0, 0, 1); + expect(moved.sections[0].blocks[1].value.text).toBe("Updated"); + + const removed = removeBlock(moved, 0, 2); + expect(removed.sections[0].blocks).toHaveLength(2); + }); + + it("does not mutate input when adding or removing blocks", () => { + const original = buildCase(); + const originalBlockCount = original.sections[0].blocks.length; + + const withBlock = addBlock(original, 0, "list"); + expect(withBlock.sections[0].blocks).toHaveLength(originalBlockCount + 1); + expect(original.sections[0].blocks).toHaveLength(originalBlockCount); + + const removed = removeBlock(withBlock, 0, 0); + expect(removed.sections[0].blocks).toHaveLength(originalBlockCount); + expect(withBlock.sections[0].blocks).toHaveLength(originalBlockCount + 1); + }); + + it("builds upload and public paths", () => { + const uploadPath = createUploadPath("demo-case", "image.webp", 123456); + expect(uploadPath).toBe("public/cases/demo-case/123456.webp"); + expect(toPublicPath(uploadPath)).toBe("/cases/demo-case/123456.webp"); + }); + + it("validates required case fields", () => { + expect(validateCaseForSave(buildCase())).toBeNull(); + + const invalid = { ...buildCase(), title: "" }; + expect(validateCaseForSave(invalid)).toBe("Title is required"); + }); +}); diff --git a/src/app/admin/lib/case-editor.ts b/src/app/admin/lib/case-editor.ts new file mode 100644 index 0000000..6f617c3 --- /dev/null +++ b/src/app/admin/lib/case-editor.ts @@ -0,0 +1,144 @@ +import type { Block, CaseStudy, Fact, SeoData, Section } from "../types"; + +export const defaultBlockValue: Record = { + paragraph: { text: "" }, + list: { items: [""] }, + link: { label: "", href: "" }, + media: { src: "", alt: "", caption: "", variant: "diagram" }, +}; + +export const updateCaseField = ( + data: CaseStudy, + field: K, + value: CaseStudy[K], +): CaseStudy => ({ ...data, [field]: value }); + +export const updateFactField = ( + data: CaseStudy, + index: number, + field: keyof Fact, + value: string | string[], +): CaseStudy => { + const facts = [...data.facts]; + facts[index] = { ...facts[index], [field]: value }; + return updateCaseField(data, "facts", facts); +}; + +export const addFact = (data: CaseStudy): CaseStudy => + updateCaseField(data, "facts", [...data.facts, { label: "", value: "" }]); + +export const removeFact = (data: CaseStudy, index: number): CaseStudy => + updateCaseField( + data, + "facts", + data.facts.filter((_, i) => i !== index), + ); + +export const updateSeoField = ( + data: CaseStudy, + field: keyof SeoData, + value: string, +): CaseStudy => updateCaseField(data, "seo", { ...(data.seo || {}), [field]: value }); + +export const updateSectionField = ( + data: CaseStudy, + sectionIndex: number, + field: keyof Section, + value: string, +): CaseStudy => { + const sections = [...data.sections]; + sections[sectionIndex] = { ...sections[sectionIndex], [field]: value }; + return updateCaseField(data, "sections", sections); +}; + +export const addSection = (data: CaseStudy): CaseStudy => + updateCaseField(data, "sections", [ + ...data.sections, + { title: "", blocks: [{ discriminant: "paragraph", value: { text: "" } }] }, + ]); + +export const removeSection = (data: CaseStudy, index: number): CaseStudy => + updateCaseField( + data, + "sections", + data.sections.filter((_, i) => i !== index), + ); + +export const updateBlockValue = ( + data: CaseStudy, + sectionIndex: number, + blockIndex: number, + value: Partial, +): CaseStudy => { + const sections = [...data.sections]; + const blocks = [...sections[sectionIndex].blocks]; + blocks[blockIndex] = { + ...blocks[blockIndex], + value: { ...blocks[blockIndex].value, ...value }, + }; + sections[sectionIndex] = { ...sections[sectionIndex], blocks }; + return updateCaseField(data, "sections", sections); +}; + +export const addBlock = ( + data: CaseStudy, + sectionIndex: number, + type: Block["discriminant"], +): CaseStudy => { + const sections = [...data.sections]; + const blocks = [...sections[sectionIndex].blocks, { discriminant: type, value: defaultBlockValue[type] }]; + sections[sectionIndex] = { ...sections[sectionIndex], blocks }; + return updateCaseField(data, "sections", sections); +}; + +export const removeBlock = ( + data: CaseStudy, + sectionIndex: number, + blockIndex: number, +): CaseStudy => { + const sections = [...data.sections]; + const blocks = sections[sectionIndex].blocks.filter((_, i) => i !== blockIndex); + sections[sectionIndex] = { ...sections[sectionIndex], blocks }; + return updateCaseField(data, "sections", sections); +}; + +export const moveBlock = ( + data: CaseStudy, + sectionIndex: number, + fromIndex: number, + toIndex: number, +): CaseStudy => { + if (fromIndex === toIndex) return data; + + const sections = [...data.sections]; + const blocks = [...sections[sectionIndex].blocks]; + const [moved] = blocks.splice(fromIndex, 1); + blocks.splice(toIndex, 0, moved); + sections[sectionIndex] = { ...sections[sectionIndex], blocks }; + return updateCaseField(data, "sections", sections); +}; + +export const createUploadPath = ( + slug: string, + fileName: string, + now = Date.now(), +) => { + const ext = fileName.split(".").pop() || "png"; + return `public/cases/${slug}/${now}.${ext}`; +}; + +export const toPublicPath = (path: string) => path.replace(/^public/, ""); + +export const validateCaseForSave = (data: CaseStudy): string | null => { + if (!data.title.trim()) return "Title is required"; + if (!data.slug.trim()) return "Slug is required"; + if (!data.coverAlt.trim()) return "Cover alt text is required"; + + const emptyFact = data.facts.find((fact) => !fact.label.trim()); + if (emptyFact) return "All fact labels must be filled"; + + const emptySection = data.sections.find((section) => !section.title.trim()); + if (emptySection) return "All section titles must be filled"; + + return null; +}; diff --git a/src/app/admin/lib/cms-client.ts b/src/app/admin/lib/cms-client.ts new file mode 100644 index 0000000..b4a6492 --- /dev/null +++ b/src/app/admin/lib/cms-client.ts @@ -0,0 +1,81 @@ +import type { CaseInfo, CaseStudy } from "../types"; + +type SaveContentResponse = { + success?: boolean; + error?: string; + code?: string; +}; + +type UploadResponse = { + success?: boolean; + error?: string; + code?: string; + path?: string; + url?: string; +}; + +const parseError = (data: unknown, fallback: string) => + typeof data === "object" && + data !== null && + "error" in data && + typeof data.error === "string" + ? data.error + : fallback; + +export const fetchCasesList = async (): Promise => { + const response = await fetch("/api/cases", { cache: "no-store" }); + if (!response.ok) { + throw new Error("Failed to load cases"); + } + return (await response.json()) as CaseInfo[]; +}; + +export const fetchCaseData = async (slug: string): Promise => { + const response = await fetch(`/api/cases/${slug}`, { cache: "no-store" }); + if (!response.ok) { + throw new Error("Failed to load case content"); + } + return (await response.json()) as CaseStudy; +}; + +export const saveCaseContent = async (params: { + slug: string; + content: CaseStudy; + message?: string; +}) => { + const path = `src/content/cases/${params.slug}.json`; + const response = await fetch("/api/save-content", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path, + content: params.content, + message: params.message ?? `Update ${params.slug} case`, + }), + }); + + const data = (await response.json()) as SaveContentResponse; + if (!response.ok) { + throw new Error(parseError(data, "Failed to save")); + } + + return data; +}; + +export const uploadCaseImage = async (params: { file: File; path: string }) => { + const formData = new FormData(); + formData.append("file", params.file); + formData.append("path", params.path); + + const response = await fetch("/api/upload-image", { + method: "POST", + body: formData, + }); + + const data = (await response.json()) as UploadResponse; + if (!response.ok) { + throw new Error(parseError(data, "Upload failed")); + } + + return data; +}; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 351cf6c..967aa41 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,52 +1,30 @@ "use client"; import { useState, useEffect } from "react"; - -interface Fact { - label: string; - value: string | string[]; -} - -interface Block { - discriminant: "paragraph" | "list" | "link" | "media"; - value: { - text?: string; - items?: string[]; - label?: string; - href?: string; - src?: string; - alt?: string; - caption?: string; - variant?: "phone" | "desktop" | "diagram"; - }; -} - -interface Section { - title: string; - blocks: Block[]; -} - -interface SeoData { - metaTitle?: string; - metaDescription?: string; - ogImage?: string; -} - -interface CaseStudy { - slug: string; - title: string; - subtitle: string; - coverSrc: string; - coverAlt: string; - facts: Fact[]; - sections: Section[]; - seo?: SeoData; -} - -interface CaseInfo { - slug: string; - title: string; -} +import { + addBlock as addCaseBlock, + addFact as addCaseFact, + addSection as addCaseSection, + createUploadPath, + moveBlock as moveCaseBlock, + removeBlock as removeCaseBlock, + removeFact as removeCaseFact, + removeSection as removeCaseSection, + toPublicPath, + updateBlockValue, + updateCaseField, + updateFactField, + updateSectionField, + updateSeoField, + validateCaseForSave, +} from "./lib/case-editor"; +import { + fetchCaseData, + fetchCasesList, + saveCaseContent, + uploadCaseImage, +} from "./lib/cms-client"; +import type { Block, CaseInfo, CaseStudy, Fact, SeoData, Section } from "./types"; export default function AdminPage() { const [cases, setCases] = useState([]); @@ -61,11 +39,14 @@ export default function AdminPage() { const [imageCaption, setImageCaption] = useState(""); const [draggedBlock, setDraggedBlock] = useState<{sectionIndex: number, blockIndex: number} | null>(null); + const updateCase = (updater: (current: CaseStudy) => CaseStudy) => { + setCaseData((current) => (current ? updater(current) : current)); + }; + // Load list of cases useEffect(() => { - fetch("/api/cases", { cache: "no-store" }) - .then((r) => r.json()) - .then((data: CaseInfo[]) => { + fetchCasesList() + .then((data) => { setCases(data); if (data.length > 0) { setSelectedCase(data[0].slug); @@ -81,9 +62,8 @@ export default function AdminPage() { // Load selected case content useEffect(() => { if (!selectedCase) return; - fetch(`/api/cases/${selectedCase}`, { cache: "no-store" }) - .then((r) => r.json()) - .then((data: CaseStudy) => { + fetchCaseData(selectedCase) + .then((data) => { setCaseData(data); }) .catch(() => { @@ -92,50 +72,25 @@ export default function AdminPage() { }, [selectedCase]); const updateField = (field: K, value: CaseStudy[K]) => { - if (!caseData) return; - setCaseData({ ...caseData, [field]: value }); + updateCase((current) => updateCaseField(current, field, value)); }; const updateFact = (index: number, field: keyof Fact, value: string | string[]) => { - if (!caseData) return; - const newFacts = [...caseData.facts]; - newFacts[index] = { ...newFacts[index], [field]: value }; - updateField("facts", newFacts); + updateCase((current) => updateFactField(current, index, field, value)); }; const updateSeo = (field: keyof SeoData, value: string) => { - if (!caseData) return; - const newSeo = { ...(caseData.seo || {}), [field]: value }; - updateField("seo", newSeo); + updateCase((current) => updateSeoField(current, field, value)); }; - const addFact = () => { - if (!caseData) return; - updateField("facts", [...caseData.facts, { label: "", value: "" }]); - }; - - const removeFact = (index: number) => { - if (!caseData) return; - updateField("facts", caseData.facts.filter((_, i) => i !== index)); - }; + const addFact = () => updateCase((current) => addCaseFact(current)); - const validateCase = (data: CaseStudy): string | null => { - if (!data.title.trim()) return "Title is required"; - if (!data.slug.trim()) return "Slug is required"; - if (!data.coverAlt.trim()) return "Cover alt text is required"; - // Check for empty fact labels - const emptyFact = data.facts.find(f => !f.label.trim()); - if (emptyFact) return "All fact labels must be filled"; - // Check for empty section titles - const emptySection = data.sections.find(s => !s.title.trim()); - if (emptySection) return "All section titles must be filled"; - return null; - }; + const removeFact = (index: number) => updateCase((current) => removeCaseFact(current, index)); const handleSave = async () => { if (!caseData) return; - const error = validateCase(caseData); + const error = validateCaseForSave(caseData); if (error) { setMessage(`❌ ${error}`); return; @@ -144,24 +99,14 @@ export default function AdminPage() { setSaving(true); setMessage(""); - const path = `src/content/cases/${selectedCase}.json`; - - const response = await fetch("/api/save-content", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path, + try { + await saveCaseContent({ + slug: selectedCase, content: caseData, - message: `Update ${selectedCase} case`, - }), - }); - - const result = await response.json(); - - if (response.ok) { + }); setMessage("✅ Saved! Changes will deploy in ~1 minute."); - } else { - setMessage(`❌ Error: ${result.error}`); + } catch (error) { + setMessage(`❌ Error: ${error instanceof Error ? error.message : "Failed to save"}`); } setSaving(false); }; @@ -171,29 +116,20 @@ export default function AdminPage() { setUploading(true); setMessage(""); - const ext = selectedFile.name.split(".").pop(); - const path = `public/cases/${selectedCase}/${Date.now()}.${ext}`; - - const formData = new FormData(); - formData.append("file", selectedFile); - formData.append("path", path); + const path = createUploadPath(selectedCase, selectedFile.name); - const response = await fetch("/api/upload-image", { - method: "POST", - body: formData, - }); - - const result = await response.json(); - - if (response.ok) { + try { + await uploadCaseImage({ + file: selectedFile, + path, + }); // Update coverSrc with new path (relative to public) - const publicPath = path.replace(/^public/, ""); - updateField("coverSrc", publicPath); + updateField("coverSrc", toPublicPath(path)); setMessage("✅ Image uploaded!"); setSelectedFile(null); setImageCaption(""); - } else { - setMessage(`❌ Upload failed: ${result.error}`); + } catch (error) { + setMessage(`❌ Upload failed: ${error instanceof Error ? error.message : "Upload failed"}`); } setUploading(false); }; @@ -207,88 +143,45 @@ export default function AdminPage() { if (!caseData || !file) return; setMessage(""); - const ext = file.name.split(".").pop(); - const path = `public/cases/${selectedCase}/${Date.now()}.${ext}`; - - const formData = new FormData(); - formData.append("file", file); - formData.append("path", path); - - const response = await fetch("/api/upload-image", { - method: "POST", - body: formData, - }); + const path = createUploadPath(selectedCase, file.name); - const result = await response.json(); - - if (response.ok) { - const publicPath = path.replace(/^public/, ""); - updateBlock(sectionIndex, blockIndex, { src: publicPath }); + try { + await uploadCaseImage({ + file, + path, + }); + updateBlock(sectionIndex, blockIndex, { src: toPublicPath(path) }); setMessage("✅ Image uploaded to media block!"); - } else { - setMessage(`❌ Upload failed: ${result.error}`); + } catch (error) { + setMessage(`❌ Upload failed: ${error instanceof Error ? error.message : "Upload failed"}`); } }; // Section management const updateSection = (sectionIndex: number, field: keyof Section, value: string) => { - if (!caseData) return; - const newSections = [...caseData.sections]; - newSections[sectionIndex] = { ...newSections[sectionIndex], [field]: value }; - updateField("sections", newSections); + updateCase((current) => updateSectionField(current, sectionIndex, field, value)); }; - const addSection = () => { - if (!caseData) return; - updateField("sections", [ - ...caseData.sections, - { title: "", blocks: [{ discriminant: "paragraph", value: { text: "" } }] }, - ]); - }; + const addSection = () => updateCase((current) => addCaseSection(current)); - const removeSection = (index: number) => { - if (!caseData) return; - updateField("sections", caseData.sections.filter((_, i) => i !== index)); - }; + const removeSection = (index: number) => updateCase((current) => removeCaseSection(current, index)); // Block management const updateBlock = (sectionIndex: number, blockIndex: number, value: Partial) => { - if (!caseData) return; - const newSections = [...caseData.sections]; - const newBlocks = [...newSections[sectionIndex].blocks]; - newBlocks[blockIndex] = { ...newBlocks[blockIndex], value: { ...newBlocks[blockIndex].value, ...value } }; - newSections[sectionIndex] = { ...newSections[sectionIndex], blocks: newBlocks }; - updateField("sections", newSections); + updateCase((current) => updateBlockValue(current, sectionIndex, blockIndex, value)); }; const addBlock = (sectionIndex: number, type: Block["discriminant"]) => { - if (!caseData) return; - const newSections = [...caseData.sections]; - const defaultValue: Record = { - paragraph: { text: "" }, - list: { items: [""] }, - link: { label: "", href: "" }, - media: { src: "", alt: "", caption: "", variant: "diagram" }, - }; - newSections[sectionIndex].blocks.push({ discriminant: type, value: defaultValue[type] }); - updateField("sections", newSections); + updateCase((current) => addCaseBlock(current, sectionIndex, type)); }; const removeBlock = (sectionIndex: number, blockIndex: number) => { - if (!caseData) return; - const newSections = [...caseData.sections]; - newSections[sectionIndex].blocks = newSections[sectionIndex].blocks.filter((_, i) => i !== blockIndex); - updateField("sections", newSections); + updateCase((current) => removeCaseBlock(current, sectionIndex, blockIndex)); }; const moveBlock = (sectionIndex: number, fromIndex: number, toIndex: number) => { - if (!caseData || fromIndex === toIndex) return; - const newSections = [...caseData.sections]; - const blocks = [...newSections[sectionIndex].blocks]; - const [movedBlock] = blocks.splice(fromIndex, 1); - blocks.splice(toIndex, 0, movedBlock); - newSections[sectionIndex] = { ...newSections[sectionIndex], blocks }; - updateField("sections", newSections); + if (fromIndex === toIndex) return; + updateCase((current) => moveCaseBlock(current, sectionIndex, fromIndex, toIndex)); }; const inputStyle = { diff --git a/src/app/admin/types.ts b/src/app/admin/types.ts new file mode 100644 index 0000000..20047e9 --- /dev/null +++ b/src/app/admin/types.ts @@ -0,0 +1,45 @@ +export interface Fact { + label: string; + value: string | string[]; +} + +export interface Block { + discriminant: "paragraph" | "list" | "link" | "media"; + value: { + text?: string; + items?: string[]; + label?: string; + href?: string; + src?: string; + alt?: string; + caption?: string; + variant?: "phone" | "desktop" | "diagram"; + }; +} + +export interface Section { + title: string; + blocks: Block[]; +} + +export interface SeoData { + metaTitle?: string; + metaDescription?: string; + ogImage?: string; +} + +export interface CaseStudy { + slug: string; + title: string; + subtitle: string; + coverSrc: string; + coverAlt: string; + facts: Fact[]; + sections: Section[]; + seo?: SeoData; +} + +export interface CaseInfo { + slug: string; + title: string; +} diff --git a/src/app/api/cases/[slug]/route.ts b/src/app/api/cases/[slug]/route.ts index 28b13c2..ff79139 100644 --- a/src/app/api/cases/[slug]/route.ts +++ b/src/app/api/cases/[slug]/route.ts @@ -1,20 +1,17 @@ import { NextResponse } from "next/server"; -import fs from "node:fs"; -import path from "node:path"; +import { isCaseSlug, readCaseBySlug } from "@/lib/content/case-files"; export async function GET( - request: Request, + _request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; - - // Read directly from disk to avoid cache issues - const casesDirectory = path.join(process.cwd(), "src", "content", "cases"); - const filePath = path.join(casesDirectory, `${slug}.json`); + if (!isCaseSlug(slug)) { + return NextResponse.json({ error: "Invalid case slug" }, { status: 400 }); + } try { - const raw = fs.readFileSync(filePath, "utf-8"); - const caseStudy = JSON.parse(raw); + const caseStudy = await readCaseBySlug(slug); return NextResponse.json(caseStudy); } catch { return NextResponse.json({ error: "Case not found" }, { status: 404 }); diff --git a/src/app/api/cases/route.ts b/src/app/api/cases/route.ts index 12bfef6..a386ec6 100644 --- a/src/app/api/cases/route.ts +++ b/src/app/api/cases/route.ts @@ -1,21 +1,14 @@ import { NextResponse } from "next/server"; -import fs from "node:fs"; -import path from "node:path"; +import { readCaseSummaries } from "@/lib/content/case-files"; export async function GET() { - // Read directly from disk to avoid cache issues - const casesDirectory = path.join(process.cwd(), "src", "content", "cases"); - const files = fs.readdirSync(casesDirectory).filter((f) => f.endsWith(".json")); - - const caseList = files.map((file) => { - const raw = fs.readFileSync(path.join(casesDirectory, file), "utf-8"); - const data = JSON.parse(raw); - const slug = file.replace(".json", ""); - return { - slug: typeof data.slug === "string" ? data.slug : slug, - title: data.title || slug, - }; - }); - - return NextResponse.json(caseList); + try { + const caseList = await readCaseSummaries(); + return NextResponse.json(caseList); + } catch { + return NextResponse.json( + { error: "Failed to read case summaries" }, + { status: 500 }, + ); + } } diff --git a/src/app/api/save-content/__tests__/route.test.ts b/src/app/api/save-content/__tests__/route.test.ts new file mode 100644 index 0000000..dfc05fa --- /dev/null +++ b/src/app/api/save-content/__tests__/route.test.ts @@ -0,0 +1,142 @@ +/** @jest-environment node */ + +jest.mock("@/lib/cms/github", () => ({ + getRepositoryFileMeta: jest.fn(), + putRepositoryFile: jest.fn(), +})); + +import { + getRepositoryFileMeta, + putRepositoryFile, +} from "@/lib/cms/github"; + +const mockedGetRepositoryFileMeta = getRepositoryFileMeta as jest.MockedFunction; +const mockedPutRepositoryFile = putRepositoryFile as jest.MockedFunction; + +const loadPost = async () => { + const routeModule = await import("@/app/api/save-content/route"); + return (routeModule.POST || + (routeModule as { default?: { POST?: typeof routeModule.POST } }).default?.POST)!; +}; + +describe("POST /api/save-content", () => { + beforeEach(() => { + process.env.GITHUB_PAT = "test-token"; + mockedGetRepositoryFileMeta.mockReset(); + mockedPutRepositoryFile.mockReset(); + }); + + const buildRequest = (body: unknown) => + new Request("http://localhost/api/save-content", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + it("returns 400 when path is not allowed", async () => { + const POST = await loadPost(); + const response = await POST( + buildRequest({ + path: "src/content/home.json", + content: {}, + }) as never, + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe("path_not_allowed"); + }); + + it("returns 400 when payload and path slugs mismatch", async () => { + const POST = await loadPost(); + const response = await POST( + buildRequest({ + path: "src/content/cases/case-a.json", + content: { + slug: "case-b", + title: "Case", + subtitle: "Sub", + coverSrc: "/cases/case-b/cover.png", + coverAlt: "Cover", + facts: [{ label: "role", value: "Designer" }], + sections: [{ title: "Context", blocks: [] }], + }, + }) as never, + ); + + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe("validation_error"); + }); + + it("returns 200 when payload is valid and github write succeeds", async () => { + const POST = await loadPost(); + mockedGetRepositoryFileMeta.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + data: { sha: "abc123" }, + }); + mockedPutRepositoryFile.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + data: {}, + }); + + const response = await POST( + buildRequest({ + path: "src/content/cases/case-a.json", + content: { + slug: "case-a", + title: "Case", + subtitle: "Sub", + coverSrc: "/cases/case-a/cover.png", + coverAlt: "Cover", + facts: [{ label: "role", value: "Designer" }], + sections: [{ title: "Context", blocks: [] }], + }, + }) as never, + ); + + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.success).toBe(true); + expect(mockedPutRepositoryFile).toHaveBeenCalledTimes(1); + }); + + it("maps github conflict to conflict code", async () => { + const POST = await loadPost(); + mockedGetRepositoryFileMeta.mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers(), + message: "Not found", + }); + mockedPutRepositoryFile.mockResolvedValue({ + ok: false, + status: 409, + headers: new Headers(), + message: "Conflict", + }); + + const response = await POST( + buildRequest({ + path: "src/content/cases/case-a.json", + content: { + slug: "case-a", + title: "Case", + subtitle: "Sub", + coverSrc: "/cases/case-a/cover.png", + coverAlt: "Cover", + facts: [{ label: "role", value: "Designer" }], + sections: [{ title: "Context", blocks: [] }], + }, + }) as never, + ); + + expect(response.status).toBe(409); + const json = await response.json(); + expect(json.code).toBe("github_conflict"); + }); +}); diff --git a/src/app/api/save-content/route.ts b/src/app/api/save-content/route.ts index ad0bad7..5ab7c7b 100644 --- a/src/app/api/save-content/route.ts +++ b/src/app/api/save-content/route.ts @@ -1,4 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import { cmsErrorResponse } from "@/lib/cms/errors"; +import { getRepositoryFileMeta, putRepositoryFile } from "@/lib/cms/github"; +import { validateCaseContentPath, validateCaseStudyPayload } from "@/lib/cms/validation"; const GITHUB_TOKEN = process.env.GITHUB_PAT; const GITHUB_REPO = process.env.GITHUB_REPO || "Ultraivanov/portfolio"; @@ -6,71 +9,86 @@ const GITHUB_BRANCH = process.env.GITHUB_BRANCH || "main"; export async function POST(request: NextRequest) { if (!GITHUB_TOKEN) { - return NextResponse.json( - { error: "GitHub PAT not configured" }, - { status: 500 } + return cmsErrorResponse( + 500, + "github_not_configured", + "GitHub PAT not configured", ); } try { - const { path, content, message } = await request.json(); + const payload = await request.json(); + const pathValidation = validateCaseContentPath(payload.path); + if (!pathValidation.ok) { + return cmsErrorResponse(400, "path_not_allowed", pathValidation.error, pathValidation.details); + } + + const caseValidation = validateCaseStudyPayload(payload.content); + if (!caseValidation.ok) { + return cmsErrorResponse(400, "validation_error", caseValidation.error, caseValidation.details); + } - if (!path || !content) { - return NextResponse.json( - { error: "Missing path or content" }, - { status: 400 } - ); + if (pathValidation.data.slug !== caseValidation.data.slug) { + return cmsErrorResponse(400, "validation_error", "Path slug and payload slug mismatch", { + pathSlug: pathValidation.data.slug, + payloadSlug: caseValidation.data.slug, + }); } // Get current file SHA (if exists) - const getResponse = await fetch( - `https://api.github.com/repos/${GITHUB_REPO}/contents/${path}?ref=${GITHUB_BRANCH}`, - { - headers: { - Authorization: `Bearer ${GITHUB_TOKEN}`, - Accept: "application/vnd.github+json", - }, - } - ); + const getResponse = await getRepositoryFileMeta({ + repo: GITHUB_REPO, + branch: GITHUB_BRANCH, + path: pathValidation.data.path, + token: GITHUB_TOKEN, + }); let sha: string | undefined; - if (getResponse.status === 200) { - const fileData = await getResponse.json(); - sha = fileData.sha; + if (getResponse.ok) { + sha = getResponse.data.sha; + } else if (getResponse.status !== 404) { + const code = getResponse.status === 429 ? "github_rate_limited" : "github_error"; + return cmsErrorResponse(getResponse.status || 502, code, getResponse.message); } // Update or create file - const updateResponse = await fetch( - `https://api.github.com/repos/${GITHUB_REPO}/contents/${path}`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${GITHUB_TOKEN}`, - Accept: "application/vnd.github+json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - message: message || `Update ${path} via CMS`, - content: Buffer.from(JSON.stringify(content, null, 2)).toString("base64"), - branch: GITHUB_BRANCH, - sha, - }), - } - ); + const updateResponse = await putRepositoryFile({ + repo: GITHUB_REPO, + branch: GITHUB_BRANCH, + path: pathValidation.data.path, + token: GITHUB_TOKEN, + message: + typeof payload.message === "string" && payload.message.trim().length > 0 + ? payload.message + : `Update ${pathValidation.data.path} via CMS`, + base64Content: Buffer.from( + JSON.stringify(caseValidation.data, null, 2), + ).toString("base64"), + sha, + }); if (!updateResponse.ok) { - const error = await updateResponse.json(); - return NextResponse.json( - { error: error.message || "Failed to save" }, - { status: updateResponse.status } - ); + const code = + updateResponse.status === 409 || updateResponse.status === 422 + ? "github_conflict" + : updateResponse.status === 429 + ? "github_rate_limited" + : "github_error"; + + const status = updateResponse.status || 502; + return cmsErrorResponse(status, code, updateResponse.message); } - return NextResponse.json({ success: true }); + return NextResponse.json({ + success: true, + slug: caseValidation.data.slug, + path: pathValidation.data.path, + }); } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } + return cmsErrorResponse( + 500, + "internal_error", + error instanceof Error ? error.message : "Unknown error", ); } } diff --git a/src/app/api/upload-image/__tests__/route.test.ts b/src/app/api/upload-image/__tests__/route.test.ts new file mode 100644 index 0000000..bd5719d --- /dev/null +++ b/src/app/api/upload-image/__tests__/route.test.ts @@ -0,0 +1,84 @@ +/** @jest-environment node */ + +jest.mock("@/lib/cms/github", () => ({ + getRepositoryFileMeta: jest.fn(), + putRepositoryFile: jest.fn(), +})); + +import { + getRepositoryFileMeta, + putRepositoryFile, +} from "@/lib/cms/github"; + +const mockedGetRepositoryFileMeta = getRepositoryFileMeta as jest.MockedFunction; +const mockedPutRepositoryFile = putRepositoryFile as jest.MockedFunction; + +const loadPost = async () => { + const routeModule = await import("@/app/api/upload-image/route"); + return (routeModule.POST || + (routeModule as { default?: { POST?: typeof routeModule.POST } }).default?.POST)!; +}; + +describe("POST /api/upload-image", () => { + beforeEach(() => { + process.env.GITHUB_PAT = "test-token"; + mockedGetRepositoryFileMeta.mockReset(); + mockedPutRepositoryFile.mockReset(); + }); + + const buildFormRequest = (formData: FormData) => + new Request("http://localhost/api/upload-image", { + method: "POST", + body: formData, + }); + + it("returns 400 when path is invalid", async () => { + const POST = await loadPost(); + const form = new FormData(); + form.append("file", new File(["img"], "test.png", { type: "image/png" })); + form.append("path", "public/other/test.png"); + + const response = await POST(buildFormRequest(form) as never); + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe("path_not_allowed"); + }); + + it("returns 400 when file is not image", async () => { + const POST = await loadPost(); + const form = new FormData(); + form.append("file", new File(["x"], "test.txt", { type: "text/plain" })); + form.append("path", "public/cases/case-a/test.txt"); + + const response = await POST(buildFormRequest(form) as never); + expect(response.status).toBe(400); + const json = await response.json(); + expect(json.code).toBe("validation_error"); + }); + + it("returns 200 when upload succeeds", async () => { + const POST = await loadPost(); + mockedGetRepositoryFileMeta.mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers(), + message: "Not found", + }); + mockedPutRepositoryFile.mockResolvedValue({ + ok: true, + status: 201, + headers: new Headers(), + data: { content: { download_url: "https://example.com/file.png" } }, + }); + + const form = new FormData(); + form.append("file", new File(["img"], "test.png", { type: "image/png" })); + form.append("path", "public/cases/case-a/test.png"); + + const response = await POST(buildFormRequest(form) as never); + expect(response.status).toBe(200); + const json = await response.json(); + expect(json.success).toBe(true); + expect(json.path).toBe("public/cases/case-a/test.png"); + }); +}); diff --git a/src/app/api/upload-image/route.ts b/src/app/api/upload-image/route.ts index 7a0ea9d..28c60a6 100644 --- a/src/app/api/upload-image/route.ts +++ b/src/app/api/upload-image/route.ts @@ -1,88 +1,102 @@ import { NextRequest, NextResponse } from "next/server"; +import { cmsErrorResponse } from "@/lib/cms/errors"; +import { getRepositoryFileMeta, putRepositoryFile } from "@/lib/cms/github"; +import { validateUploadPath } from "@/lib/cms/validation"; const GITHUB_TOKEN = process.env.GITHUB_PAT; const GITHUB_REPO = process.env.GITHUB_REPO || "Ultraivanov/portfolio"; const GITHUB_BRANCH = process.env.GITHUB_BRANCH || "main"; +const MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024; export async function POST(request: NextRequest) { if (!GITHUB_TOKEN) { - return NextResponse.json( - { error: "GitHub PAT not configured" }, - { status: 500 } + return cmsErrorResponse( + 500, + "github_not_configured", + "GitHub PAT not configured", ); } try { const formData = await request.formData(); - const file = formData.get("file") as File; - const path = formData.get("path") as string; + const rawFile = formData.get("file"); + const rawPath = formData.get("path"); - if (!file || !path) { - return NextResponse.json( - { error: "Missing file or path" }, - { status: 400 } - ); + if (!(rawFile instanceof File)) { + return cmsErrorResponse(400, "validation_error", "Missing file"); + } + + if (rawFile.size > MAX_UPLOAD_SIZE_BYTES) { + return cmsErrorResponse(400, "validation_error", "File size exceeds 10MB limit"); + } + + if (!rawFile.type.startsWith("image/")) { + return cmsErrorResponse(400, "validation_error", "Only image uploads are allowed"); + } + + const pathValidation = validateUploadPath(rawPath); + if (!pathValidation.ok) { + return cmsErrorResponse(400, "path_not_allowed", pathValidation.error, pathValidation.details); } // Convert file to base64 - const bytes = await file.arrayBuffer(); + const bytes = await rawFile.arrayBuffer(); const base64Content = Buffer.from(bytes).toString("base64"); // Check if file exists - const getResponse = await fetch( - `https://api.github.com/repos/${GITHUB_REPO}/contents/${path}?ref=${GITHUB_BRANCH}`, - { - headers: { - Authorization: `Bearer ${GITHUB_TOKEN}`, - Accept: "application/vnd.github+json", - }, - } - ); + const getResponse = await getRepositoryFileMeta({ + repo: GITHUB_REPO, + branch: GITHUB_BRANCH, + path: pathValidation.data.path, + token: GITHUB_TOKEN, + }); let sha: string | undefined; - if (getResponse.status === 200) { - const fileData = await getResponse.json(); - sha = fileData.sha; + if (getResponse.ok) { + sha = getResponse.data.sha; + } else if (getResponse.status !== 404) { + const code = getResponse.status === 429 ? "github_rate_limited" : "github_error"; + return cmsErrorResponse(getResponse.status || 502, code, getResponse.message); } // Upload to GitHub - const updateResponse = await fetch( - `https://api.github.com/repos/${GITHUB_REPO}/contents/${path}`, - { - method: "PUT", - headers: { - Authorization: `Bearer ${GITHUB_TOKEN}`, - Accept: "application/vnd.github+json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - message: `Upload ${path} via CMS`, - content: base64Content, - branch: GITHUB_BRANCH, - sha, - }), - } - ); + const updateResponse = await putRepositoryFile({ + repo: GITHUB_REPO, + branch: GITHUB_BRANCH, + path: pathValidation.data.path, + token: GITHUB_TOKEN, + message: `Upload ${pathValidation.data.path} via CMS`, + base64Content, + sha, + }); if (!updateResponse.ok) { - const error = await updateResponse.json(); - return NextResponse.json( - { error: error.message || "Failed to upload" }, - { status: updateResponse.status } - ); + const code = + updateResponse.status === 409 || updateResponse.status === 422 + ? "github_conflict" + : updateResponse.status === 429 + ? "github_rate_limited" + : "github_error"; + return cmsErrorResponse(updateResponse.status || 502, code, updateResponse.message); } - const result = await updateResponse.json(); + const responseBody = + typeof updateResponse.data === "object" && updateResponse.data !== null + ? (updateResponse.data as { + content?: { download_url?: string; url?: string }; + }) + : {}; return NextResponse.json({ success: true, - url: result.content?.download_url || result.content?.url, - path: path, + url: responseBody.content?.download_url || responseBody.content?.url, + path: pathValidation.data.path, }); } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : "Unknown error" }, - { status: 500 } + return cmsErrorResponse( + 500, + "internal_error", + error instanceof Error ? error.message : "Unknown error", ); } } diff --git a/src/app/work/[slug]/page.tsx b/src/app/work/[slug]/page.tsx index 37bc94d..c6a9eda 100644 --- a/src/app/work/[slug]/page.tsx +++ b/src/app/work/[slug]/page.tsx @@ -4,6 +4,7 @@ import CaseFacts from "@/components/case/CaseFacts"; import CaseMedia from "@/components/case/CaseMedia"; import CaseSection from "@/components/case/CaseSection"; import { cases, getCaseBySlug } from "@/content/cases"; +import { notFound } from "next/navigation"; type CasePageProps = { params: Promise<{ slug: string }>; @@ -11,7 +12,10 @@ type CasePageProps = { export default async function CasePage({ params }: CasePageProps) { const { slug } = await params; - const caseStudy = getCaseBySlug(slug) ?? cases[0]; + const caseStudy = getCaseBySlug(slug); + if (!caseStudy) { + notFound(); + } return (
diff --git a/src/content/__tests__/cases.test.ts b/src/content/__tests__/cases.test.ts new file mode 100644 index 0000000..dfeb935 --- /dev/null +++ b/src/content/__tests__/cases.test.ts @@ -0,0 +1,12 @@ +import { cases, getCaseBySlug } from "@/content/cases"; + +describe("cases content loader", () => { + it("returns undefined for unknown slug", () => { + expect(getCaseBySlug("does-not-exist")).toBeUndefined(); + }); + + it("keeps expected preferred order on top", () => { + expect(cases[0]?.slug).toBe("travel-booking-platform"); + expect(cases[1]?.slug).toBe("railway-booking-flow"); + }); +}); diff --git a/src/lib/cms/__tests__/validation.test.ts b/src/lib/cms/__tests__/validation.test.ts new file mode 100644 index 0000000..bd5b750 --- /dev/null +++ b/src/lib/cms/__tests__/validation.test.ts @@ -0,0 +1,106 @@ +import { + validateCaseContentPath, + validateCaseStudyPayload, + validateUploadPath, +} from "@/lib/cms/validation"; + +describe("CMS validation", () => { + describe("validateCaseContentPath", () => { + it("accepts allowed case path", () => { + const result = validateCaseContentPath("src/content/cases/railway-booking-flow.json"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.slug).toBe("railway-booking-flow"); + } + }); + + it("rejects path outside case content", () => { + const result = validateCaseContentPath("src/content/home.json"); + expect(result.ok).toBe(false); + }); + }); + + describe("validateUploadPath", () => { + it("accepts allowed image upload path", () => { + const result = validateUploadPath("public/cases/megamod/screen-01.png"); + expect(result.ok).toBe(true); + }); + + it("rejects disallowed upload extension", () => { + const result = validateUploadPath("public/cases/megamod/payload.exe"); + expect(result.ok).toBe(false); + }); + + it("rejects traversal-like upload path", () => { + const result = validateUploadPath("public/cases/megamod/../secret.png"); + expect(result.ok).toBe(false); + }); + }); + + describe("validateCaseStudyPayload", () => { + const validCase = { + slug: "test-case", + title: "Test case", + subtitle: "Subtitle", + coverSrc: "/cases/test/cover.png", + coverAlt: "Cover alt", + facts: [{ label: "role", value: "Product Designer" }], + sections: [ + { + title: "Context", + blocks: [ + { + discriminant: "paragraph", + value: { text: "Body" }, + }, + ], + }, + ], + }; + + it("accepts valid case payload", () => { + const result = validateCaseStudyPayload(validCase); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.slug).toBe("test-case"); + } + }); + + it("accepts object slug and normalizes it", () => { + const result = validateCaseStudyPayload({ + ...validCase, + slug: { name: "Test", slug: "object-slug" }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.slug).toBe("object-slug"); + } + }); + + it("rejects fact with empty label", () => { + const result = validateCaseStudyPayload({ + ...validCase, + facts: [{ label: " ", value: "x" }], + }); + expect(result.ok).toBe(false); + }); + + it("rejects invalid block discriminant", () => { + const result = validateCaseStudyPayload({ + ...validCase, + sections: [ + { + title: "Context", + blocks: [ + { + discriminant: "unknown", + value: {}, + }, + ], + }, + ], + }); + expect(result.ok).toBe(false); + }); + }); +}); diff --git a/src/lib/cms/errors.ts b/src/lib/cms/errors.ts new file mode 100644 index 0000000..558a16e --- /dev/null +++ b/src/lib/cms/errors.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; + +export type CmsErrorCode = + | "validation_error" + | "path_not_allowed" + | "github_not_configured" + | "github_conflict" + | "github_rate_limited" + | "github_error" + | "internal_error"; + +type ErrorPayload = { + error: string; + code: CmsErrorCode; + details?: Record; +}; + +export const cmsErrorResponse = ( + status: number, + code: CmsErrorCode, + error: string, + details?: Record, +) => NextResponse.json({ error, code, details }, { status }); diff --git a/src/lib/cms/github.ts b/src/lib/cms/github.ts new file mode 100644 index 0000000..6f1e2d2 --- /dev/null +++ b/src/lib/cms/github.ts @@ -0,0 +1,149 @@ +const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]); + +export type GitHubFileMeta = { + sha: string; +}; + +type GitHubResponse = + | { ok: true; status: number; data: T; headers: Headers } + | { ok: false; status: number; message: string; headers: Headers }; + +const parseResponseBody = async (response: Response): Promise => { + try { + return await response.json(); + } catch { + return null; + } +}; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const requestGitHub = async ( + url: string, + init: RequestInit, + { timeoutMs = 15000, retries = 2 }: { timeoutMs?: number; retries?: number } = {}, +): Promise> => { + let attempt = 0; + let lastResponse: GitHubResponse | null = null; + + while (attempt <= retries) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + clearTimeout(timeoutId); + + const body = await parseResponseBody(response); + + if (response.ok) { + return { + ok: true, + status: response.status, + data: (body ?? {}) as T, + headers: response.headers, + }; + } + + const message = + typeof body === "object" && + body !== null && + "message" in body && + typeof body.message === "string" + ? body.message + : `GitHub request failed (${response.status})`; + + const failedResponse: GitHubResponse = { + ok: false, + status: response.status, + message, + headers: response.headers, + }; + lastResponse = failedResponse; + + if (!RETRYABLE_STATUSES.has(response.status) || attempt === retries) { + return failedResponse; + } + + const retryAfterHeader = response.headers.get("retry-after"); + const retryAfterMs = retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) * 1000 : 0; + const backoffMs = Math.max(retryAfterMs, 200 * 2 ** attempt); + await wait(backoffMs); + attempt += 1; + continue; + } catch (error) { + clearTimeout(timeoutId); + const isAbort = error instanceof Error && error.name === "AbortError"; + const message = isAbort ? "GitHub request timed out" : "GitHub request failed"; + + if (attempt === retries) { + return { + ok: false, + status: 0, + message, + headers: new Headers(), + }; + } + + await wait(200 * 2 ** attempt); + attempt += 1; + } + } + + return ( + lastResponse ?? { + ok: false, + status: 0, + message: "GitHub request failed", + headers: new Headers(), + } + ); +}; + +const authHeaders = (token: string) => ({ + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", +}); + +export const getRepositoryFileMeta = async (params: { + repo: string; + branch: string; + path: string; + token: string; +}) => + requestGitHub( + `https://api.github.com/repos/${params.repo}/contents/${params.path}?ref=${params.branch}`, + { + method: "GET", + headers: authHeaders(params.token), + }, + ); + +export const putRepositoryFile = async (params: { + repo: string; + branch: string; + path: string; + token: string; + message: string; + base64Content: string; + sha?: string; +}) => + requestGitHub( + `https://api.github.com/repos/${params.repo}/contents/${params.path}`, + { + method: "PUT", + headers: { + ...authHeaders(params.token), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message: params.message, + content: params.base64Content, + branch: params.branch, + sha: params.sha, + }), + }, + ); diff --git a/src/lib/cms/validation.ts b/src/lib/cms/validation.ts new file mode 100644 index 0000000..6e99859 --- /dev/null +++ b/src/lib/cms/validation.ts @@ -0,0 +1,387 @@ +const CASE_PATH_PATTERN = /^src\/content\/cases\/([a-z0-9-]+)\.json$/; +const UPLOAD_PATH_PATTERN = /^public\/cases\/([a-z0-9-]+)\/([A-Za-z0-9._-]+)$/; +const ALLOWED_IMAGE_EXTENSIONS = new Set([ + "png", + "jpg", + "jpeg", + "webp", + "svg", + "gif", + "avif", +]); + +type ValidationSuccess = { ok: true; data: T }; +type ValidationFailure = { ok: false; error: string; details?: Record }; +type ValidationResult = ValidationSuccess | ValidationFailure; + +export type FactPayload = { + label: string; + value: string | string[]; + href?: string; +}; + +export type CaseBlockPayload = { + discriminant: "paragraph" | "list" | "link" | "media"; + value: { + text?: string; + items?: string[]; + label?: string; + href?: string; + src?: string; + alt?: string; + caption?: string; + variant?: "phone" | "desktop" | "diagram"; + }; +}; + +export type CaseSectionPayload = { + title: string; + blocks: CaseBlockPayload[]; +}; + +export type CaseStudyPayload = { + slug: string; + title: string; + subtitle: string; + coverSrc: string; + coverAlt: string; + facts: FactPayload[]; + sections: CaseSectionPayload[]; + seo?: { + metaTitle?: string; + metaDescription?: string; + ogImage?: string; + }; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isString = (value: unknown): value is string => typeof value === "string"; + +const isNonEmptyString = (value: unknown): value is string => + isString(value) && value.trim().length > 0; + +const validateFact = (value: unknown, index: number): ValidationResult => { + if (!isRecord(value)) { + return { ok: false, error: "Invalid fact object", details: { index } }; + } + + if (!isNonEmptyString(value.label)) { + return { + ok: false, + error: "Fact label is required", + details: { index, field: "label" }, + }; + } + + const factValue = value.value; + const valueIsValid = + isString(factValue) || + (Array.isArray(factValue) && factValue.every((item) => isString(item))); + + if (!valueIsValid) { + return { + ok: false, + error: "Fact value must be string or string[]", + details: { index, field: "value" }, + }; + } + + if (value.href !== undefined && !isString(value.href)) { + return { + ok: false, + error: "Fact href must be a string", + details: { index, field: "href" }, + }; + } + + return { + ok: true, + data: { + label: value.label, + value: factValue, + href: value.href as string | undefined, + }, + }; +}; + +const validateBlock = (value: unknown, sectionIndex: number, blockIndex: number): ValidationResult => { + if (!isRecord(value) || !isRecord(value.value)) { + return { + ok: false, + error: "Invalid block object", + details: { sectionIndex, blockIndex }, + }; + } + + const discriminant = value.discriminant; + if ( + discriminant !== "paragraph" && + discriminant !== "list" && + discriminant !== "link" && + discriminant !== "media" + ) { + return { + ok: false, + error: "Block discriminant is invalid", + details: { sectionIndex, blockIndex, discriminant }, + }; + } + + const blockValue = value.value; + + if (discriminant === "paragraph" && blockValue.text !== undefined && !isString(blockValue.text)) { + return { + ok: false, + error: "Paragraph block text must be string", + details: { sectionIndex, blockIndex, field: "value.text" }, + }; + } + + if (discriminant === "list") { + const items = blockValue.items; + if (items !== undefined && (!Array.isArray(items) || !items.every((item) => isString(item)))) { + return { + ok: false, + error: "List block items must be string[]", + details: { sectionIndex, blockIndex, field: "value.items" }, + }; + } + } + + if (discriminant === "link") { + if (blockValue.label !== undefined && !isString(blockValue.label)) { + return { + ok: false, + error: "Link block label must be string", + details: { sectionIndex, blockIndex, field: "value.label" }, + }; + } + if (blockValue.href !== undefined && !isString(blockValue.href)) { + return { + ok: false, + error: "Link block href must be string", + details: { sectionIndex, blockIndex, field: "value.href" }, + }; + } + } + + if (discriminant === "media") { + if (blockValue.src !== undefined && !isString(blockValue.src)) { + return { + ok: false, + error: "Media block src must be string", + details: { sectionIndex, blockIndex, field: "value.src" }, + }; + } + if (blockValue.alt !== undefined && !isString(blockValue.alt)) { + return { + ok: false, + error: "Media block alt must be string", + details: { sectionIndex, blockIndex, field: "value.alt" }, + }; + } + if (blockValue.caption !== undefined && !isString(blockValue.caption)) { + return { + ok: false, + error: "Media block caption must be string", + details: { sectionIndex, blockIndex, field: "value.caption" }, + }; + } + if ( + blockValue.variant !== undefined && + blockValue.variant !== "phone" && + blockValue.variant !== "desktop" && + blockValue.variant !== "diagram" + ) { + return { + ok: false, + error: "Media block variant is invalid", + details: { sectionIndex, blockIndex, field: "value.variant" }, + }; + } + } + + return { + ok: true, + data: { + discriminant, + value: { + text: blockValue.text as string | undefined, + items: blockValue.items as string[] | undefined, + label: blockValue.label as string | undefined, + href: blockValue.href as string | undefined, + src: blockValue.src as string | undefined, + alt: blockValue.alt as string | undefined, + caption: blockValue.caption as string | undefined, + variant: blockValue.variant as "phone" | "desktop" | "diagram" | undefined, + }, + }, + }; +}; + +const validateSections = (value: unknown): ValidationResult => { + if (!Array.isArray(value)) { + return { ok: false, error: "Sections must be an array", details: { field: "sections" } }; + } + + const sections: CaseSectionPayload[] = []; + for (let sectionIndex = 0; sectionIndex < value.length; sectionIndex += 1) { + const section = value[sectionIndex]; + if (!isRecord(section)) { + return { ok: false, error: "Section must be an object", details: { sectionIndex } }; + } + + if (!isNonEmptyString(section.title)) { + return { + ok: false, + error: "Section title is required", + details: { sectionIndex, field: "title" }, + }; + } + + if (!Array.isArray(section.blocks)) { + return { + ok: false, + error: "Section blocks must be an array", + details: { sectionIndex, field: "blocks" }, + }; + } + + const blocks: CaseBlockPayload[] = []; + for (let blockIndex = 0; blockIndex < section.blocks.length; blockIndex += 1) { + const blockValidation = validateBlock(section.blocks[blockIndex], sectionIndex, blockIndex); + if (!blockValidation.ok) return blockValidation; + blocks.push(blockValidation.data); + } + + sections.push({ title: section.title, blocks }); + } + + return { ok: true, data: sections }; +}; + +const normalizeSlug = (slug: unknown): string | null => { + if (isString(slug)) return slug; + if (isRecord(slug) && isString(slug.slug)) return slug.slug; + return null; +}; + +export const validateCaseStudyPayload = (value: unknown): ValidationResult => { + if (!isRecord(value)) { + return { ok: false, error: "Case payload must be an object" }; + } + + const slug = normalizeSlug(value.slug); + if (!isNonEmptyString(slug)) { + return { ok: false, error: "Case slug is required", details: { field: "slug" } }; + } + + if (!isNonEmptyString(value.title)) { + return { ok: false, error: "Case title is required", details: { field: "title" } }; + } + + if (!isString(value.subtitle)) { + return { ok: false, error: "Case subtitle must be a string", details: { field: "subtitle" } }; + } + + if (!isString(value.coverSrc)) { + return { ok: false, error: "Case coverSrc must be a string", details: { field: "coverSrc" } }; + } + + if (!isNonEmptyString(value.coverAlt)) { + return { ok: false, error: "Case coverAlt is required", details: { field: "coverAlt" } }; + } + + if (!Array.isArray(value.facts)) { + return { ok: false, error: "Case facts must be an array", details: { field: "facts" } }; + } + const facts: FactPayload[] = []; + for (let index = 0; index < value.facts.length; index += 1) { + const factValidation = validateFact(value.facts[index], index); + if (!factValidation.ok) return factValidation; + facts.push(factValidation.data); + } + + const sectionsValidation = validateSections(value.sections); + if (!sectionsValidation.ok) return sectionsValidation; + + const seoValue = value.seo; + if (seoValue !== undefined) { + if (!isRecord(seoValue)) { + return { ok: false, error: "SEO must be an object", details: { field: "seo" } }; + } + if (seoValue.metaTitle !== undefined && !isString(seoValue.metaTitle)) { + return { ok: false, error: "SEO metaTitle must be a string", details: { field: "seo.metaTitle" } }; + } + if (seoValue.metaDescription !== undefined && !isString(seoValue.metaDescription)) { + return { + ok: false, + error: "SEO metaDescription must be a string", + details: { field: "seo.metaDescription" }, + }; + } + if (seoValue.ogImage !== undefined && !isString(seoValue.ogImage)) { + return { ok: false, error: "SEO ogImage must be a string", details: { field: "seo.ogImage" } }; + } + } + + return { + ok: true, + data: { + slug, + title: value.title, + subtitle: value.subtitle, + coverSrc: value.coverSrc, + coverAlt: value.coverAlt, + facts, + sections: sectionsValidation.data, + seo: seoValue as CaseStudyPayload["seo"], + }, + }; +}; + +export const validateCaseContentPath = (path: unknown): ValidationResult<{ path: string; slug: string }> => { + if (!isString(path)) { + return { ok: false, error: "Path must be a string", details: { field: "path" } }; + } + + const match = CASE_PATH_PATTERN.exec(path); + if (!match) { + return { + ok: false, + error: "Path is not allowed", + details: { field: "path", expected: "src/content/cases/.json" }, + }; + } + + return { ok: true, data: { path, slug: match[1] } }; +}; + +export const validateUploadPath = (path: unknown): ValidationResult<{ path: string; slug: string; filename: string }> => { + if (!isString(path)) { + return { ok: false, error: "Path must be a string", details: { field: "path" } }; + } + + const match = UPLOAD_PATH_PATTERN.exec(path); + if (!match) { + return { + ok: false, + error: "Upload path is not allowed", + details: { field: "path", expected: "public/cases//" }, + }; + } + + const [, slug, filename] = match; + const ext = filename.split(".").pop()?.toLowerCase(); + if (!ext || !ALLOWED_IMAGE_EXTENSIONS.has(ext)) { + return { + ok: false, + error: "Image extension is not allowed", + details: { filename, allowed: Array.from(ALLOWED_IMAGE_EXTENSIONS) }, + }; + } + + return { ok: true, data: { path, slug, filename } }; +}; diff --git a/src/lib/content/__tests__/case-files.test.ts b/src/lib/content/__tests__/case-files.test.ts new file mode 100644 index 0000000..1274e08 --- /dev/null +++ b/src/lib/content/__tests__/case-files.test.ts @@ -0,0 +1,26 @@ +import { + isCaseSlug, + readCaseBySlug, + readCaseSummaries, +} from "@/lib/content/case-files"; + +describe("case files loader", () => { + it("validates slug format", () => { + expect(isCaseSlug("railway-booking-flow")).toBe(true); + expect(isCaseSlug("../railway-booking-flow")).toBe(false); + expect(isCaseSlug("railway_booking_flow")).toBe(false); + }); + + it("reads case summaries", async () => { + const summaries = await readCaseSummaries(); + expect(summaries.length).toBeGreaterThan(0); + expect(summaries[0]).toHaveProperty("slug"); + expect(summaries[0]).toHaveProperty("title"); + }); + + it("reads case by slug", async () => { + const caseData = await readCaseBySlug("railway-booking-flow"); + expect(caseData).toBeTruthy(); + expect(typeof caseData).toBe("object"); + }); +}); diff --git a/src/lib/content/case-files.ts b/src/lib/content/case-files.ts new file mode 100644 index 0000000..e63ad2a --- /dev/null +++ b/src/lib/content/case-files.ts @@ -0,0 +1,36 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +const casesDirectory = path.join(process.cwd(), "src", "content", "cases"); +const CASE_SLUG_PATTERN = /^[a-z0-9-]+$/; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const isCaseSlug = (slug: string) => CASE_SLUG_PATTERN.test(slug); + +export const readCaseSummaries = async () => { + const files = await fs.readdir(casesDirectory); + const caseFiles = files.filter((file) => file.endsWith(".json")); + + const summaries = await Promise.all( + caseFiles.map(async (file) => { + const raw = await fs.readFile(path.join(casesDirectory, file), "utf-8"); + const data = JSON.parse(raw) as unknown; + const fallbackSlug = file.replace(".json", ""); + const slug = + isRecord(data) && typeof data.slug === "string" ? data.slug : fallbackSlug; + const title = + isRecord(data) && typeof data.title === "string" ? data.title : fallbackSlug; + return { slug, title }; + }), + ); + + return summaries; +}; + +export const readCaseBySlug = async (slug: string) => { + const filePath = path.join(casesDirectory, `${slug}.json`); + const raw = await fs.readFile(filePath, "utf-8"); + return JSON.parse(raw) as unknown; +};