voxblog/apps/api/src/ghost.ts

157 lines
4.9 KiB
TypeScript

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;