diff --git a/apps/frontend/public/icons/platforms/ghost.png b/apps/frontend/public/icons/platforms/ghost.png new file mode 100644 index 0000000000..2d1e4ba523 Binary files /dev/null and b/apps/frontend/public/icons/platforms/ghost.png differ 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..b8234b84a5 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/ghost/ghost.tags.tsx @@ -0,0 +1,80 @@ +'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'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; + +interface GhostTag { + value: string; + label: string; +} + +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 customFunc = useCustomProviderFunction(); + const [suggestions, setSuggestions] = useState([]); + 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(() => { + // 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 }))); + } + }, []); + + 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/dtos/posts/providers-settings/ghost.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/ghost.dto.ts new file mode 100644 index 0000000000..625b69e454 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/ghost.dto.ts @@ -0,0 +1,42 @@ +import { + IsArray, + IsDefined, + IsEnum, + IsOptional, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; +import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; +import { Type } from 'class-transformer'; + +export enum GhostPostStatus { + PUBLISHED = 'published', + DRAFT = 'draft', + SCHEDULED = 'scheduled', +} + +export class GhostDto { + @IsString() + @MinLength(1) + @IsDefined() + title: string; + + @IsOptional() + @IsString() + slug?: string; + + @IsEnum(GhostPostStatus) + @IsDefined() + status: GhostPostStatus; + + @IsOptional() + @ValidateNested() + @Type(() => 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..b096a4ebf4 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/ghost.provider.ts @@ -0,0 +1,290 @@ +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'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +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); + + // 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', + }, + }); + + 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 site = data.site; + + if (!site) { + return 'Could not retrieve site information'; + } + + return { + refreshToken: '', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: params.code, + 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); + return 'Invalid credentials or connection error'; + } + } + + @Tool({ description: 'Get Ghost tags', dataSchema: [] }) + async tags(token: string): Promise> { + 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, + 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; + } + } + + // 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(postTitle, { + lower: true, + strict: true, + trim: true, + }); + + const ghostPost: Record = { + title: postTitle, + 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, + }, + ]; + } +}