diff --git a/apps/frontend/public/icons/platforms/wordpress.png b/apps/frontend/public/icons/platforms/wordpress.png new file mode 100644 index 00000000..fc5be833 Binary files /dev/null and b/apps/frontend/public/icons/platforms/wordpress.png differ 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 8e83b804..73f2d800 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 @@ -30,6 +30,7 @@ import { IntegrationContext } from '@gitroom/frontend/components/launches/helper import { Button } from '@gitroom/react/form/button'; import { useT } from '@gitroom/react/translation/get.transation.service.client'; import { PostComment } from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import WordpressProvider from '@gitroom/frontend/components/new-launch/providers/wordpress/wordpress.provider'; export const Providers = [ { @@ -128,6 +129,10 @@ export const Providers = [ identifier: 'vk', component: VkProvider, }, + { + identifier: 'wordpress', + component: WordpressProvider, + }, ]; export const ShowAllProviders = forwardRef((props, ref) => { const { date, current, global, selectedIntegrations, allIntegrations } = diff --git a/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.post.type.tsx b/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.post.type.tsx new file mode 100644 index 00000000..53a18563 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.post.type.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { Select } from '@gitroom/react/form/select'; +import { useT } from '@gitroom/react/translation/get.transation.service.client'; +import { useCustomProviderFunction } from '@gitroom/frontend/components/launches/helpers/use.custom.provider.function'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; +export const WordpressPostType: FC<{ + name: string; + onChange: (event: { + target: { + value: string; + name: string; + }; + }) => void; +}> = (props) => { + const { onChange, name } = props; + const t = useT(); + const customFunc = useCustomProviderFunction(); + const [orgs, setOrgs] = useState([]); + const { getValues } = useSettings(); + const [currentMedia, setCurrentMedia] = useState(); + const onChangeInner = (event: { + target: { + value: string; + name: string; + }; + }) => { + setCurrentMedia(event.target.value); + onChange(event); + }; + useEffect(() => { + customFunc.get('postTypes').then((data) => setOrgs(data)); + const settings = getValues()[props.name]; + if (settings) { + setCurrentMedia(settings); + } + }, []); + if (!orgs.length) { + return null; + } + return ( + + ); +}; diff --git a/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx b/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx new file mode 100644 index 00000000..47fb98a7 --- /dev/null +++ b/apps/frontend/src/components/new-launch/providers/wordpress/wordpress.provider.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { FC } from 'react'; +import { + PostComment, + withProvider, +} from '@gitroom/frontend/components/new-launch/providers/high.order.provider'; +import { Input } from '@gitroom/react/form/input'; +import { useSettings } from '@gitroom/frontend/components/launches/helpers/use.values'; +import { WordpressPostType } from '@gitroom/frontend/components/new-launch/providers/wordpress/wordpress.post.type'; +import { MediaComponent } from '@gitroom/frontend/components/media/media.component'; + +const WordpressSettings: FC = () => { + const form = useSettings(); + return ( + <> + + + + + ); +}; +export default withProvider({ + postComment: PostComment.COMMENT, + minimumCharacters: [], + SettingsComponent: WordpressSettings, + CustomPreviewComponent: undefined, // WordpressPreview, + dto: undefined, + checkValidity: undefined, + maximumCharacters: 100000, +}); 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 7a2572a7..3caf5e5f 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 @@ -13,6 +13,7 @@ import { IsIn } from 'class-validator'; import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto'; import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto'; import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto'; +import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto'; export type ProviderExtension = { __type: T } & M; export type AllProvidersSettings = @@ -32,6 +33,7 @@ export type AllProvidersSettings = | ProviderExtension<'medium', MediumSettingsDto> | ProviderExtension<'devto', DevToSettingsDto> | ProviderExtension<'hashnode', HashnodeSettingsDto> + | ProviderExtension<'wordpress', WordpressDto> | ProviderExtension<'facebook', None> | ProviderExtension<'threads', None> | ProviderExtension<'mastodon', None> @@ -60,6 +62,7 @@ export const allProviders = (setEmpty?: any) => { { value: InstagramDto, name: 'instagram-standalone' }, { value: MediumSettingsDto, name: 'medium' }, { value: DevToSettingsDto, name: 'devto' }, + { value: WordpressDto, name: 'wordpress' }, { value: HashnodeSettingsDto, name: 'hashnode' }, { value: setEmpty, name: 'facebook' }, { value: setEmpty, name: 'threads' }, diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/wordpress.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/wordpress.dto.ts new file mode 100644 index 00000000..732b4e35 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/wordpress.dto.ts @@ -0,0 +1,25 @@ +import { + IsDefined, + IsOptional, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; +import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; +import { Type } from 'class-transformer'; + +export class WordpressDto { + @IsString() + @MinLength(2) + @IsDefined() + title: string; + + @IsOptional() + @ValidateNested() + @Type(() => MediaDto) + main_image?: MediaDto; + + @IsString() + @IsDefined() + type: string; +} diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index 50fed756..c1ca5e73 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -26,6 +26,7 @@ import { FarcasterProvider } from '@gitroom/nestjs-libraries/integrations/social import { TelegramProvider } from '@gitroom/nestjs-libraries/integrations/social/telegram.provider'; import { NostrProvider } from '@gitroom/nestjs-libraries/integrations/social/nostr.provider'; import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.provider'; +import { WordpressProvider } from '@gitroom/nestjs-libraries/integrations/social/wordpress.provider'; export const socialIntegrationList: SocialProvider[] = [ new XProvider(), @@ -52,6 +53,7 @@ export const socialIntegrationList: SocialProvider[] = [ new MediumProvider(), new DevToProvider(), new HashnodeProvider(), + new WordpressProvider(), // new MastodonCustomProvider(), ]; diff --git a/libraries/nestjs-libraries/src/integrations/social.abstract.ts b/libraries/nestjs-libraries/src/integrations/social.abstract.ts index 9d807236..299cbc1c 100644 --- a/libraries/nestjs-libraries/src/integrations/social.abstract.ts +++ b/libraries/nestjs-libraries/src/integrations/social.abstract.ts @@ -75,6 +75,7 @@ export abstract class SocialAbstract { json = '{}'; } + console.log(json); if (json.includes('rate_limit_exceeded') || json.includes('Rate limit')) { await timer(5000); console.log('rate limit trying again'); diff --git a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts new file mode 100644 index 00000000..1ad8fadb --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts @@ -0,0 +1,237 @@ +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 { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto'; +import slugify from 'slugify'; +import FormData from 'form-data'; +import axios from 'axios'; + +export class WordpressProvider + extends SocialAbstract + implements SocialProvider +{ + identifier = 'wordpress'; + name = 'WordPress'; + isBetweenSteps = false; + editor = 'html' as const; + scopes = [] as string[]; + + 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: 'Domain URL', + validation: `/^https?:\\/\\/(?:www\\.)?[\\w\\-]+(\\.[\\w\\-]+)+([\\/?#][^\\s]*)?$/`, + type: 'text' as const, + }, + { + key: 'username', + label: 'Username', + validation: `/.+/`, + type: 'text' as const, + }, + { + key: 'password', + label: 'Password', + validation: `/.+/`, + type: 'password' as const, + }, + ]; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()) as { + domain: string; + username: string; + password: string; + }; + try { + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + const { id, name, avatar_urls } = await ( + await fetch(`${body.domain}/wp-json/wp/v2/users/me`, { + headers: { + Authorization: `Basic ${auth}`, + }, + }) + ).json(); + + const biggestImage = Object.entries(avatar_urls).reduce( + (all, current) => { + if (all > Number(current[0])) { + return all; + } + return Number(current[0]); + }, + 0 + ); + + return { + refreshToken: '', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: params.code, + id: body.domain + '_' + id, + name, + picture: avatar_urls?.[String(biggestImage)] || '', + username: body.username, + }; + } catch (err) { + console.log(err); + return 'Invalid credentials'; + } + } + + async postTypes(token: string) { + const body = JSON.parse(Buffer.from(token, 'base64').toString()) as { + domain: string; + username: string; + password: string; + }; + + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + + const postTypes = await ( + await this.fetch(`${body.domain}/wp-json/wp/v2/types`, { + headers: { + Authorization: `Basic ${auth}`, + }, + }) + ).json(); + + return Object.entries(postTypes).reduce((all, [key, value]) => { + if ( + key.indexOf('wp_') > -1 || + key.indexOf('nav_') > -1 || + key === 'attachment' + ) { + return all; + } + + all.push({ + id: value.rest_base, + name: value.name, + }); + + return all; + }, []); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const body = JSON.parse(Buffer.from(accessToken, 'base64').toString()) as { + domain: string; + username: string; + password: string; + }; + + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + + let mediaId = ''; + if (postDetails?.[0]?.settings?.main_image?.path) { + console.log('Uploading image to WordPress', postDetails[0].settings.main_image.path); + const imageData = await axios.get(postDetails[0].settings.main_image.path, { + responseType: 'stream', + }); + + const form = new FormData(); + form.append('file', imageData.data, { + filename: postDetails[0].settings.main_image.path.split('/').pop(), // You can customize the filename + contentType: imageData.headers['content-type'], + }); + if (postDetails[0].settings.main_image?.alt) { + form.append('alt_text', postDetails[0].settings.main_image.alt); + } + + const mediaResponse = await axios.post( + `${body.domain}/wp-json/wp/v2/media`, + { + method: 'POST', + body: form, + }, + { + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + } + ); + + console.log('nevo', mediaResponse); + mediaId = mediaResponse.data.id; + } + + const submit = await ( + await this.fetch( + `https://cms.postiz.com/wp-json/wp/v2/${postDetails?.[0]?.settings?.type}`, + { + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({ + title: postDetails?.[0]?.settings?.title, + content: postDetails?.[0]?.message, + slug: slugify(postDetails?.[0]?.settings?.title, { + lower: true, + strict: true, + trim: true, + }), + status: 'publish', + ...(mediaId ? { featured_media: mediaId } : {}), + }), + } + ) + ).json(); + + return [ + { + id: postDetails?.[0].id, + status: 'completed', + postId: String(submit.id), + releaseURL: submit.link, + }, + ]; + } +} diff --git a/package.json b/package.json index ff995a76..2ce1b269 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,7 @@ "sha256": "^0.2.0", "sharp": "^0.33.4", "simple-statistics": "^7.8.3", + "slugify": "^1.6.6", "stripe": "^15.5.0", "striptags": "^3.2.0", "subtitle": "4.2.2-alpha.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44c8c053..6041f17b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -462,6 +462,9 @@ importers: simple-statistics: specifier: ^7.8.3 version: 7.8.8 + slugify: + specifier: ^1.6.6 + version: 1.6.6 stripe: specifier: ^15.5.0 version: 15.12.0 @@ -13260,6 +13263,10 @@ packages: slate@0.94.1: resolution: {integrity: sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==} + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -31592,6 +31599,8 @@ snapshots: is-plain-object: 5.0.0 tiny-warning: 1.0.3 + slugify@1.6.6: {} + smart-buffer@4.2.0: {} snake-case@3.0.4: