diff --git a/.env.example b/.env.example index fb227a93..100a5951 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,14 @@ NUXT_PUBLIC_MAINTENANCE=false NUXT_PUBLIC_TESTING=true #PROXY_DOMAIN=rotki.com #PROXY_INSECURE=true #When set it will proxy to http instead of https + +## FOR DISCORD FUNCTIONALITY +# RECAPTCHA VERIFICATION +NUXT_RECAPTCHA_SECRET= + +# DISCORD +NUXT_DISCORD_APP_ID= +NUXT_DISCORD_TOKEN= +NUXT_DISCORD_PUBLIC_KEY= +NUXT_DISCORD_GUILD_ID= +NUXT_DISCORD_CHANNEL_ID= diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 638498ac..4606cc02 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -9,7 +9,7 @@ module.exports = { { registeredComponentsOnly: false, // components are only exported in kebab-case - ignores: ['i18n-t'], + ignores: ['i18n-t', 'i18n-d'], }, ], }, diff --git a/components/discord/DiscordFooter.vue b/components/discord/DiscordFooter.vue new file mode 100644 index 00000000..f05771b9 --- /dev/null +++ b/components/discord/DiscordFooter.vue @@ -0,0 +1,24 @@ + + + + + + + + + © Rotki Solutions GmbH 2018-{{ year }}. + All Rights Reserved. + + + + + + + + diff --git a/components/discord/DiscordHeader.vue b/components/discord/DiscordHeader.vue new file mode 100644 index 00000000..c0401ed7 --- /dev/null +++ b/components/discord/DiscordHeader.vue @@ -0,0 +1,21 @@ + + + + + + + + {{ text }} + + + + + diff --git a/components/discord/DiscordVerification.vue b/components/discord/DiscordVerification.vue new file mode 100644 index 00000000..76c82a60 --- /dev/null +++ b/components/discord/DiscordVerification.vue @@ -0,0 +1,83 @@ + + + + + + + {{ t('discord.chat') }} + {{ t('discord.title') }} + + + {{ t('discord.description') }} + + + + + + {{ inviteLink }} + + + + + + {{ expiry.toLocaleString() }} + + + + + + + + diff --git a/components/footer/FooterIconLinks.vue b/components/footer/FooterIconLinks.vue index 166c2702..f2e2acc9 100644 --- a/components/footer/FooterIconLinks.vue +++ b/components/footer/FooterIconLinks.vue @@ -1,7 +1,17 @@ diff --git a/layouts/discord.vue b/layouts/discord.vue new file mode 100644 index 00000000..0c7eb90c --- /dev/null +++ b/layouts/discord.vue @@ -0,0 +1,5 @@ + + + + + diff --git a/locales/en.json b/locales/en.json index 75c6f490..64de75e6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -12,6 +12,16 @@ "get_in_touch": "Get In Touch Now", "go_back_home": "Go back home" }, + "discord": { + "title": "Join rotki's Discord", + "description": "You will get an invite to rotki's discord after solving the captcha below.", + "link": "Learn more at {link}", + "chat": "Chat with us", + "invite": { + "link": "You can use the link {link} to join our Discord.", + "expiry": "The link is valid until {expiry}." + } + }, "supported_defi": { "title": "Dedicated DeFi support for", "caption": "* All DeFi protocols are supported, these are just the ones we have dedicated views for." diff --git a/nuxt.config.ts b/nuxt.config.ts index 4595c0ed..07a6f24f 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -124,6 +124,14 @@ export default defineNuxtConfig({ }, runtimeConfig: { + recaptchaSecret: '', + discord: { + appId: '', + token: '', + publicKey: '', + guildId: 0, + channelId: 0, + }, public: { recaptcha: { siteKey: '', diff --git a/pages/discord.vue b/pages/discord.vue new file mode 100644 index 00000000..22ade27a --- /dev/null +++ b/pages/discord.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/server/routes/_api/discord_invite.post.ts b/server/routes/_api/discord_invite.post.ts new file mode 100644 index 00000000..1961ea77 --- /dev/null +++ b/server/routes/_api/discord_invite.post.ts @@ -0,0 +1,70 @@ +import { FetchError } from 'ofetch'; +import { $fetch } from 'ofetch/node'; +import { + CaptchaVerification, + type DiscordInvite, + DiscordInviteBody, + DiscordInviteResponse, +} from '~/types/discord'; +import { discordRequest } from '~/utils/discord'; + +export default defineEventHandler(async (event) => { + const { + recaptchaSecret, + discord: { token, channelId }, + } = useRuntimeConfig(); + const requestBody = await readBody(event); + const body = DiscordInviteBody.parse(requestBody); + + const response = CaptchaVerification.parse( + await $fetch('https://www.google.com/recaptcha/api/siteverify', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams( + Object.entries({ + secret: recaptchaSecret, + response: body.captcha, + }), + ).toString(), + }), + ); + + if (!response.success) { + throw createError({ + statusCode: 400, + statusMessage: response['error-codes']?.join(',') ?? '', + }); + } + + try { + const discordResponse = await discordRequest( + `/channels/${channelId}/invites`, + { + method: 'POST', + // https://discord.com/developers/docs/resources/channel#create-channel-invite + body: { + max_age: 1800, + max_uses: 1, + unique: true, + }, + }, + token, + ); + + return DiscordInviteResponse.parse(discordResponse); + } catch (e: any) { + if (e instanceof FetchError) { + throw createError({ + statusCode: e.statusCode, + statusMessage: e.statusMessage, + }); + } + + throw createError({ + statusCode: 500, + statusMessage: e.message, + }); + } +}); diff --git a/types/discord/index.ts b/types/discord/index.ts new file mode 100644 index 00000000..300cc154 --- /dev/null +++ b/types/discord/index.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const DiscordInviteBody = z.object({ + captcha: z.string(), +}); + +export const CaptchaVerification = z.object({ + success: z.boolean(), + challenge_ts: z.string().optional(), + hostname: z.string().optional(), + 'error-codes': z.array(z.string()).optional(), +}); + +export type CaptchaVerification = z.infer; + +export const DiscordInviteResponse = z.object({ + code: z.string(), + expires_at: z.coerce.date(), +}); + +export type DiscordInvite = z.infer; diff --git a/utils/discord/index.ts b/utils/discord/index.ts new file mode 100644 index 00000000..009e204d --- /dev/null +++ b/utils/discord/index.ts @@ -0,0 +1,19 @@ +import { $fetch, type FetchOptions, type FetchRequest } from 'ofetch/node'; + +export async function discordRequest< + Resp, + Req extends FetchRequest = FetchRequest, +>(endpoint: Req, options: FetchOptions<'json'>, token: string): Promise { + const url = `https://discord.com/api/v10/${endpoint}`; + if (options.body) { + options.body = JSON.stringify(options.body); + } + return await $fetch(url, { + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json; charset=UTF-8', + 'User-Agent': 'RotkiBot', + }, + ...options, + }); +}