Something went wrong. Try again.
Ghost is lightweight headless server that enables Ghost self hosters to bridge to standard.site with ease..
Something went wrong. Try again.
7.9 kB · 213 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214// Ghost Admin API client + helpers for the codeinjection_head trick.// Site-scoped: every function takes the Site it's operating on. Cached clients// live in a Map keyed by site.id so we don't reconstruct on every call.
import GhostAdminAPI from '@tryghost/admin-api';import type { Site } from './db.js';
// eslint-disable-next-line @typescript-eslint/no-explicit-anyconst _clients = new Map<number, any>();
// eslint-disable-next-line @typescript-eslint/no-explicit-anyexport function getClient(site: Site): any { const cached = _clients.get(site.id); if (cached) return cached; const client = new GhostAdminAPI({ url: site.ghost_url, key: site.ghost_admin_api_key, version: 'v5.0', }); _clients.set(site.id, client); return client;}
// Useful when credentials change (set-* commands re-pull a site from DB and// want a fresh client).export function invalidateClient(siteId: number): void { _clients.delete(siteId);}
// Accept either a 24-char hex ObjectID or a slug.// eslint-disable-next-line @typescript-eslint/no-explicit-anyexport async function readPost(site: Site, idOrSlug: string): Promise<any> { const client = getClient(site); const lookup = /^[a-f0-9]{24}$/.test(idOrSlug) ? { id: idOrSlug } : { slug: idOrSlug }; return client.posts.read(lookup, { formats: ['html'] });}
// eslint-disable-next-line @typescript-eslint/no-explicit-anyexport async function browsePublishedPosts(site: Site): Promise<any[]> { const client = getClient(site); return client.posts.browse({ limit: 'all', filter: 'status:published', fields: 'id,slug,title,status', });}
// ---------------------------------------------------------------------------// Ghost site settings — used by the setup wizard to pre-fill defaults// ---------------------------------------------------------------------------
export interface GhostSettings { title: string | null; description: string | null; accent_color: string | null; iconUrl: string | null;}
// "Live" credentials variant — used during the setup wizard before a Site row// exists. Once a Site is persisted, use fetchSettingsForSite() / fetchIconForSite().export interface LiveCreds { ghost_url: string; ghost_admin_api_key: string;}
// eslint-disable-next-line @typescript-eslint/no-explicit-anyfunction makeAdHocClient(creds: LiveCreds): any { return new GhostAdminAPI({ url: creds.ghost_url, key: creds.ghost_admin_api_key, version: 'v5.0' });}
export async function fetchSettings(creds: LiveCreds): Promise<GhostSettings> { const client = makeAdHocClient(creds); // @tryghost/admin-api exposes site metadata via client.site.read() — not // client.settings.browse() (which doesn't exist). The /site/ endpoint // returns title, description, icon, accent_color, etc. const raw = await client.site.read(); const iconUrl = (() => { if (!raw?.icon) return null; const url = String(raw.icon); if (url.startsWith('http://') || url.startsWith('https://')) return url; const base = creds.ghost_url.replace(/\/$/, ''); return base + (url.startsWith('/') ? url : `/${url}`); })(); return { title: raw?.title ?? null, description: raw?.description ?? null, accent_color: raw?.accent_color ?? null, iconUrl, };}
export async function fetchIcon(creds: LiveCreds): Promise< { buffer: Buffer; mimeType: string; width: number; height: number } | null> { const settings = await fetchSettings(creds); if (!settings.iconUrl) return null; try { const resp = await fetch(settings.iconUrl); if (!resp.ok) return null; const buffer = Buffer.from(await resp.arrayBuffer()); const mimeType = resp.headers.get('content-type') ?? 'image/png'; const sharp = (await import('sharp')).default; const meta = await sharp(buffer).metadata(); if (!meta.width || !meta.height) return null; return { buffer, mimeType, width: meta.width, height: meta.height }; } catch { return null; }}
// ---------------------------------------------------------------------------// Webhook management — auto-install the four required webhooks via the Admin// API so the operator never has to paste secrets into Ghost's UI by hand.// Idempotent: existing webhooks for an event+url already present are left// alone; only missing ones are created.// ---------------------------------------------------------------------------
export const REQUIRED_WEBHOOK_EVENTS = [ 'post.published', 'post.published.edited', 'post.unpublished', 'post.deleted',] as const;
export interface WebhookInstallSummary { created: string[]; existing: string[]; failed: { event: string; error: string }[]; targetUrl: string;}
// @tryghost/admin-api special-cases the webhooks resource to expose only// add/edit/delete (the Admin API doesn't seem to publish a list endpoint —// our attempt to GET /admin/webhooks/ returned 406). So we can't dedupe by// fetching what's already there.//// Instead: just try to add each webhook. Ghost rejects duplicates with a// validation error like "Target URL has already been used for this event",// which we catch and treat as "already installed". Any other error is a real// failure.function looksLikeDuplicateError(err: unknown): boolean { const msg = err instanceof Error ? err.message : String(err); return /already|exists?|duplicate/i.test(msg);}
export async function ensureWebhooks( site: Site, bridgeUrl: string,): Promise<WebhookInstallSummary> { const client = getClient(site); const targetUrl = `${bridgeUrl.replace(/\/$/, '')}/webhooks/ghost/${site.slug}`; const summary: WebhookInstallSummary = { created: [], existing: [], failed: [], targetUrl };
for (const event of REQUIRED_WEBHOOK_EVENTS) { try { await client.webhooks.add({ event, target_url: targetUrl, secret: site.webhook_secret, name: `ghoststandard ${event}`, }); summary.created.push(event); } catch (err) { if (looksLikeDuplicateError(err)) { summary.existing.push(event); } else { summary.failed.push({ event, error: (err as Error).message }); } } }
return summary;}
// ---------------------------------------------------------------------------// codeinjection_head marker block + idempotent strip/append// ---------------------------------------------------------------------------
const MARKER_START = '<!-- standard.site:document -->';const MARKER_END = '<!-- /standard.site:document -->';const MARKER_RE = /<!-- standard\.site:document -->[\s\S]*?<!-- \/standard\.site:document -->\n?/g;
function escapeHtmlAttr(s: string): string { return String(s).replace(/&/g, '&').replace(/"/g, '"');}
export function buildCodeInjectionHead(existing: string | null | undefined, atUri: string): string { if (typeof atUri !== 'string' || !atUri.startsWith('at://')) { throw new Error(`Invalid AT-URI for codeinjection_head: ${atUri}`); } const stripped = (existing ?? '').replace(MARKER_RE, '').trimEnd(); const block = `${MARKER_START}\n<link rel="site.standard.document" href="${escapeHtmlAttr(atUri)}">\n${MARKER_END}`; return stripped ? `${stripped}\n\n${block}\n` : `${block}\n`;}
export async function setDocumentLinkInjection( site: Site, // eslint-disable-next-line @typescript-eslint/no-explicit-any post: any, atUri: string,): Promise<{ updated: boolean }> { const desired = buildCodeInjectionHead(post.codeinjection_head, atUri); if (desired === (post.codeinjection_head ?? '')) { return { updated: false }; } const client = getClient(site); await client.posts.edit({ id: post.id, updated_at: post.updated_at, codeinjection_head: desired, }); return { updated: true };}