voxblog/apps/api/src/ghost.ts

199 lines
6.7 KiB
TypeScript

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<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 decoded = src.replace(/&amp;/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<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;
}
// Inspect incoming HTML for <img> srcs before rewriting
try {
const re = /<img\b[^>]*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 <img> count:', found.length);
console.log('[Ghost] Incoming <img> 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) });
}
});
export default router;