import express from 'express'; import jwt from 'jsonwebtoken'; import { fetch } from 'undici'; import { getPresignedUrl, getPublicUrlForKey, copyObject } 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' }); } const replacePairs: Array<{ from: string; to: string }> = []; 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 decoded = src.replace(/&/g, '&'); const u = new URL(decoded, 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 (and optionally copy), else presigned URL let out: string; const publicBase = process.env.PUBLIC_MEDIA_BASE_URL; if (publicBase) { const destBucket = process.env.PUBLIC_MEDIA_BUCKET || ''; if (destBucket) { try { await copyObject({ srcBucket: bucket, srcKey: key, destBucket, destKey: key }); } catch (e) { console.warn('[Ghost] Copy to public bucket failed, will still use public base URL', e); } } out = getPublicUrlForKey(key) as string; } else { out = await getPresignedUrl({ bucket, key, expiresInSeconds: 60 * 60 * 24 * 7 }); } if (out !== src) { replacePairs.push({ from: src, to: out }); } return out; } 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; } // Inspect incoming HTML for srcs before rewriting try { const re = /]*src=["']([^"']+)["'][^>]*>/gi; const found = [...(html || '').matchAll(re)].map(m => m[1]); console.log('[Ghost] Incoming feature_image (raw):', feature_image || '(none)'); console.log('[Ghost] Incoming count:', found.length); console.log('[Ghost] Incoming srcs (first 5):', found.slice(0, 5)); console.log('[Ghost] HTML contains /api/media/obj:', (html || '').includes('/api/media/obj')); } catch {} const bucketDefault = process.env.S3_BUCKET || ''; const rewrittenFeatureImage = await rewriteInternalMediaUrl(feature_image || '', bucketDefault); const rewrittenHtml = await rewriteHtmlImagesToPublic(html, bucketDefault); // Debug logs to trace rewriting try { const base = process.env.PUBLIC_MEDIA_BASE_URL || '(none)'; console.log('[Ghost] PUBLIC_MEDIA_BASE_URL:', base); console.log('[Ghost] Replacements count:', replacePairs.length); if (rewrittenFeatureImage && rewrittenFeatureImage !== (feature_image || '')) { console.log('[Ghost] feature_image rewritten'); } if (replacePairs.length > 0) { console.log('[Ghost] Sample replacements:', replacePairs.slice(0, 3)); } } catch {} 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) }); } }); // Preview endpoint: rewrite media URLs without copying, to render a faithful content preview router.post('/preview', async (req, res) => { try { const { html, feature_image } = req.body as { html: string; feature_image?: string }; const replacePairs: Array<{ from: string; to: string }> = []; const origin = `${req.protocol}://${req.get('host')}`; async function rewriteInternalMediaUrlPreview(src: string, _bucketFallback: string): Promise { try { if (!src) return src; // If this references our media proxy, ensure it points to the API origin if (src.startsWith('/api/media/obj')) { const out = `${origin}${src}`; if (out !== src) replacePairs.push({ from: src, to: out }); return out; } if (/^https?:\/\//i.test(src)) { try { const uAbs = new URL(src); if (uAbs.pathname === '/api/media/obj') { const out = `${origin}${uAbs.pathname}${uAbs.search}`; if (out !== src) replacePairs.push({ from: src, to: out }); return out; } return src; } catch { return src; } } return src; } catch { return src; } } async function rewriteHtmlImagesToPublicPreview(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 srcAttr = m[1]; const newSrc = await rewriteInternalMediaUrlPreview(srcAttr, process.env.S3_BUCKET || ''); out += htmlIn.slice(last, idx) + full.replace(srcAttr, newSrc); last = idx + full.length; } out += htmlIn.slice(last); return out; } const rewrittenFeatureImage = await rewriteInternalMediaUrlPreview(feature_image || '', ''); const rewrittenHtml = await rewriteHtmlImagesToPublicPreview(html, ''); return res.json({ html: rewrittenHtml, feature_image: rewrittenFeatureImage, replacements: replacePairs }); } catch (err: any) { console.error('Ghost preview failed:', err); return res.status(500).json({ error: 'Ghost preview failed', detail: err?.message || String(err) }); } }); export default router;