From cddce6bee6f3ba37d83c4d569c3a8ca824d75c4f Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Thu, 25 Dec 2025 13:38:42 +0400 Subject: [PATCH 1/4] Adds Ghost CMS as a new publishing provider Features: - JWT-based authentication using Ghost Admin API keys - Post creation with HTML content support - Featured image upload - Tags support - Custom post status (published/draft/scheduled) - Custom slug support Files added: - ghost.provider.ts: Main provider implementation - ghost.dto.ts: Post settings validation - ghost.png: Platform icon (50x50) --- .../frontend/public/icons/platforms/ghost.png | Bin 0 -> 4538 bytes .../posts/providers-settings/ghost.dto.ts | 42 +++ .../src/integrations/integration.manager.ts | 2 + .../src/integrations/social/ghost.provider.ts | 248 ++++++++++++++++++ 4 files changed, 292 insertions(+) create mode 100644 apps/frontend/public/icons/platforms/ghost.png create mode 100644 libraries/nestjs-libraries/src/dtos/posts/providers-settings/ghost.dto.ts create mode 100644 libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts diff --git a/apps/frontend/public/icons/platforms/ghost.png b/apps/frontend/public/icons/platforms/ghost.png new file mode 100644 index 0000000000000000000000000000000000000000..2d1e4ba5234d0fbefbc2bfe46d5c59e65bca78f4 GIT binary patch literal 4538 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 MediaDto) + feature_image?: MediaDto; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; +} diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index a8ef22a439..a4ca5bbe39 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -29,6 +29,7 @@ import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.pro import { WordpressProvider } from '@gitroom/nestjs-libraries/integrations/social/wordpress.provider'; import { ListmonkProvider } from '@gitroom/nestjs-libraries/integrations/social/listmonk.provider'; import { GmbProvider } from '@gitroom/nestjs-libraries/integrations/social/gmb.provider'; +import { GhostProvider } from '@gitroom/nestjs-libraries/integrations/social/ghost.provider'; export const socialIntegrationList: SocialProvider[] = [ new XProvider(), @@ -58,6 +59,7 @@ export const socialIntegrationList: SocialProvider[] = [ new HashnodeProvider(), new WordpressProvider(), new ListmonkProvider(), + new GhostProvider(), // new MastodonCustomProvider(), ]; diff --git a/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts new file mode 100644 index 0000000000..abb6f3c007 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts @@ -0,0 +1,248 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { GhostDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/ghost.dto'; +import slugify from 'slugify'; +import { sign } from 'jsonwebtoken'; + +interface GhostCredentials { + domain: string; + apiKey: string; +} + +export class GhostProvider extends SocialAbstract implements SocialProvider { + identifier = 'ghost'; + name = 'Ghost'; + isBetweenSteps = false; + editor = 'html' as const; + scopes = [] as string[]; + override maxConcurrentJob = 5; + dto = GhostDto; + + maxLength() { + return 100000; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: '', + codeVerifier: makeId(10), + state, + }; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async customFields() { + return [ + { + key: 'domain', + label: 'Ghost Site URL', + validation: `/^https?:\\/\\/(?:www\\.)?[\\w\\-]+(\\.[\\w\\-]+)+([\\/?#][^\\s]*)?$/`, + type: 'text' as const, + }, + { + key: 'apiKey', + label: 'Admin API Key', + validation: `/^[a-f0-9]+:[a-f0-9]+$/`, + type: 'password' as const, + }, + ]; + } + + private generateGhostJWT(apiKey: string): string { + const [id, secret] = apiKey.split(':'); + const secretBytes = Buffer.from(secret, 'hex'); + + return sign({}, secretBytes, { + algorithm: 'HS256', + keyid: id, + expiresIn: '5m', + audience: '/admin/', + }); + } + + private parseCredentials(accessToken: string): GhostCredentials { + return JSON.parse(Buffer.from(accessToken, 'base64').toString()) as GhostCredentials; + } + + private getApiUrl(domain: string): string { + const cleanDomain = domain.replace(/\/$/, ''); + return `${cleanDomain}/ghost/api/admin`; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const credentials = JSON.parse( + Buffer.from(params.code, 'base64').toString() + ) as GhostCredentials; + + try { + const token = this.generateGhostJWT(credentials.apiKey); + const apiUrl = this.getApiUrl(credentials.domain); + + const response = await fetch(`${apiUrl}/users/me/`, { + headers: { + Authorization: `Ghost ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const error = await response.text(); + console.error('Ghost authentication failed:', error); + return 'Invalid credentials or API key'; + } + + const data = await response.json(); + const user = data.users?.[0]; + + if (!user) { + return 'Could not retrieve user information'; + } + + return { + refreshToken: '', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: params.code, + id: `${credentials.domain}_${user.id}`, + name: user.name || user.email, + picture: user.profile_image || '', + username: user.slug || user.email, + }; + } catch (err) { + console.error('Ghost authentication error:', err); + return 'Invalid credentials or connection error'; + } + } + + private async uploadImage( + apiUrl: string, + token: string, + imageUrl: string + ): Promise { + try { + const imageResponse = await fetch(imageUrl); + if (!imageResponse.ok) { + console.error('Failed to fetch image:', imageUrl); + return null; + } + + const blob = await imageResponse.blob(); + const filename = imageUrl.split('/').pop() || 'image.jpg'; + + const formData = new FormData(); + formData.append('file', blob, filename); + formData.append('purpose', 'image'); + + const uploadResponse = await this.fetch(`${apiUrl}/images/upload/`, { + method: 'POST', + headers: { + Authorization: `Ghost ${token}`, + }, + body: formData, + }); + + const uploadData = await uploadResponse.json(); + return uploadData.images?.[0]?.url || null; + } catch (err) { + console.error('Ghost image upload error:', err); + return null; + } + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const credentials = this.parseCredentials(accessToken); + const token = this.generateGhostJWT(credentials.apiKey); + const apiUrl = this.getApiUrl(credentials.domain); + + const firstPost = postDetails[0]; + const settings = firstPost.settings; + + let featureImageUrl: string | undefined; + if (settings?.feature_image?.path) { + const uploadedUrl = await this.uploadImage( + apiUrl, + token, + settings.feature_image.path + ); + if (uploadedUrl) { + featureImageUrl = uploadedUrl; + } + } + + const postSlug = settings?.slug + ? slugify(settings.slug, { lower: true, strict: true, trim: true }) + : slugify(settings?.title || 'untitled', { + lower: true, + strict: true, + trim: true, + }); + + const ghostPost: Record = { + title: settings?.title || 'Untitled', + html: firstPost.message, + slug: postSlug, + status: settings?.status || 'published', + }; + + if (featureImageUrl) { + ghostPost.feature_image = featureImageUrl; + } + + if (settings?.tags && settings.tags.length > 0) { + ghostPost.tags = settings.tags.map((tag) => ({ name: tag })); + } + + const response = await this.fetch(`${apiUrl}/posts/`, { + method: 'POST', + headers: { + Authorization: `Ghost ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ posts: [ghostPost] }), + }); + + const responseData = await response.json(); + const createdPost = responseData.posts?.[0]; + + if (!createdPost) { + throw new Error('Failed to create Ghost post'); + } + + return [ + { + id: firstPost.id, + status: 'completed', + postId: String(createdPost.id), + releaseURL: createdPost.url, + }, + ]; + } +} From 0fa6211d3ce8c3d399065fdacd236c880cad0e3d Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Sun, 28 Dec 2025 10:21:49 +0400 Subject: [PATCH 2/4] fix(ghost): use /site/ endpoint instead of /users/me/ for auth --- .../src/integrations/social/ghost.provider.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts index abb6f3c007..915bb4daa4 100644 --- a/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts @@ -102,7 +102,9 @@ export class GhostProvider extends SocialAbstract implements SocialProvider { const token = this.generateGhostJWT(credentials.apiKey); const apiUrl = this.getApiUrl(credentials.domain); - const response = await fetch(`${apiUrl}/users/me/`, { + // Use /site/ endpoint instead of /users/me/ because integrations + // don't have a "me" user context - they authenticate as the integration itself + const response = await fetch(`${apiUrl}/site/`, { headers: { Authorization: `Ghost ${token}`, 'Content-Type': 'application/json', @@ -116,20 +118,20 @@ export class GhostProvider extends SocialAbstract implements SocialProvider { } const data = await response.json(); - const user = data.users?.[0]; + const site = data.site; - if (!user) { - return 'Could not retrieve user information'; + if (!site) { + return 'Could not retrieve site information'; } return { refreshToken: '', expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), accessToken: params.code, - id: `${credentials.domain}_${user.id}`, - name: user.name || user.email, - picture: user.profile_image || '', - username: user.slug || user.email, + id: `ghost_${site.title?.toLowerCase().replace(/\s+/g, '_') || 'site'}`, + name: site.title || 'Ghost Site', + picture: site.logo || site.icon || '', + username: new URL(credentials.domain).hostname, }; } catch (err) { console.error('Ghost authentication error:', err); From 093d80d9f7807751271de81e02353428f716db9a Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Sun, 28 Dec 2025 11:32:50 +0400 Subject: [PATCH 3/4] feat(ghost): add frontend settings form for Ghost posts - Add ghost.provider.tsx with settings UI (title, slug, status, feature image, tags) - Add ghost.tags.tsx with typed ReactTags component for free-form tag entry - Register Ghost provider in show.all.providers.tsx - Add GhostDto to all.providers.settings.ts type system - Extract title from first line of message as fallback when settings not provided --- .../providers/ghost/ghost.provider.tsx | 46 +++++++++++++ .../new-launch/providers/ghost/ghost.tags.tsx | 68 +++++++++++++++++++ .../providers/show.all.providers.tsx | 5 ++ .../all.providers.settings.ts | 3 + .../src/integrations/social/ghost.provider.ts | 11 ++- 5 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 apps/frontend/src/components/new-launch/providers/ghost/ghost.provider.tsx create mode 100644 apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx diff --git a/apps/frontend/src/components/new-launch/providers/ghost/ghost.provider.tsx b/apps/frontend/src/components/new-launch/providers/ghost/ghost.provider.tsx new file mode 100644 index 0000000000..85e0e8e2bf --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/ghost/ghost.provider.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { FC } from 'react'; +import { + PostComment, + withProvider, +} from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; +import { Input } from '@gitroom/react/form/input'; +import { Select } from '@gitroom/react/form/select'; +import { MediaComponent } from '@gitroom/frontend/components/media/media.component'; +import { GhostTags } from '@gitroom/frontend/components/new-launch/providers/ghost/ghost.tags'; +import { GhostDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/ghost.dto'; + +const GhostSettings: FC = () => { + const form = useSettings(); + return ( + <> + + + + + + + ); +}; + +export default withProvider({ + postComment: PostComment.POST, + minimumCharacters: [], + SettingsComponent: GhostSettings, + CustomPreviewComponent: undefined, + dto: GhostDto, + checkValidity: undefined, + maximumCharacters: 100000, +}); diff --git a/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx b/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx new file mode 100644 index 0000000000..14e5198569 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { FC, useCallback, useEffect, useState } from 'react'; +import { ReactTags, Tag, TagSelected } from 'react-tag-autocomplete'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; + +export const GhostTags: FC<{ + name: string; + label: string; + onChange: (event: { + target: { + value: string[]; + name: string; + }; + }) => void; +}> = (props) => { + const { name, label } = props; + const form = useSettings(); + const { getValues } = useSettings(); + const [tagValue, setTagValue] = useState([]); + + const onDelete = useCallback( + (tagIndex: number) => { + const modify = tagValue.filter((_, i) => i !== tagIndex); + setTagValue(modify); + form.setValue( + name, + modify.map((t) => String(t.label)) + ); + }, + [tagValue, name, form] + ); + + const onAddition = useCallback( + (newTag: Tag) => { + const modify = [...tagValue, newTag]; + setTagValue(modify); + form.setValue( + name, + modify.map((t) => String(t.label)) + ); + }, + [tagValue, name, form] + ); + + useEffect(() => { + const settings = getValues()[name]; + if (settings && Array.isArray(settings)) { + setTagValue( + settings.map((t: string) => ({ value: t, label: t })) + ); + } + }, []); + + return ( +
+
{label}
+ +
+ ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx index 521eb8ae50..3508bf9be4 100644 --- a/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/new-launch/providers/show.all.providers.tsx @@ -33,6 +33,7 @@ import { PostComment } from '@gitroom/frontend/components/new-launch/providers/h import WordpressProvider from '@gitroom/frontend/components/new-launch/providers/wordpress/wordpress.provider'; import ListmonkProvider from '@gitroom/frontend/components/new-launch/providers/listmonk/listmonk.provider'; import GmbProvider from '@gitroom/frontend/components/new-launch/providers/gmb/gmb.provider'; +import GhostProvider from '@gitroom/frontend/components/new-launch/providers/ghost/ghost.provider'; export const Providers = [ { @@ -143,6 +144,10 @@ export const Providers = [ identifier: 'gmb', component: GmbProvider, }, + { + identifier: 'ghost', + component: GhostProvider, + }, ]; export const ShowAllProviders = forwardRef((props, ref) => { const { date, current, global, selectedIntegrations, allIntegrations } = diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts index 8ad6b8b8ea..ffa6b755ec 100644 --- a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts @@ -18,6 +18,7 @@ import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-sett import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto'; import { FarcasterDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/farcaster.dto'; import { FacebookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/facebook.dto'; +import { GhostDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/ghost.dto'; export type ProviderExtension = { __type: T } & M; export type AllProvidersSettings = @@ -42,6 +43,7 @@ export type AllProvidersSettings = | ProviderExtension<'gmb', GmbSettingsDto> | ProviderExtension<'facebook', FacebookDto> | ProviderExtension<'wrapcast', FarcasterDto> + | ProviderExtension<'ghost', GhostDto> | ProviderExtension<'threads', None> | ProviderExtension<'mastodon', None> | ProviderExtension<'bluesky', None> @@ -74,6 +76,7 @@ export const allProviders = (setEmpty?: any) => { { value: GmbSettingsDto, name: 'gmb' }, { value: FarcasterDto, name: 'wrapcast' }, { value: FacebookDto, name: 'facebook' }, + { value: GhostDto, name: 'ghost' }, { value: setEmpty, name: 'threads' }, { value: setEmpty, name: 'mastodon' }, { value: setEmpty, name: 'bluesky' }, diff --git a/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts index 915bb4daa4..48a4411030 100644 --- a/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts @@ -199,16 +199,23 @@ export class GhostProvider extends SocialAbstract implements SocialProvider { } } + // Extract title from first line of message if not provided in settings + // This handles cases where frontend doesn't pass Ghost-specific settings + const messageLines = firstPost.message.split('\n').filter((l) => l.trim()); + const extractedTitle = + messageLines[0]?.replace(/<[^>]*>/g, '').trim() || 'Untitled'; + const postTitle = settings?.title || extractedTitle; + const postSlug = settings?.slug ? slugify(settings.slug, { lower: true, strict: true, trim: true }) - : slugify(settings?.title || 'untitled', { + : slugify(postTitle, { lower: true, strict: true, trim: true, }); const ghostPost: Record = { - title: settings?.title || 'Untitled', + title: postTitle, html: firstPost.message, slug: postSlug, status: settings?.status || 'published', From e41beee153a0a16f01f93587aa908c1451f328b2 Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Sun, 28 Dec 2025 12:00:27 +0400 Subject: [PATCH 4/4] feat(ghost): add tags autocomplete from Ghost Admin API - Add @Tool decorated tags() method to fetch existing tags - Update frontend to show tag suggestions while allowing new tags - Uses Ghost Admin API /tags/ endpoint (experimental but functional) --- .../new-launch/providers/ghost/ghost.tags.tsx | 20 ++++++++--- .../src/integrations/social/ghost.provider.ts | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx b/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx index 14e5198569..b8234b84a5 100644 --- a/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx +++ b/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx @@ -3,6 +3,12 @@ import { FC, useCallback, useEffect, useState } from 'react'; import { ReactTags, Tag, TagSelected } from 'react-tag-autocomplete'; import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; + +interface GhostTag { + value: string; + label: string; +} export const GhostTags: FC<{ name: string; @@ -17,6 +23,8 @@ export const GhostTags: FC<{ const { name, label } = props; const form = useSettings(); const { getValues } = useSettings(); + const customFunc = useCustomProviderFunction(); + const [suggestions, setSuggestions] = useState([]); const [tagValue, setTagValue] = useState([]); const onDelete = useCallback( @@ -44,11 +52,15 @@ export const GhostTags: FC<{ ); useEffect(() => { + // Fetch existing tags from Ghost + customFunc.get('tags').then((data: GhostTag[]) => { + setSuggestions(data || []); + }); + + // Load existing values const settings = getValues()[name]; if (settings && Array.isArray(settings)) { - setTagValue( - settings.map((t: string) => ({ value: t, label: t })) - ); + setTagValue(settings.map((t: string) => ({ value: t, label: t }))); } }, []); @@ -57,7 +69,7 @@ export const GhostTags: FC<{
{label}
> { + try { + const credentials = this.parseCredentials(token); + const jwtToken = this.generateGhostJWT(credentials.apiKey); + const apiUrl = this.getApiUrl(credentials.domain); + + const response = await fetch(`${apiUrl}/tags/?limit=all`, { + headers: { + Authorization: `Ghost ${jwtToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.error('Failed to fetch Ghost tags:', await response.text()); + return []; + } + + const data = await response.json(); + return ( + data.tags?.map((tag: { slug: string; name: string }) => ({ + value: tag.slug, + label: tag.name, + })) || [] + ); + } catch (err) { + console.error('Ghost tags fetch error:', err); + return []; + } + } + private async uploadImage( apiUrl: string, token: string,