From 5a0063606322557c5d712dacc4b906b2898ff321 Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 11:41:53 +0200 Subject: [PATCH] feat: implement Ghost blog post publishing with metadata and media handling --- PLAN.md | 11 +- apps/admin/src/components/EditorShell.tsx | 34 +++- apps/admin/src/components/MediaLibrary.tsx | 5 +- apps/admin/src/components/MetadataPanel.tsx | 35 ++++ apps/api/package.json | 3 + apps/api/src/ghost.ts | 156 ++++++++++++++++++ apps/api/src/index.ts | 2 + apps/api/src/storage/s3.ts | 14 ++ .../31ba935b-4424-4226-9f8b-803d401022a2.json | 2 +- 9 files changed, 256 insertions(+), 6 deletions(-) create mode 100644 apps/admin/src/components/MetadataPanel.tsx create mode 100644 apps/api/src/ghost.ts diff --git a/PLAN.md b/PLAN.md index e0a3f6e..873fcbf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -34,9 +34,14 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit - [ ] Prompt templates for tone/style suggestions via OpenAI. - [ ] Inline improvement workflow with diff/revert capabilities. - **M6 · Ghost Publication Flow** (Scope: Goal 6) - - [ ] Map editor content to Ghost post payload. - - [ ] Implement publish/draft triggers with status reports. - - [ ] Handle tags, feature image, and canonical URL settings. + - [ ] Map editor content (HTML) to Ghost post payload (title, html, tags[], feature_image, canonical_url) + - [ ] Backend: `/api/ghost/post` create/update (draft or published) using Ghost Admin API + - Body: `{ id?, title, html, tags: string[], feature_image?, canonical_url?, status: 'draft'|'published' }` + - Returns: `{ id, url, status }` + - [ ] Frontend: Metadata panel (title, tags input, feature image picker, canonical URL) + - [ ] Frontend: Buttons — "Save as Draft" and "Publish" (calls `/api/ghost/post`) + - [ ] Show status toast and link to view post + - [ ] ENV: `GHOST_ADMIN_API_URL`, `GHOST_ADMIN_API_KEY`, `GHOST_PUBLIC_URL` - **M7 · Media Management** (Scope: Goal 7) - [x] Centralize media library view with reuse. - [ ] Background cleanup/retention policies. diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx index bd5fece..4e7d689 100644 --- a/apps/admin/src/components/EditorShell.tsx +++ b/apps/admin/src/components/EditorShell.tsx @@ -4,6 +4,7 @@ import Recorder from '../features/recorder/Recorder'; import RichEditor from './RichEditor'; import type { RichEditorHandle } from './RichEditor'; import MediaLibrary from './MediaLibrary'; +import MetadataPanel, { type Metadata } from './MetadataPanel'; import { useEffect, useRef, useState } from 'react'; export default function EditorShell({ onLogout }: { onLogout?: () => void }) { @@ -11,6 +12,7 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) { const [draftId, setDraftId] = useState(null); const [drafts, setDrafts] = useState([]); const editorRef = useRef(null); + const [meta, setMeta] = useState({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' }); useEffect(() => { const savedId = localStorage.getItem('voxblog_draft_id'); @@ -100,6 +102,36 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) { )} + { + try { + const tags = meta.tagsText.split(',').map(t => t.trim()).filter(Boolean); + const res = await fetch('/api/ghost/post', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: meta.title || 'Untitled', + html: draft || '', + tags, + feature_image: meta.featureImage || undefined, + canonical_url: meta.canonicalUrl || undefined, + status, + }), + }); + const data = await res.json(); + if (!res.ok) { + console.error('Ghost error', data); + alert('Ghost error: ' + (data?.detail || data?.error || res.status)); + } else { + alert(`${status === 'published' ? 'Published' : 'Saved draft'} (id: ${data.id})`); + } + } catch (e: any) { + alert('Ghost error: ' + (e?.message || String(e))); + } + }} + /> { if (editorRef.current) { editorRef.current.insertImage(url); @@ -107,7 +139,7 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) { // fallback setDraft((prev) => `${prev || ''}

`); } - }} /> + }} showSetFeature onSetFeature={(url) => setMeta((m) => ({ ...m, featureImage: url }))} /> ); diff --git a/apps/admin/src/components/MediaLibrary.tsx b/apps/admin/src/components/MediaLibrary.tsx index 8155dcc..b80efdc 100644 --- a/apps/admin/src/components/MediaLibrary.tsx +++ b/apps/admin/src/components/MediaLibrary.tsx @@ -7,7 +7,7 @@ type MediaItem = { lastModified: string | null; }; -export default function MediaLibrary({ onInsert }: { onInsert: (url: string) => void }) { +export default function MediaLibrary({ onInsert, onSetFeature, showSetFeature }: { onInsert: (url: string) => void; onSetFeature?: (url: string) => void; showSetFeature?: boolean }) { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -65,6 +65,9 @@ export default function MediaLibrary({ onInsert }: { onInsert: (url: string) => {name} + {showSetFeature && onSetFeature && ( + + )} diff --git a/apps/admin/src/components/MetadataPanel.tsx b/apps/admin/src/components/MetadataPanel.tsx new file mode 100644 index 0000000..4e001c7 --- /dev/null +++ b/apps/admin/src/components/MetadataPanel.tsx @@ -0,0 +1,35 @@ +import { Box, Button, Stack, TextField, Typography } from '@mui/material'; +import { useState } from 'react'; + +export type Metadata = { + title: string; + tagsText: string; // comma-separated + canonicalUrl: string; + featureImage?: string; +}; + +export default function MetadataPanel({ value, onChange, onPublish }: { + value: Metadata; + onChange: (v: Metadata) => void; + onPublish: (status: 'draft' | 'published') => void; +}) { + const [busy, setBusy] = useState(false); + + const set = (patch: Partial) => onChange({ ...value, ...patch }); + + return ( + + Post Metadata + + set({ title: e.target.value })} fullWidth /> + set({ tagsText: e.target.value })} fullWidth /> + set({ canonicalUrl: e.target.value })} fullWidth /> + set({ featureImage: e.target.value })} fullWidth /> + + + + + + + ); +} diff --git a/apps/api/package.json b/apps/api/package.json index 913a910..5b69197 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -33,6 +33,8 @@ ], "dependencies": { "@aws-sdk/client-s3": "^3.916.0", + "@aws-sdk/s3-request-presigner": "^3.916.0", + "@types/jsonwebtoken": "^9.0.10", "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", @@ -50,6 +52,7 @@ "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", + "jsonwebtoken": "^9.0.2", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "multer": "^2.0.2", diff --git a/apps/api/src/ghost.ts b/apps/api/src/ghost.ts new file mode 100644 index 0000000..ae1e527 --- /dev/null +++ b/apps/api/src/ghost.ts @@ -0,0 +1,156 @@ +import express from 'express'; +import jwt from 'jsonwebtoken'; +import { fetch } from 'undici'; +import { getPresignedUrl, getPublicUrlForKey } from './storage/s3'; + +const router = express.Router(); + +function getGhostToken(): string { + const apiUrl = process.env.GHOST_ADMIN_API_URL || ''; + const apiKey = process.env.GHOST_ADMIN_API_KEY || ''; + if (!apiUrl || !apiKey) { + throw new Error('Missing GHOST_ADMIN_API_URL or GHOST_ADMIN_API_KEY'); + } + const [keyId, secret] = apiKey.split(':'); + if (!keyId || !secret) throw new Error('Invalid GHOST_ADMIN_API_KEY format'); + + const now = Math.floor(Date.now() / 1000); + const token = jwt.sign( + { + iat: now, + exp: now + 5 * 60, + aud: '/admin/', + }, + Buffer.from(secret, 'hex'), + { + keyid: keyId, + algorithm: 'HS256', + issuer: keyId, + } + ); + return token; +} + +router.post('/post', async (req, res) => { + try { + const apiUrl = process.env.GHOST_ADMIN_API_URL || ''; + const token = getGhostToken(); + + const { + id, + title, + html, + tags, + feature_image, + canonical_url, + status, + } = req.body as { + id?: string; + title: string; + html: string; + tags?: string[]; + feature_image?: string; + canonical_url?: string; + status?: 'draft' | 'published'; + }; + + if (!title || !html) { + return res.status(400).json({ error: 'title and html are required' }); + } + + async function rewriteInternalMediaUrl(src: string, bucketFallback: string): Promise { + try { + if (!src) return src; + // handle absolute or relative /api/media/obj URLs + const isInternal = src.includes('/api/media/obj'); + if (!isInternal) return src; + // parse query + const base = 'http://local'; + const u = new URL(src, base); + const key = u.searchParams.get('key') || ''; + const bucket = u.searchParams.get('bucket') || bucketFallback; + if (!key || !bucket) return src; + // Prefer a public base if provided, else presigned URL + const publicUrl = getPublicUrlForKey(key); + if (publicUrl) return publicUrl; + return await getPresignedUrl({ bucket, key, expiresInSeconds: 60 * 60 * 24 * 7 }); + } catch { + return src; + } + } + + async function rewriteHtmlImagesToPublic(htmlIn: string, bucketFallback: string): Promise { + if (!htmlIn) return htmlIn; + const re = /]*src=["']([^"']+)["'][^>]*>/gi; + const matches = [...htmlIn.matchAll(re)]; + if (matches.length === 0) return htmlIn; + let out = ''; + let last = 0; + for (const m of matches) { + const idx = m.index || 0; + const full = m[0]; + const src = m[1]; + const newSrc = await rewriteInternalMediaUrl(src, bucketFallback); + out += htmlIn.slice(last, idx) + full.replace(src, newSrc); + last = idx + full.length; + } + out += htmlIn.slice(last); + return out; + } + + const bucketDefault = process.env.S3_BUCKET || ''; + const rewrittenFeatureImage = await rewriteInternalMediaUrl(feature_image || '', bucketDefault); + const rewrittenHtml = await rewriteHtmlImagesToPublic(html, bucketDefault); + + const payload = { + posts: [ + { + ...(id ? { id } : {}), + title, + html: rewrittenHtml, + status: status || 'draft', + tags: tags && Array.isArray(tags) ? tags : [], + ...(rewrittenFeatureImage ? { feature_image: rewrittenFeatureImage } : {}), + ...(canonical_url ? { canonical_url } : {}), + }, + ], + }; + + const base = apiUrl.replace(/\/$/, ''); + const url = id ? `${base}/posts/${id}/?source=html` : `${base}/posts/?source=html`; + + const method = id ? 'PUT' : 'POST'; + + const ghRes = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Accept-Version': 'v5.0', + Authorization: `Ghost ${token}`, + }, + body: JSON.stringify(payload), + }); + + const contentType = ghRes.headers.get('content-type') || ''; + if (!ghRes.ok) { + const text = await ghRes.text(); + console.error('Ghost API error:', ghRes.status, text); + return res.status(ghRes.status).json({ error: 'Ghost API error', detail: text }); + } + if (!contentType.includes('application/json')) { + const text = await ghRes.text(); + console.error('Ghost API non-JSON response (possible wrong GHOST_ADMIN_API_URL):', text.slice(0, 300)); + return res.status(500).json({ error: 'Ghost response not JSON', detail: text.slice(0, 500) }); + } + + const data: any = await ghRes.json(); + const post = data?.posts?.[0] || {}; + return res.json({ id: post.id, url: post.url, status: post.status }); + } catch (err: any) { + console.error('Ghost publish failed:', err); + return res.status(500).json({ error: 'Ghost publish failed', detail: err?.message || String(err) }); + } +}); + +export default router; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index dd41bb0..b6d902d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,6 +8,7 @@ import authRouter from './auth'; import mediaRouter from './media'; import sttRouter from './stt'; import draftsRouter from './drafts'; +import ghostRouter from './ghost'; const app = express(); console.log('ENV ADMIN_PASSWORD loaded:', Boolean(process.env.ADMIN_PASSWORD)); @@ -25,6 +26,7 @@ app.use('/api/auth', authRouter); app.use('/api/media', mediaRouter); app.use('/api/stt', sttRouter); app.use('/api/drafts', draftsRouter); +app.use('/api/ghost', ghostRouter); app.get('/api/health', (_req, res) => { res.json({ ok: true }); }); diff --git a/apps/api/src/storage/s3.ts b/apps/api/src/storage/s3.ts index 083db45..3072251 100644 --- a/apps/api/src/storage/s3.ts +++ b/apps/api/src/storage/s3.ts @@ -1,4 +1,5 @@ import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; export function getS3Client() { const endpoint = process.env.S3_ENDPOINT; // e.g. http://:9000 @@ -17,6 +18,19 @@ export function getS3Client() { }); } +export async function getPresignedUrl(params: { bucket: string; key: string; expiresInSeconds?: number }): Promise { + const s3 = getS3Client(); + const cmd = new GetObjectCommand({ Bucket: params.bucket, Key: params.key }); + const expiresIn = typeof params.expiresInSeconds === 'number' ? params.expiresInSeconds : 60 * 60 * 24 * 7; // 7 days + return await getSignedUrl(s3, cmd, { expiresIn }); +} + +export function getPublicUrlForKey(key: string): string | null { + const base = process.env.PUBLIC_MEDIA_BASE_URL; + if (!base) return null; + return `${base.replace(/\/$/, '')}/${key}`; +} + export async function listObjects(params: { bucket: string; prefix?: string; maxKeys?: number }) { const s3 = getS3Client(); const cmd = new ListObjectsV2Command({ diff --git a/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json b/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json index af2036c..0ab9751 100644 --- a/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json +++ b/data/drafts/31ba935b-4424-4226-9f8b-803d401022a2.json @@ -1,5 +1,5 @@ { "id": "31ba935b-4424-4226-9f8b-803d401022a2", "content": "
enasdasdasd

zdfsdfsadsdfsdfsdf

sdfsdfs

\"Vector-2.png\"

  • df

", - "updatedAt": "2025-10-24T01:58:35.356Z" + "updatedAt": "2025-10-24T09:28:18.204Z" } \ No newline at end of file