// 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-any const _clients = new Map(); // eslint-disable-next-line @typescript-eslint/no-explicit-any export 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-any export async function readPost(site: Site, idOrSlug: string): Promise { 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-any export async function browsePublishedPosts(site: Site): Promise { 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-any function 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 { 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 { 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 = ''; const MARKER_END = ''; const MARKER_RE = /[\s\S]*?\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\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 }; }