diff --git a/apps/backend/src/api/routes/integrations.controller.ts b/apps/backend/src/api/routes/integrations.controller.ts
index f061ab5d..c5a41fc6 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..520804fe 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..1e48f2da
--- /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, ...all } = 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 }>;
}