diff --git a/apps/frontend/public/icons/platforms/vk.png b/apps/frontend/public/icons/platforms/vk.png new file mode 100644 index 00000000..67ab342c Binary files /dev/null and b/apps/frontend/public/icons/platforms/vk.png differ diff --git a/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx b/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx index 5beb10e0..80725c24 100644 --- a/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx +++ b/apps/frontend/src/app/(site)/integrations/social/[provider]/continue/page.tsx @@ -22,6 +22,14 @@ export default async function Page({ }; } + if (provider === 'vk') { + searchParams = { + ...searchParams, + state: searchParams.state || '', + code: searchParams.code + '&&&&' + searchParams.device_id + }; + } + const data = await internalFetch(`/integrations/social/${provider}/connect`, { method: 'POST', body: JSON.stringify(searchParams), 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 0cb1e1a0..350a1acb 100644 --- a/apps/frontend/src/components/launches/providers/show.all.providers.tsx +++ b/apps/frontend/src/components/launches/providers/show.all.providers.tsx @@ -21,6 +21,7 @@ import LemmyProvider from '@gitroom/frontend/components/launches/providers/lemmy import WarpcastProvider from '@gitroom/frontend/components/launches/providers/warpcast/warpcast.provider'; import TelegramProvider from '@gitroom/frontend/components/launches/providers/telegram/telegram.provider'; import NostrProvider from '@gitroom/frontend/components/launches/providers/nostr/nostr.provider'; +import VkProvider from '@gitroom/frontend/components/launches/providers/vk/vk.provider'; export const Providers = [ {identifier: 'devto', component: DevtoProvider}, @@ -46,6 +47,7 @@ export const Providers = [ {identifier: 'wrapcast', component: WarpcastProvider}, {identifier: 'telegram', component: TelegramProvider}, {identifier: 'nostr', component: NostrProvider}, + {identifier: 'vk', component: VkProvider}, ]; diff --git a/apps/frontend/src/components/launches/providers/vk/vk.provider.tsx b/apps/frontend/src/components/launches/providers/vk/vk.provider.tsx new file mode 100644 index 00000000..d305e618 --- /dev/null +++ b/apps/frontend/src/components/launches/providers/vk/vk.provider.tsx @@ -0,0 +1,11 @@ +import { withProvider } from '@gitroom/frontend/components/launches/providers/high.order.provider'; + +export default withProvider( + null, + undefined, + undefined, + async (posts) => { + return true; + }, + 2048 +); diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts index 021f1e6b..cb61de76 100644 --- a/libraries/nestjs-libraries/src/integrations/integration.manager.ts +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -26,6 +26,7 @@ import { InstagramStandaloneProvider } from '@gitroom/nestjs-libraries/integrati import { FarcasterProvider } from '@gitroom/nestjs-libraries/integrations/social/farcaster.provider'; 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'; export const socialIntegrationList: SocialProvider[] = [ new XProvider(), @@ -48,6 +49,7 @@ export const socialIntegrationList: SocialProvider[] = [ new FarcasterProvider(), new TelegramProvider(), new NostrProvider(), + new VkProvider(), // new MastodonCustomProvider(), ]; diff --git a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts new file mode 100644 index 00000000..aef318f7 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts @@ -0,0 +1,246 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { createHash, randomBytes } from 'crypto'; +import axios from 'axios'; +import FormDataNew from 'form-data'; +import mime from 'mime-types'; + +export class VkProvider extends SocialAbstract implements SocialProvider { + identifier = 'vk'; + name = 'VK'; + isBetweenSteps = false; + scopes = [ + 'vkid.personal_info', + 'email', + 'wall', + 'status', + 'docs', + 'photos', + 'video', + ]; + + async refreshToken(refresh_token: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(32); + const codeVerifier = randomBytes(64).toString('base64url'); + const challenge = Buffer.from( + createHash('sha256').update(codeVerifier).digest() + ) + .toString('base64') + .replace(/=*$/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + return { + url: + 'https://id.vk.com/authorize' + + `?response_type=code` + + `&client_id=${process.env.VK_ID}` + + `&code_challenge_method=S256` + + `&code_challenge=${challenge}` + + `&redirect_uri=${encodeURIComponent( + `${ + process?.env.FRONTEND_URL?.indexOf('https') == -1 + ? `https://redirectmeto.com/${process?.env.FRONTEND_URL}` + : `${process?.env.FRONTEND_URL}` + }/integrations/social/vk` + )}` + + `&state=${state}` + + `&scope=${encodeURIComponent(this.scopes.join(' '))}`, + codeVerifier, + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const [code, device_id] = params.code.split('&&&&'); + + const formData = new FormData(); + formData.append('client_id', process.env.VK_ID!); + formData.append('grant_type', 'authorization_code'); + formData.append('code_verifier', params.codeVerifier); + formData.append('device_id', device_id); + formData.append('code', code); + formData.append( + 'redirect_uri', + `${ + process?.env.FRONTEND_URL?.indexOf('https') == -1 + ? `https://redirectmeto.com/${process?.env.FRONTEND_URL}` + : `${process?.env.FRONTEND_URL}` + }/integrations/social/vk` + ); + + const { access_token, scope, refresh_token } = await ( + await this.fetch('https://id.vk.com/oauth2/auth', { + method: 'POST', + body: formData, + }) + ).json(); + + const newFormData = new FormData(); + newFormData.append('client_id', process.env.VK_ID!); + newFormData.append('access_token', access_token); + + const { + user: { user_id, first_name, last_name, avatar }, + } = await ( + await this.fetch('https://id.vk.com/oauth2/user_info', { + method: 'POST', + body: newFormData, + }) + ).json(); + + return { + id: user_id, + name: first_name + ' ' + last_name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(), + picture: avatar, + username: first_name.toLowerCase(), + }; + } + + async post( + userId: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + let replyTo = ''; + const values: PostResponse[] = []; + + const uploading = await Promise.all( + postDetails.map(async (post) => { + return await Promise.all( + (post?.media || []).map(async (media) => { + const all = await ( + await this.fetch( + media.url.indexOf('mp4') > -1 + ? `https://api.vk.com/method/video.save?access_token=${accessToken}&v=5.251` + : `https://api.vk.com/method/photos.getWallUploadServer?owner_id=${userId}&access_token=${accessToken}&v=5.251` + ) + ).json(); + + const { data } = await axios.get(media.url!, { + responseType: 'stream', + }); + + const slash = media.url.split('/').at(-1); + + const formData = new FormDataNew(); + formData.append('photo', data, { + filename: slash, + contentType: mime.lookup(slash!) || '', + }); + const value = ( + await axios.post(all.response.upload_url, formData, { + headers: { + ...formData.getHeaders(), + }, + }) + ).data; + + if (media.url.indexOf('mp4') > -1) { + return { + id: all.response.video_id, + type: 'video', + }; + } + + const formSend = new FormData(); + formSend.append('photo', value.photo); + formSend.append('server', value.server); + formSend.append('hash', value.hash); + + const { id } = ( + await ( + await fetch( + `https://api.vk.com/method/photos.saveWallPhoto?access_token=${accessToken}&v=5.251`, + { + method: 'POST', + body: formSend, + } + ) + ).json() + ).response[0]; + + return { + id, + type: 'photo', + }; + }) + ); + }) + ); + + let i = 0; + for (const post of postDetails) { + const list = (uploading?.[i] || []); + + const body = new FormData(); + body.append('message', post.message); + if (replyTo) { + body.append('post_id', replyTo); + } + + if (list.length) { + body.append( + 'attachments', + list.map((p) => `${p.type}${userId}_${p.id}`).join(',') + ); + } + + const { response, ...all } = await ( + await this.fetch( + `https://api.vk.com/method/${ + replyTo ? 'wall.createComment' : 'wall.post' + }?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`, + { + method: 'POST', + body, + } + ) + ).json(); + + + values.push({ + id: post.id, + postId: String(response?.post_id || response?.comment_id), + releaseURL: `https://vk.com/feed?w=wall${userId}_${ + response?.post_id || replyTo + }`, + status: 'completed', + }); + + if (!replyTo) { + replyTo = response.post_id; + } + + i++; + } + + return values; + } +}