264 lines
9.1 KiB
TypeScript
264 lines
9.1 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(/&/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) });
|
|
}
|
|
});
|
|
|
|
// 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<string> {
|
|
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<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 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;
|