feat: implement Ghost blog post publishing with metadata and media handling

This commit is contained in:
Ender 2025-10-24 11:41:53 +02:00
parent 41f35ddca3
commit 5a00636063
9 changed files with 256 additions and 6 deletions

11
PLAN.md
View File

@ -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. - [ ] Prompt templates for tone/style suggestions via OpenAI.
- [ ] Inline improvement workflow with diff/revert capabilities. - [ ] Inline improvement workflow with diff/revert capabilities.
- **M6 · Ghost Publication Flow** (Scope: Goal 6) - **M6 · Ghost Publication Flow** (Scope: Goal 6)
- [ ] Map editor content to Ghost post payload. - [ ] Map editor content (HTML) to Ghost post payload (title, html, tags[], feature_image, canonical_url)
- [ ] Implement publish/draft triggers with status reports. - [ ] Backend: `/api/ghost/post` create/update (draft or published) using Ghost Admin API
- [ ] Handle tags, feature image, and canonical URL settings. - 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) - **M7 · Media Management** (Scope: Goal 7)
- [x] Centralize media library view with reuse. - [x] Centralize media library view with reuse.
- [ ] Background cleanup/retention policies. - [ ] Background cleanup/retention policies.

View File

@ -4,6 +4,7 @@ import Recorder from '../features/recorder/Recorder';
import RichEditor from './RichEditor'; import RichEditor from './RichEditor';
import type { RichEditorHandle } from './RichEditor'; import type { RichEditorHandle } from './RichEditor';
import MediaLibrary from './MediaLibrary'; import MediaLibrary from './MediaLibrary';
import MetadataPanel, { type Metadata } from './MetadataPanel';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
export default function EditorShell({ onLogout }: { onLogout?: () => void }) { export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
@ -11,6 +12,7 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
const [draftId, setDraftId] = useState<string | null>(null); const [draftId, setDraftId] = useState<string | null>(null);
const [drafts, setDrafts] = useState<string[]>([]); const [drafts, setDrafts] = useState<string[]>([]);
const editorRef = useRef<RichEditorHandle | null>(null); const editorRef = useRef<RichEditorHandle | null>(null);
const [meta, setMeta] = useState<Metadata>({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' });
useEffect(() => { useEffect(() => {
const savedId = localStorage.getItem('voxblog_draft_id'); const savedId = localStorage.getItem('voxblog_draft_id');
@ -100,6 +102,36 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
)} )}
</Stack> </Stack>
</Box> </Box>
<MetadataPanel
value={meta}
onChange={setMeta}
onPublish={async (status) => {
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)));
}
}}
/>
<MediaLibrary onInsert={(url) => { <MediaLibrary onInsert={(url) => {
if (editorRef.current) { if (editorRef.current) {
editorRef.current.insertImage(url); editorRef.current.insertImage(url);
@ -107,7 +139,7 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
// fallback // fallback
setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`); setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`);
} }
}} /> }} showSetFeature onSetFeature={(url) => setMeta((m) => ({ ...m, featureImage: url }))} />
</Box> </Box>
</AdminLayout> </AdminLayout>
); );

View File

@ -7,7 +7,7 @@ type MediaItem = {
lastModified: string | null; 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<MediaItem[]>([]); const [items, setItems] = useState<MediaItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -65,6 +65,9 @@ export default function MediaLibrary({ onInsert }: { onInsert: (url: string) =>
<Typography variant="caption" sx={{ display: 'block', mb: 1 }} title={name}>{name}</Typography> <Typography variant="caption" sx={{ display: 'block', mb: 1 }} title={name}>{name}</Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Button size="small" variant="outlined" onClick={() => onInsert(url)}>Insert</Button> <Button size="small" variant="outlined" onClick={() => onInsert(url)}>Insert</Button>
{showSetFeature && onSetFeature && (
<Button size="small" variant="outlined" onClick={() => onSetFeature(url)}>Set as Feature</Button>
)}
<Button size="small" color="error" onClick={() => del(it.key)}>Delete</Button> <Button size="small" color="error" onClick={() => del(it.key)}>Delete</Button>
</Stack> </Stack>
</Box> </Box>

View File

@ -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<Metadata>) => onChange({ ...value, ...patch });
return (
<Box sx={{ border: '1px solid #eee', borderRadius: 1, p: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post Metadata</Typography>
<Stack spacing={1}>
<TextField label="Title" value={value.title} onChange={(e) => set({ title: e.target.value })} fullWidth />
<TextField label="Tags (comma-separated)" value={value.tagsText} onChange={(e) => set({ tagsText: e.target.value })} fullWidth />
<TextField label="Canonical URL" value={value.canonicalUrl} onChange={(e) => set({ canonicalUrl: e.target.value })} fullWidth />
<TextField label="Feature Image URL" value={value.featureImage || ''} onChange={(e) => set({ featureImage: e.target.value })} fullWidth />
<Stack direction="row" spacing={1}>
<Button variant="outlined" disabled={busy} onClick={() => onPublish('draft')}>Save Draft to Ghost</Button>
<Button variant="contained" disabled={busy} onClick={() => onPublish('published')}>Publish to Ghost</Button>
</Stack>
</Stack>
</Box>
);
}

View File

@ -33,6 +33,8 @@
], ],
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.916.0", "@aws-sdk/client-s3": "^3.916.0",
"@aws-sdk/s3-request-presigner": "^3.916.0",
"@types/jsonwebtoken": "^9.0.10",
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"content-disposition": "^1.0.0", "content-disposition": "^1.0.0",
@ -50,6 +52,7 @@
"finalhandler": "^2.1.0", "finalhandler": "^2.1.0",
"fresh": "^2.0.0", "fresh": "^2.0.0",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"jsonwebtoken": "^9.0.2",
"merge-descriptors": "^2.0.0", "merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0", "mime-types": "^3.0.0",
"multer": "^2.0.2", "multer": "^2.0.2",

156
apps/api/src/ghost.ts Normal file
View File

@ -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<string> {
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<string> {
if (!htmlIn) return htmlIn;
const re = /<img\b[^>]*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;

View File

@ -8,6 +8,7 @@ import authRouter from './auth';
import mediaRouter from './media'; import mediaRouter from './media';
import sttRouter from './stt'; import sttRouter from './stt';
import draftsRouter from './drafts'; import draftsRouter from './drafts';
import ghostRouter from './ghost';
const app = express(); const app = express();
console.log('ENV ADMIN_PASSWORD loaded:', Boolean(process.env.ADMIN_PASSWORD)); 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/media', mediaRouter);
app.use('/api/stt', sttRouter); app.use('/api/stt', sttRouter);
app.use('/api/drafts', draftsRouter); app.use('/api/drafts', draftsRouter);
app.use('/api/ghost', ghostRouter);
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });

View File

@ -1,4 +1,5 @@
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3'; import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export function getS3Client() { export function getS3Client() {
const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000 const endpoint = process.env.S3_ENDPOINT; // e.g. http://<VPS_IP>:9000
@ -17,6 +18,19 @@ export function getS3Client() {
}); });
} }
export async function getPresignedUrl(params: { bucket: string; key: string; expiresInSeconds?: number }): Promise<string> {
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 }) { export async function listObjects(params: { bucket: string; prefix?: string; maxKeys?: number }) {
const s3 = getS3Client(); const s3 = getS3Client();
const cmd = new ListObjectsV2Command({ const cmd = new ListObjectsV2Command({

View File

@ -1,5 +1,5 @@
{ {
"id": "31ba935b-4424-4226-9f8b-803d401022a2", "id": "31ba935b-4424-4226-9f8b-803d401022a2",
"content": "<pre><code>enasdasdasd</code></pre><p>zdfsdfsadsdfsdfsdf</p><p></p><p></p><p>sdfsdfs</p><img src=\"/api/media/obj?bucket=voxblog&amp;key=images%2F2025-10-24%2F15962af6-52ae-4c16-918d-86b9e6488bfa.png\" alt=\"Vector-2.png\"><p></p><p></p><ul><li><p>df</p></li></ul><p></p><p></p><p></p><p></p><p></p>", "content": "<pre><code>enasdasdasd</code></pre><p>zdfsdfsadsdfsdfsdf</p><p></p><p></p><p>sdfsdfs</p><img src=\"/api/media/obj?bucket=voxblog&amp;key=images%2F2025-10-24%2F15962af6-52ae-4c16-918d-86b9e6488bfa.png\" alt=\"Vector-2.png\"><p></p><p></p><ul><li><p>df</p></li></ul><p></p><p></p><p></p><p></p><p></p>",
"updatedAt": "2025-10-24T01:58:35.356Z" "updatedAt": "2025-10-24T09:28:18.204Z"
} }