diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts index b0e01043..bfdada6e 100644 --- a/apps/backend/src/api/routes/integrations.controller.ts +++ b/apps/backend/src/api/routes/integrations.controller.ts @@ -27,6 +27,7 @@ import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.req import { NotEnoughScopesFilter } from '@gitroom/nestjs-libraries/integrations/integration.missing.scopes'; import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; @ApiTags('Integrations') @Controller('/integrations') @@ -127,7 +128,8 @@ export class IntegrationsController { @CheckPolicies([AuthorizationActions.Create, Sections.CHANNEL]) async getIntegrationUrl( @Param('integration') integration: string, - @Query('refresh') refresh: string + @Query('refresh') refresh: string, + @Query('externalUrl') externalUrl: string ) { if ( !this._integrationManager @@ -139,11 +141,33 @@ export class IntegrationsController { const integrationProvider = this._integrationManager.getSocialIntegration(integration); - const { codeVerifier, state, url } = - await integrationProvider.generateAuthUrl(refresh); - await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300); - return { url }; + if (integrationProvider.externalUrl && !externalUrl) { + throw new Error('Missing external url'); + } + + try { + const getExternalUrl = integrationProvider.externalUrl + ? { + ...(await integrationProvider.externalUrl(externalUrl)), + instanceUrl: externalUrl, + } + : undefined; + + const { codeVerifier, state, url } = + await integrationProvider.generateAuthUrl(refresh, getExternalUrl); + await ioRedis.set(`login:${state}`, codeVerifier, 'EX', 300); + await ioRedis.set( + `external:${state}`, + JSON.stringify(getExternalUrl), + 'EX', + 300 + ); + + return { url }; + } catch (err) { + return { err: true }; + } } @Post('/:id/time') @@ -273,6 +297,15 @@ export class IntegrationsController { const integrationProvider = this._integrationManager.getSocialIntegration(integration); + + const details = integrationProvider.externalUrl + ? await ioRedis.get(`external:${body.state}`) + : undefined; + + if (details) { + await ioRedis.del(`external:${body.state}`); + } + const { accessToken, expiresIn, @@ -281,11 +314,14 @@ export class IntegrationsController { name, picture, username, - } = await integrationProvider.authenticate({ - code: body.code, - codeVerifier: getCodeVerifier, - refresh: body.refresh, - }); + } = await integrationProvider.authenticate( + { + code: body.code, + codeVerifier: getCodeVerifier, + refresh: body.refresh, + }, + details ? JSON.parse(details) : undefined + ); if (!id) { throw new Error('Invalid api key'); @@ -304,7 +340,8 @@ export class IntegrationsController { username, integrationProvider.isBetweenSteps, body.refresh, - +body.timezone + +body.timezone, + AuthService.fixedEncryption(details) ); } diff --git a/apps/frontend/public/icons/platforms/mastodon-custom.png b/apps/frontend/public/icons/platforms/mastodon-custom.png new file mode 100644 index 00000000..79670abc Binary files /dev/null and b/apps/frontend/public/icons/platforms/mastodon-custom.png differ diff --git a/apps/frontend/public/icons/platforms/mastodon.png b/apps/frontend/public/icons/platforms/mastodon.png new file mode 100644 index 00000000..79670abc Binary files /dev/null and b/apps/frontend/public/icons/platforms/mastodon.png differ diff --git a/apps/frontend/src/components/launches/add.provider.component.tsx b/apps/frontend/src/components/launches/add.provider.component.tsx index 7d0f6a8b..9f354049 100644 --- a/apps/frontend/src/components/launches/add.provider.component.tsx +++ b/apps/frontend/src/components/launches/add.provider.component.tsx @@ -11,6 +11,7 @@ import { ApiKeyDto } from '@gitroom/nestjs-libraries/dtos/integrations/api.key.d import { useRouter } from 'next/navigation'; import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component'; import { useVariables } from '@gitroom/react/helpers/variable.context'; +import { useToaster } from '@gitroom/react/toaster/toaster'; const resolver = classValidatorResolver(ApiKeyDto); @@ -127,23 +128,107 @@ export const ApiModal: FC<{ ); }; + +export const UrlModal: FC<{ + gotoUrl(url: string): void; +}> = (props) => { + const { gotoUrl } = props; + const methods = useForm({ + mode: 'onChange', + }); + + const submit = useCallback(async (data: FieldValues) => { + gotoUrl(data.url); + }, []); + + return ( +
+ + + +
+
+ +
+
+ +
+
+
+
+ ); +}; + export const AddProviderComponent: FC<{ - social: Array<{ identifier: string; name: string }>; + social: Array<{ identifier: string; name: string; isExternal: boolean }>; article: Array<{ identifier: string; name: string }>; update?: () => void; }> = (props) => { const { update } = props; - const {isGeneral} = useVariables(); + const { isGeneral } = useVariables(); + const toaster = useToaster(); const fetch = useFetch(); const modal = useModals(); const { social, article } = props; const getSocialLink = useCallback( - (identifier: string) => async () => { - const { url } = await ( - await fetch('/integrations/social/' + identifier) - ).json(); - window.location.href = url; + (identifier: string, isExternal: boolean) => async () => { + const gotoIntegration = async (externalUrl?: string) => { + const { url, err } = await ( + await fetch( + `/integrations/social/${identifier}${ + externalUrl ? `?externalUrl=${externalUrl}` : `` + }` + ) + ).json(); + + if (err) { + toaster.show('Could not connect to the platform', 'warning'); + return ; + } + window.location.href = url; + }; + + if (isExternal) { + modal.closeAll(); + + modal.openModal({ + title: '', + withCloseButton: false, + classNames: { + modal: 'bg-transparent text-textColor', + }, + children: ( + + ), + }); + + return; + } + + await gotoIntegration(); }, [] ); @@ -196,7 +281,7 @@ export const AddProviderComponent: FC<{ {social.map((item) => (
{ + return null; +}; + +export default withProvider(null, Empty, undefined, undefined); diff --git a/apps/frontend/src/components/launches/providers/show.all.providers.tsx b/apps/frontend/src/components/launches/providers/show.all.providers.tsx index 50d9392a..def7733c 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -15,6 +15,7 @@ import DribbbleProvider from '@gitroom/frontend/components/launches/providers/dr import ThreadsProvider from '@gitroom/frontend/components/launches/providers/threads/threads.provider'; import DiscordProvider from '@gitroom/frontend/components/launches/providers/discord/discord.provider'; import SlackProvider from '@gitroom/frontend/components/launches/providers/slack/slack.provider'; +import MastodonProvider from '@gitroom/frontend/components/launches/providers/mastodon/mastodon.provider'; export const Providers = [ {identifier: 'devto', component: DevtoProvider}, @@ -33,6 +34,7 @@ export const Providers = [ {identifier: 'threads', component: ThreadsProvider}, {identifier: 'discord', component: DiscordProvider}, {identifier: 'slack', component: SlackProvider}, + {identifier: 'mastodon', component: MastodonProvider}, ]; diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts index f618cdfc..145c5ce3 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -79,7 +79,8 @@ export class IntegrationRepository { username?: string, isBetweenSteps = false, refresh?: string, - timezone?: number + timezone?: number, + customInstanceDetails?: string ) { const postTimes = timezone ? { @@ -113,6 +114,7 @@ export class IntegrationRepository { ...postTimes, organizationId: org, refreshNeeded: false, + ...(customInstanceDetails ? { customInstanceDetails } : {}), }, update: { type: type as any, diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts index 53f2cad6..0de38c66 100644 --- a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -47,7 +47,8 @@ export class IntegrationService { username?: string, isBetweenSteps = false, refresh?: string, - timezone?: number + timezone?: number, + customInstanceDetails?: string ) { const loadImage = await axios.get(picture, { responseType: 'arraybuffer' }); const uploadedPicture = await simpleUpload( @@ -69,7 +70,8 @@ export class IntegrationService { username, isBetweenSteps, refresh, - timezone + timezone, + customInstanceDetails ); } diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma index 3944ec4d..a2858330 100644 --- a/libraries/nestjs-libraries/src/database/prisma/schema.prisma +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -242,27 +242,28 @@ model Subscription { } model Integration { - id String @id @default(cuid()) - internalId String - organizationId String - name String - organization Organization @relation(fields: [organizationId], references: [id]) - picture String? - providerIdentifier String - type String - token String - disabled Boolean @default(false) - tokenExpiration DateTime? - refreshToken String? - posts Post[] - profile String? - deletedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime? @updatedAt - orderItems OrderItems[] - inBetweenSteps Boolean @default(false) - refreshNeeded Boolean @default(false) - postingTimes String @default("[{\"time\":120}, {\"time\":400}, {\"time\":700}]") + id String @id @default(cuid()) + internalId String + organizationId String + name String + organization Organization @relation(fields: [organizationId], references: [id]) + picture String? + providerIdentifier String + type String + token String + disabled Boolean @default(false) + tokenExpiration DateTime? + refreshToken String? + posts Post[] + profile String? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt + orderItems OrderItems[] + inBetweenSteps Boolean @default(false) + refreshNeeded Boolean @default(false) + postingTimes String @default("[{\"time\":120}, {\"time\":400}, {\"time\":700}]") + customInstanceDetails String? @@index([updatedAt]) @@index([deletedAt]) diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index 00ef821d..2133c05e 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -17,8 +17,10 @@ import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/soc import { ThreadsProvider } from '@gitroom/nestjs-libraries/integrations/social/threads.provider'; import { DiscordProvider } from '@gitroom/nestjs-libraries/integrations/social/discord.provider'; import { SlackProvider } from '@gitroom/nestjs-libraries/integrations/social/slack.provider'; +import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider'; +import { MastodonCustomProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.custom.provider'; -const socialIntegrationList = [ +const socialIntegrationList: SocialProvider[] = [ new XProvider(), new LinkedinProvider(), new LinkedinPageProvider(), @@ -32,6 +34,8 @@ const socialIntegrationList = [ new DribbbleProvider(), new DiscordProvider(), new SlackProvider(), + new MastodonProvider(), + new MastodonCustomProvider(), ]; const articleIntegrationList = [ @@ -47,6 +51,7 @@ export class IntegrationManager { social: socialIntegrationList.map((p) => ({ name: p.name, identifier: p.identifier, + isExternal: !!p.externalUrl })), article: articleIntegrationList.map((p) => ({ name: p.name, diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts new file mode 100644 index 00000000..d3b11e44 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts @@ -0,0 +1,81 @@ +import { + ClientInformation, + PostDetails, + PostResponse, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; + +export class MastodonCustomProvider extends MastodonProvider { + override identifier = 'mastodon-custom'; + override name = 'M. Instance'; + async externalUrl(url: string) { + const form = new FormData(); + form.append('client_name', 'Postiz'); + form.append( + 'redirect_uris', + `${process.env.FRONTEND_URL}/integrations/social/mastodon` + ); + form.append('scopes', this.scopes.join(' ')); + form.append('website', process.env.FRONTEND_URL!); + const { client_id, client_secret } = await ( + await fetch(url + '/api/v1/apps', { + method: 'POST', + body: form, + }) + ).json(); + + return { + client_id, + client_secret, + }; + } + override async generateAuthUrl( + refresh?: string, + external?: ClientInformation + ) { + const state = makeId(6); + const url = this.generateUrlDynamic( + external?.instanceUrl!, + state, + external?.client_id!, + process.env.FRONTEND_URL!, + refresh + ); + + return { + url, + codeVerifier: makeId(10), + state, + }; + } + + override async authenticate( + params: { + code: string; + codeVerifier: string; + refresh?: string; + }, + clientInformation?: ClientInformation + ) { + return this.dynamicAuthenticate( + clientInformation?.client_id!, + clientInformation?.client_secret!, + clientInformation?.instanceUrl!, + params.code + ); + } + + override async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + return this.dynamicPost( + id, + accessToken, + 'https://mastodon.social', + postDetails + ); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts new file mode 100644 index 00000000..74198db5 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts @@ -0,0 +1,193 @@ +import { + AuthTokenDetails, + ClientInformation, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import dayjs from 'dayjs'; + +export class MastodonProvider extends SocialAbstract implements SocialProvider { + identifier = 'mastodon'; + name = 'Mastodon'; + isBetweenSteps = false; + scopes = ['write:statuses', 'profile', 'write:media']; + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + protected generateUrlDynamic( + customUrl: string, + state: string, + clientId: string, + url: string, + refresh?: string + ) { + return `${customUrl}/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent( + `${url}/integrations/social/mastodon${ + refresh ? `?refresh=${refresh}` : '' + }` + )}&scope=${this.scopes.join('+')}&state=${state}`; + } + + async generateAuthUrl(refresh?: string) { + const state = makeId(6); + const url = this.generateUrlDynamic( + 'https://mastodon.social', + state, + process.env.MASTODON_CLIENT_ID!, + process.env.FRONTEND_URL!, + refresh + ); + return { + url, + codeVerifier: makeId(10), + state, + }; + } + + protected async dynamicAuthenticate( + clientId: string, + clientSecret: string, + url: string, + code: string + ) { + const form = new FormData(); + form.append('client_id', clientId); + form.append('client_secret', clientSecret); + form.append('code', code); + form.append('grant_type', 'authorization_code'); + form.append( + 'redirect_uri', + `${process.env.FRONTEND_URL}/integrations/social/mastodon` + ); + form.append('scope', this.scopes.join(' ')); + + const tokenInformation = await ( + await this.fetch(`${url}/oauth/token`, { + method: 'POST', + body: form, + }) + ).json(); + + const personalInformation = await ( + await this.fetch(`${url}/api/v1/accounts/verify_credentials`, { + headers: { + Authorization: `Bearer ${tokenInformation.access_token}`, + }, + }) + ).json(); + + return { + id: personalInformation.id, + name: personalInformation.display_name || personalInformation.acct, + accessToken: tokenInformation.access_token, + refreshToken: 'null', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + picture: personalInformation.avatar, + username: personalInformation.username, + }; + } + + async authenticate( + params: { + code: string; + codeVerifier: string; + refresh?: string; + } + ) { + return this.dynamicAuthenticate( + process.env.MASTODON_CLIENT_ID!, + process.env.MASTODON_CLIENT_SECRET!, + 'https://mastodon.social', + params.code + ); + } + + async uploadFile(instanceUrl: string, fileUrl: string, accessToken: string) { + const form = new FormData(); + form.append('file', await fetch(fileUrl).then((r) => r.blob())); + const media = await ( + await this.fetch(`${instanceUrl}/api/v1/media`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: form, + }) + ).json(); + return media.id; + } + + async dynamicPost( + id: string, + accessToken: string, + url: string, + postDetails: PostDetails[] + ): Promise { + let loadId = ''; + const ids = [] as string[]; + for (const getPost of postDetails) { + const uploadFiles = await Promise.all( + getPost?.media?.map((media) => + this.uploadFile(url, media.url, accessToken) + ) || [] + ); + + const form = new FormData(); + form.append('status', getPost.message); + form.append('visibility', 'public'); + if (loadId) { + form.append('in_reply_to_id', loadId); + } + if (uploadFiles.length) { + for (const file of uploadFiles) { + form.append('media_ids[]', file); + } + } + + const post = await ( + await this.fetch(`${url}/api/v1/statuses`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: form, + }) + ).json(); + + ids.push(post.id); + loadId = loadId || post.id; + } + + return postDetails.map((p, i) => ({ + id: p.id, + postId: ids[i], + releaseURL: `${url}/statuses/${ids[i]}`, + status: 'completed', + })); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + return this.dynamicPost( + id, + accessToken, + 'https://mastodon.social', + postDetails + ); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts index e3b232bb..b36ac6cb 100644 --- a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -1,13 +1,24 @@ import { Integration } from '@prisma/client'; +export interface ClientInformation { + client_id: string; + client_secret: string; + instanceUrl: string; +} export interface IAuthenticator { - authenticate(params: { - code: string; - codeVerifier: string; - refresh?: string; - }): Promise; + authenticate( + params: { + code: string; + codeVerifier: string; + refresh?: string; + }, + clientInformation?: ClientInformation + ): Promise; refreshToken(refreshToken: string): Promise; - generateAuthUrl(refresh?: string): Promise; + generateAuthUrl( + refresh?: string, + clientInformation?: ClientInformation + ): Promise; analytics?( id: string, accessToken: string, @@ -90,4 +101,7 @@ export interface SocialProvider name: string; isBetweenSteps: boolean; scopes: string[]; + externalUrl?: ( + url: string + ) => Promise<{ client_id: string; client_secret: string }>; }