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;