diff --git a/apps/frontend/public/icons/platforms/ghost.png b/apps/frontend/public/icons/platforms/ghost.png new file mode 100644 index 000000000..2d1e4ba52 Binary files /dev/null and b/apps/frontend/public/icons/platforms/ghost.png differ 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 000000000..625b69e45 --- /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 a8ef22a43..a4ca5bbe3 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 000000000..abb6f3c00 --- /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, + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts index b30ae6f5a..3ec0c2cd2 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -25,6 +25,9 @@ export class LinkedinPageProvider override isBetweenSteps = true; override refreshWait = true; override maxConcurrentJob = 2; // LinkedIn Page has professional posting limits + // Organization pages require full scopes including org-specific permissions. + // These scopes require LinkedIn Advertising API or Community Management API access. + // Parent LinkedinProvider uses minimal scopes for personal profiles only. override scopes = [ 'openid', 'profile', diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts index 4aa1c9d5b..b4520f78a 100644 --- a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -25,15 +25,10 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider { oneTimeToken = true; isBetweenSteps = false; - scopes = [ - 'openid', - 'profile', - 'w_member_social', - 'r_basicprofile', - 'rw_organization_admin', - 'w_organization_social', - 'r_organization_social', - ]; + // Personal profile only needs basic scopes - organization scopes (w_organization_social, etc.) + // require special LinkedIn API access that self-hosters may not have. + // LinkedinPageProvider overrides this with full org scopes for company page posting. + scopes = ['openid', 'profile', 'w_member_social']; override maxConcurrentJob = 2; // LinkedIn has professional posting limits refreshWait = true; editor = 'normal' as const;