157 lines
4.9 KiB
TypeScript
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;
|