diff --git a/packages/api/src/routes/blog.ts b/packages/api/src/routes/blog.ts index a83d2e0..dd4a1ab 100644 --- a/packages/api/src/routes/blog.ts +++ b/packages/api/src/routes/blog.ts @@ -1874,6 +1874,94 @@ blogRouter.post("/:id/regenerate", async (req: Request, res: Response) => { }); // DELETE /api/blog/:id — Delete a blog draft +// POST /api/blog/:id/publish-ghost — Publish to blog.fichtmueller.org via Ghost Admin API +blogRouter.post("/:id/publish-ghost", async (req: Request, res: Response) => { + try { + const draft = await pool.query( + "SELECT id, title, draft_content, seo_keywords FROM blog_drafts WHERE id = $1::uuid", + [req.params.id] + ); + if (draft.rows.length === 0) { + return res.status(404).json({ success: false, error: "Draft not found" }); + } + + const { title, draft_content, seo_keywords } = draft.rows[0]; + if (!draft_content || draft_content.trim().length < 100) { + return res.status(400).json({ success: false, error: "Draft content too short to publish" }); + } + + // Ghost Admin API JWT auth + const GHOST_URL = process.env.GHOST_URL || "https://blog.fichtmueller.org"; + const GHOST_ADMIN_KEY = process.env.GHOST_ADMIN_KEY || "87727de2746a4de69efd5b03:7abdbec3a7ae473ad09487fc6e48327809da27c8adaaea457cce2d4f55b065f7"; + const [keyId, secret] = GHOST_ADMIN_KEY.split(":"); + + // Create JWT token for Ghost Admin API + const crypto = await import("crypto"); + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT", kid: keyId })).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const payload = Buffer.from(JSON.stringify({ + iat: now, exp: now + 300, aud: "/admin/" + })).toString("base64url"); + const signature = crypto.createHmac("sha256", Buffer.from(secret, "hex")) + .update(`${header}.${payload}`).digest("base64url"); + const jwt = `${header}.${payload}.${signature}`; + + // Convert markdown content to Ghost mobiledoc + // Strip the # Title from content (Ghost uses its own title field) + const bodyContent = draft_content.replace(/^#\s+[^\n]+\n*/m, "").trim(); + + // Build mobiledoc with markdown card + const mobiledoc = JSON.stringify({ + version: "0.3.1", + ghostVersion: "4.0", + markups: [], atoms: [], sections: [[10, 0]], cards: [["markdown", { markdown: bodyContent }]] + }); + + // Build tags from seo_keywords + const tags = (seo_keywords || "").split(",").map((k: string) => k.trim()).filter(Boolean).slice(0, 5) + .map((t: string) => ({ name: t })); + + // POST to Ghost Admin API + const ghostRes = await fetch(`${GHOST_URL}/ghost/api/admin/posts/?source=html`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept-Version": "v5.0", + Authorization: `Ghost ${jwt}`, + }, + body: JSON.stringify({ + posts: [{ + title, + mobiledoc, + status: "published", + tags: tags.length > 0 ? tags : [{ name: "Optical Networking" }], + }] + }), + }); + + if (!ghostRes.ok) { + const errBody = await ghostRes.text(); + console.error("[blog] Ghost publish failed:", ghostRes.status, errBody.slice(0, 300)); + return res.status(500).json({ success: false, error: `Ghost API error: ${ghostRes.status}` }); + } + + const ghostData = await ghostRes.json() as { posts?: Array<{ url?: string; slug?: string }> }; + const ghostUrl = ghostData.posts?.[0]?.url || `${GHOST_URL}/`; + + // Update TIP draft status + await pool.query( + "UPDATE blog_drafts SET status = 'published', updated_at = NOW() WHERE id = $1::uuid", + [req.params.id] + ); + + console.log(`[blog] Published to Ghost: ${title} → ${ghostUrl}`); + res.json({ success: true, url: ghostUrl, ghost_slug: ghostData.posts?.[0]?.slug }); + } catch (err) { + console.error("[blog] Ghost publish error:", err); + res.status(500).json({ success: false, error: (err as Error).message }); + } +}); + blogRouter.delete("/:id", async (req: Request, res: Response) => { try { // Delete feedback first (FK constraint) diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index f5d50c1..80f8b79 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -4384,7 +4384,7 @@ async function openBlogDetail(id) { } h += ''; if (d.linkedin_post) { - h += '