/** * Ghost Admin API Client * Full CRUD operations for Ghost content using native fetch */ import { generateToken } from './ghost.jwt.js'; import type { THttpMethod, IBrowseOptions, IGhostAPIResponse, IGhostErrorResponse } from './ghost.types.js'; import * as fs from 'fs'; import * as path from 'path'; export interface IGhostAdminAPIOptions { url: string; key: string; version?: string; ghostPath?: string; } export class GhostAdminAPI { private url: string; private key: string; private version: string; private ghostPath: string; private acceptVersionHeader: string; constructor(options: IGhostAdminAPIOptions) { this.url = options.url.replace(/\/$/, ''); // Remove trailing slash this.key = options.key; this.version = options.version || 'v3'; this.ghostPath = options.ghostPath || 'ghost'; if (!this.url) { throw new Error('GhostAdminAPI: url is required'); } if (!this.key) { throw new Error('GhostAdminAPI: key is required'); } // Set accept version header if (this.version.match(/^v\d+$/)) { this.acceptVersionHeader = `${this.version}.0`; } else if (this.version.match(/^v\d+\.\d+$/)) { this.acceptVersionHeader = this.version; } else { this.acceptVersionHeader = 'v6.0'; } } /** * Build the API prefix based on version */ private getAPIPrefix(): string { // v5+ doesn't need version prefix if (this.version === 'v5' || this.version === 'v6' || this.version.match(/^v[5-9]\.\d+/)) { return `/admin/`; } // v2-v4 and canary need version prefix return `/${this.version}/admin/`; } /** * Build full API URL */ private buildUrl(resource: string, identifier?: string, params?: Record): string { const apiPrefix = this.getAPIPrefix(); let endpoint = `${this.url}/${this.ghostPath}/api${apiPrefix}${resource}/`; if (identifier) { endpoint += `${identifier}/`; } // Build query string if params exist if (params && Object.keys(params).length > 0) { const queryString = Object.keys(params) .filter(key => params[key] !== undefined && params[key] !== null) .map(key => { const value = Array.isArray(params[key]) ? params[key].join(',') : params[key]; return `${key}=${encodeURIComponent(value)}`; }) .join('&'); if (queryString) { endpoint += `?${queryString}`; } } return endpoint; } /** * Get authorization token */ private async getAuthToken(): Promise { const audience = this.getAPIPrefix(); return await generateToken(this.key, audience); } /** * Make API request */ private async makeRequest( resource: string, method: THttpMethod = 'GET', identifier?: string, body?: any, queryParams?: Record ): Promise { const url = this.buildUrl(resource, identifier, queryParams); const token = await this.getAuthToken(); const headers: Record = { 'Authorization': `Ghost ${token}`, 'Accept-Version': this.acceptVersionHeader, 'Accept': 'application/json', 'User-Agent': 'GhostAdminAPI/2.0' }; const requestOptions: RequestInit = { method, headers }; // Add body for POST/PUT if (body && (method === 'POST' || method === 'PUT')) { headers['Content-Type'] = 'application/json'; requestOptions.body = JSON.stringify(body); } try { const response = await fetch(url, requestOptions); if (!response.ok) { const errorData: IGhostErrorResponse = await response.json().catch(() => ({ errors: [{ type: 'UnknownError', message: response.statusText }] })); const error = errorData.errors?.[0]; const err = new Error(error?.message || response.statusText); Object.assign(err, { name: error?.type || 'GhostAdminAPIError', statusCode: response.status, ...error }); throw err; } // DELETE returns empty response if (method === 'DELETE') { return; } const data: IGhostAPIResponse = await response.json(); // Extract the resource data const resourceData = data[resource]; if (!resourceData) { return data as any; // For some special endpoints that don't follow standard format } // If it's an array and has meta, attach meta to the array if (Array.isArray(resourceData) && data.meta) { return Object.assign(resourceData, { meta: data.meta }); } // If it's an array with single item and no meta, return the item if (Array.isArray(resourceData) && resourceData.length === 1 && !data.meta) { return resourceData[0]; } return resourceData as T | T[]; } catch (error) { throw error; } } /** * Create resource API methods factory */ private createResourceAPI(resourceType: string) { return { browse: (options?: IBrowseOptions) => { return this.makeRequest(resourceType, 'GET', undefined, undefined, options); }, read: (data: { id?: string; slug?: string; email?: string }, queryParams?: Record) => { if (data.slug) { return this.makeRequest(resourceType, 'GET', `slug/${data.slug}`, undefined, queryParams); } if (data.email) { return this.makeRequest(resourceType, 'GET', `email/${data.email}`, undefined, queryParams); } if (data.id) { return this.makeRequest(resourceType, 'GET', data.id, undefined, queryParams); } throw new Error('Must provide id, slug, or email'); }, add: (data: any, queryParams?: Record) => { if (!data || !Object.keys(data).length) { return Promise.reject(new Error('Missing data')); } const body: any = {}; body[resourceType] = [data]; return this.makeRequest(resourceType, 'POST', undefined, body, queryParams); }, edit: (data: any, queryParams?: Record) => { if (!data) { return Promise.reject(new Error('Missing data')); } if (!data.id) { return Promise.reject(new Error('Must include data.id')); } const id = data.id; const updateData = { ...data }; delete updateData.id; const body: any = {}; body[resourceType] = [updateData]; return this.makeRequest(resourceType, 'PUT', id, body, queryParams); }, delete: (data: { id?: string; email?: string }, queryParams?: Record) => { if (!data) { return Promise.reject(new Error('Missing data')); } if (!data.id && !data.email) { return Promise.reject(new Error('Must include either data.id or data.email')); } const identifier = data.email ? `email/${data.email}` : data.id; return this.makeRequest(resourceType, 'DELETE', identifier, undefined, queryParams); } }; } // Resource APIs public posts = this.createResourceAPI('posts'); public pages = this.createResourceAPI('pages'); public tags = this.createResourceAPI('tags'); public members = this.createResourceAPI('members'); public users = this.createResourceAPI('users'); // Webhooks (limited operations) public webhooks = { add: (data: any, queryParams?: Record) => { const body: any = { webhooks: [data] }; return this.makeRequest('webhooks', 'POST', undefined, body, queryParams); }, edit: (data: any, queryParams?: Record) => { if (!data.id) { return Promise.reject(new Error('Must include data.id')); } const id = data.id; const updateData = { ...data }; delete updateData.id; const body: any = { webhooks: [updateData] }; return this.makeRequest('webhooks', 'PUT', id, body, queryParams); }, delete: (data: { id: string }, queryParams?: Record) => { if (!data.id) { return Promise.reject(new Error('Must include data.id')); } return this.makeRequest('webhooks', 'DELETE', data.id, undefined, queryParams); } }; // Image upload public images = { upload: async (data: { file: string }) => { if (!data || !data.file) { throw new Error('Must provide file path'); } const url = this.buildUrl('images', 'upload'); const token = await this.getAuthToken(); // Read file const fileBuffer = fs.readFileSync(data.file); const fileName = path.basename(data.file); // Create FormData const formData = new FormData(); // Convert Buffer to ArrayBuffer for Blob const arrayBuffer = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength) as ArrayBuffer; const blob = new Blob([arrayBuffer], { type: 'image/*' }); formData.append('file', blob, fileName); const response = await fetch(url, { method: 'POST', headers: { 'Authorization': `Ghost ${token}`, 'Accept-Version': this.acceptVersionHeader, 'User-Agent': 'GhostAdminAPI/2.0' }, body: formData }); if (!response.ok) { const errorData: IGhostErrorResponse = await response.json().catch(() => ({ errors: [{ type: 'UnknownError', message: response.statusText }] })); const error = errorData.errors?.[0]; const err = new Error(error?.message || response.statusText); throw err; } const result = await response.json(); return result.images?.[0] || result; } }; }