feat: bluesky

This commit is contained in:
Nevo David 2024-10-05 21:54:52 +07:00
parent 934296955a
commit 3bfac7a38d
13 changed files with 519 additions and 50 deletions

View File

@ -288,15 +288,19 @@ export class IntegrationsController {
throw new Error('Integration not allowed');
}
const getCodeVerifier = await ioRedis.get(`login:${body.state}`);
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
const getCodeVerifier = integrationProvider.customFields
? 'none'
: await ioRedis.get(`login:${body.state}`);
if (!getCodeVerifier) {
throw new Error('Invalid state');
}
await ioRedis.del(`login:${body.state}`);
const integrationProvider =
this._integrationManager.getSocialIntegration(integration);
if (!integrationProvider.customFields) {
await ioRedis.del(`login:${body.state}`);
}
const details = integrationProvider.externalUrl
? await ioRedis.get(`external:${body.state}`)
@ -341,7 +345,13 @@ export class IntegrationsController {
integrationProvider.isBetweenSteps,
body.refresh,
+body.timezone,
details ? AuthService.fixedEncryption(details) : undefined
details
? AuthService.fixedEncryption(details)
: integrationProvider.customFields
? AuthService.fixedEncryption(
Buffer.from(body.code, 'base64').toString()
)
: undefined
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -4,6 +4,7 @@ export const dynamic = 'force-dynamic';
import { internalFetch } from '@gitroom/helpers/utils/internal.fetch';
import { redirect } from 'next/navigation';
import { Redirect } from '@gitroom/frontend/components/layout/redirect';
export default async function Page({
params: { provider },
@ -30,6 +31,22 @@ export default async function Page({
return redirect(`/launches?scope=missing`);
}
if (
data.status !== HttpStatusCode.Ok &&
data.status !== HttpStatusCode.Created
) {
return (
<>
<div className="mt-[50px] text-[50px]">
Could not add provider.
<br />
You are being redirected back
</div>
<Redirect url="/launches" delay={3000} />
</>
);
}
const { inBetweenSteps, id } = await data.json();
if (inBetweenSteps && !searchParams.refresh) {

View File

@ -1,7 +1,7 @@
'use client';
import { useModals } from '@mantine/modals';
import React, { FC, useCallback } from 'react';
import React, { FC, useCallback, useMemo } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { Input } from '@gitroom/react/form/input';
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
@ -12,6 +12,8 @@ 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';
import { object, string } from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
const resolver = classValidatorResolver(ApiKeyDto);
@ -181,55 +183,193 @@ export const UrlModal: FC<{
);
};
export const CustomVariables: FC<{
variables: Array<{
key: string;
label: string;
defaultValue?: string;
validation: string;
type: 'text' | 'password';
}>;
identifier: string;
gotoUrl(url: string): void;
}> = (props) => {
const { gotoUrl, identifier, variables } = props;
const modals = useModals();
const schema = useMemo(() => {
return object({
...variables.reduce((aIcc, item) => {
const splitter = item.validation.split('/');
const regex = new RegExp(
splitter.slice(1, -1).join('/'),
splitter.pop()
);
return {
...aIcc,
[item.key]: string()
.matches(regex, `${item.label} is invalid`)
.required(),
};
}, {}),
});
}, [variables]);
const methods = useForm({
mode: 'onChange',
resolver: yupResolver(schema),
values: variables.reduce(
(acc, item) => ({
...acc,
...(item.defaultValue ? { [item.key]: item.defaultValue } : {}),
}),
{}
),
});
const submit = useCallback(
async (data: FieldValues) => {
gotoUrl(
`/integrations/social/${identifier}?state=nostate&code=${Buffer.from(
JSON.stringify(data)
).toString('base64')}`
);
},
[variables]
);
return (
<div className="rounded-[4px] border border-customColor6 bg-sixth px-[16px] pb-[16px] relative">
<TopTitle title={`Custom URL`} />
<button
onClick={modals.closeAll}
className="outline-none absolute right-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root hover:bg-tableBorder cursor-pointer mantine-Modal-close mantine-1dcetaa"
type="button"
>
<svg
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
>
<path
d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z"
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
></path>
</svg>
</button>
<FormProvider {...methods}>
<form
className="gap-[8px] flex flex-col pt-[10px]"
onSubmit={methods.handleSubmit(submit)}
>
{variables.map((variable) => (
<div key={variable.key}>
<Input
label={variable.label}
name={variable.key}
type={variable.type == 'text' ? 'text' : 'password'}
/>
</div>
))}
<div>
<Button type="submit">Connect</Button>
</div>
</form>
</FormProvider>
</div>
);
};
export const AddProviderComponent: FC<{
social: Array<{ identifier: string; name: string; isExternal: boolean }>;
social: Array<{
identifier: string;
name: string;
isExternal: boolean;
customFields?: Array<{
key: string;
label: string;
validation: string;
type: 'text' | 'password';
}>;
}>;
article: Array<{ identifier: string; name: string }>;
update?: () => void;
}> = (props) => {
const { update } = props;
const { update, social, article } = props;
const { isGeneral } = useVariables();
const toaster = useToaster();
const router = useRouter();
const fetch = useFetch();
const modal = useModals();
const { social, article } = props;
const getSocialLink = useCallback(
(identifier: string, isExternal: boolean) => async () => {
const gotoIntegration = async (externalUrl?: string) => {
const { url, err } = await (
await fetch(
`/integrations/social/${identifier}${
externalUrl ? `?externalUrl=${externalUrl}` : ``
}`
)
).json();
(
identifier: string,
isExternal: boolean,
customFields?: Array<{
key: string;
label: string;
validation: string;
defaultValue?: string;
type: 'text' | 'password';
}>
) =>
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 ;
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: <UrlModal gotoUrl={gotoIntegration} />,
});
return;
}
window.location.href = url;
};
if (isExternal) {
modal.closeAll();
if (customFields) {
modal.closeAll();
modal.openModal({
title: '',
withCloseButton: false,
classNames: {
modal: 'bg-transparent text-textColor',
},
children: (
<UrlModal gotoUrl={gotoIntegration} />
),
});
modal.openModal({
title: '',
withCloseButton: false,
classNames: {
modal: 'bg-transparent text-textColor',
},
children: (
<CustomVariables
identifier={identifier}
gotoUrl={(url: string) => router.push(url)}
variables={customFields}
/>
),
});
return;
}
return;
}
await gotoIntegration();
},
await gotoIntegration();
},
[]
);
@ -281,7 +421,11 @@ export const AddProviderComponent: FC<{
{social.map((item) => (
<div
key={item.identifier}
onClick={getSocialLink(item.identifier, item.isExternal)}
onClick={getSocialLink(
item.identifier,
item.isExternal,
item.customFields
)}
className={
'w-[120px] h-[100px] bg-input text-textColor justify-center items-center flex flex-col gap-[10px] cursor-pointer'
}

View File

@ -0,0 +1,14 @@
import { FC } from 'react';
import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider';
const Empty: FC = (props) => {
return null;
};
export default withProvider(null, Empty, undefined, async (posts) => {
if (posts.some((p) => p.length > 4)) {
return 'There can be maximum 4 pictures in a post.';
}
return true;
});

View File

@ -16,6 +16,7 @@ import ThreadsProvider from '@gitroom/frontend/components/launches/providers/thr
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';
import BlueskyProvider from '@gitroom/frontend/components/launches/providers/bluesky/bluesky.provider';
export const Providers = [
{identifier: 'devto', component: DevtoProvider},
@ -35,6 +36,7 @@ export const Providers = [
{identifier: 'discord', component: DiscordProvider},
{identifier: 'slack', component: SlackProvider},
{identifier: 'mastodon', component: MastodonProvider},
{identifier: 'bluesky', component: BlueskyProvider},
];

View File

@ -0,0 +1,15 @@
'use client';
import { FC, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export const Redirect: FC<{url: string, delay: number}> = (props) => {
const { url, delay } = props;
const router = useRouter();
useEffect(() => {
setTimeout(() => {
router.push(url);
}, delay);
}, []);
return null;
}

View File

@ -18,6 +18,7 @@ import { ThreadsProvider } from '@gitroom/nestjs-libraries/integrations/social/t
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 { BlueskyProvider } from '@gitroom/nestjs-libraries/integrations/social/bluesky.provider';
// import { MastodonCustomProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.custom.provider';
const socialIntegrationList: SocialProvider[] = [
@ -35,6 +36,7 @@ const socialIntegrationList: SocialProvider[] = [
new DiscordProvider(),
new SlackProvider(),
new MastodonProvider(),
new BlueskyProvider(),
// new MastodonCustomProvider(),
];
@ -46,13 +48,16 @@ const articleIntegrationList = [
@Injectable()
export class IntegrationManager {
getAllIntegrations() {
async getAllIntegrations() {
return {
social: socialIntegrationList.map((p) => ({
name: p.name,
identifier: p.identifier,
isExternal: !!p.externalUrl
})),
social: await Promise.all(
socialIntegrationList.map(async (p) => ({
name: p.name,
identifier: p.identifier,
isExternal: !!p.externalUrl,
...(p.customFields ? { customFields: await p.customFields() } : {}),
}))
),
article: articleIntegrationList.map((p) => ({
name: p.name,
identifier: p.identifier,

View File

@ -0,0 +1,169 @@
import {
AuthTokenDetails,
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 { BskyAgent } from '@atproto/api';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import sharp from 'sharp';
export class BlueskyProvider extends SocialAbstract implements SocialProvider {
identifier = 'bluesky';
name = 'Bluesky';
isBetweenSteps = false;
scopes = ['write:statuses', 'profile', 'write:media'];
async customFields() {
return [
{
key: 'service',
label: 'Service',
defaultValue: 'https://bsky.social',
validation: `/^(https?:\\/\\/)?((([a-zA-Z0-9\\-_]{1,256}\\.[a-zA-Z]{2,6})|(([0-9]{1,3}\\.){3}[0-9]{1,3}))(:[0-9]{1,5})?)(\\/[^\\s]*)?$/`,
type: 'text' as const,
},
{
key: 'identifier',
label: 'Identifier',
validation: `/^.{3,}$/`,
type: 'text' as const,
},
{
key: 'password',
label: 'Password',
validation: `/^.{3,}$/`,
type: 'password' as const,
},
];
}
async refreshToken(refreshToken: string): Promise<AuthTokenDetails> {
return {
refreshToken: '',
expiresIn: 0,
accessToken: '',
id: '',
name: '',
picture: '',
username: '',
};
}
async generateAuthUrl(refresh?: string) {
const state = makeId(6);
return {
url: '',
codeVerifier: makeId(10),
state,
};
}
async authenticate(params: {
code: string;
codeVerifier: string;
refresh?: string;
}) {
const body = JSON.parse(Buffer.from(params.code, 'base64').toString());
const agent = new BskyAgent({
service: body.service,
});
const {
data: { accessJwt, refreshJwt, handle, did },
} = await agent.login({
identifier: body.identifier,
password: body.password,
});
const profile = await agent.getProfile({
actor: did,
});
return {
refreshToken: refreshJwt,
expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(),
accessToken: accessJwt,
id: did,
name: profile.data.displayName!,
picture: profile.data.avatar!,
username: profile.data.handle!,
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const body = JSON.parse(
AuthService.fixedDecryption(integration.customInstanceDetails!)
);
const agent = new BskyAgent({
service: body.service,
});
await agent.login({
identifier: body.identifier,
password: body.password,
});
let loadCid = '';
let loadUri = '';
for (const post of postDetails) {
const images = await Promise.all(
post.media?.map(async (p) => {
return await agent.uploadBlob(
new Blob([
await sharp(await (await fetch(p.url)).arrayBuffer())
.resize({ width: 400 })
.toBuffer(),
])
);
}) || []
);
const { cid, uri } = await agent.post({
text: post.message,
createdAt: new Date().toISOString(),
...(images.length
? {
embed: {
$type: 'app.bsky.embed.images',
images: images.map((p) => ({
// can be an array up to 4 values
alt: 'image', // the alt text
image: p.data.blob,
})),
},
}
: {}),
...(loadCid
? {
reply: {
root: {
uri: loadUri,
cid: loadCid,
},
parent: {
uri: loadUri,
cid: loadCid,
},
},
}
: {}),
});
loadCid = loadCid || cid;
loadUri = loadUri || uri;
}
return [];
}
}

View File

@ -1,6 +1,5 @@
import {
AuthTokenDetails,
ClientInformation,
PostDetails,
PostResponse,
SocialProvider,

View File

@ -98,6 +98,15 @@ export interface SocialProvider
ISocialMediaIntegration {
identifier: string;
refreshWait?: boolean;
customFields?: () => Promise<
{
key: string;
label: string;
defaultValue?: string;
validation: string;
type: 'text' | 'password';
}[]
>;
name: string;
isBetweenSteps: boolean;
scopes: string[];

84
package-lock.json generated
View File

@ -10,6 +10,7 @@
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@atproto/api": "^0.13.11",
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@casl/ability": "^6.5.0",
@ -463,6 +464,58 @@
"web-streams-polyfill": "^3.2.1"
}
},
"node_modules/@atproto/api": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.11.tgz",
"integrity": "sha512-YW+4WzZEGGj/SDYo9w+S2PkSaeSS+8Dosk21GFm4EFYq1eq7G0cxuMgvdcq6fov7f9zqsaTFQL2fA6cAgMA0ow==",
"dependencies": {
"@atproto/common-web": "^0.3.1",
"@atproto/lexicon": "^0.4.2",
"@atproto/syntax": "^0.3.0",
"@atproto/xrpc": "^0.6.3",
"await-lock": "^2.2.2",
"multiformats": "^9.9.0",
"tlds": "^1.234.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/common-web": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.1.tgz",
"integrity": "sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==",
"dependencies": {
"graphemer": "^1.4.0",
"multiformats": "^9.9.0",
"uint8arrays": "3.0.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/lexicon": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.2.tgz",
"integrity": "sha512-CXoOkhcdF3XVUnR2oNgCs2ljWfo/8zUjxL5RIhJW/UNLp/FSl+KpF8Jm5fbk8Y/XXVPGRAsv9OYfxyU/14N/pw==",
"dependencies": {
"@atproto/common-web": "^0.3.1",
"@atproto/syntax": "^0.3.0",
"iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0",
"zod": "^3.23.8"
}
},
"node_modules/@atproto/syntax": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.3.0.tgz",
"integrity": "sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA=="
},
"node_modules/@atproto/xrpc": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.3.tgz",
"integrity": "sha512-S3tRvOdA9amPkKLll3rc4vphlDitLrkN5TwWh5Tu/jzk7mnobVVE3akYgICV9XCNHKjWM+IAPxFFI2qi+VW6nQ==",
"dependencies": {
"@atproto/lexicon": "^0.4.2",
"zod": "^3.23.8"
}
},
"node_modules/@aws-crypto/crc32": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
@ -15780,6 +15833,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/await-lock": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
},
"node_modules/axe-core": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.0.tgz",
@ -24646,6 +24704,11 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/iso-datestring-validator": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz",
"integrity": "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="
},
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
@ -28889,6 +28952,11 @@
"multicast-dns": "cli.js"
}
},
"node_modules/multiformats": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
@ -36374,6 +36442,14 @@
"node": ">=14.0.0"
}
},
"node_modules/tlds": {
"version": "1.255.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.255.0.tgz",
"integrity": "sha512-tcwMRIioTcF/FcxLev8MJWxCp+GUALRhFEqbDoZrnowmKSGqPrl5pqS+Sut2m8BgJ6S4FExCSSpGffZ0Tks6Aw==",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/tldts": {
"version": "6.1.47",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.47.tgz",
@ -37068,6 +37144,14 @@
"node": ">=8"
}
},
"node_modules/uint8arrays": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.0.0.tgz",
"integrity": "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==",
"dependencies": {
"multiformats": "^9.4.2"
}
},
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",

View File

@ -30,6 +30,7 @@
},
"private": true,
"dependencies": {
"@atproto/api": "^0.13.11",
"@aws-sdk/client-s3": "^3.410.0",
"@aws-sdk/s3-request-presigner": "^3.410.0",
"@casl/ability": "^6.5.0",