diff --git a/apps/frontend/nuxt.config.ts b/apps/frontend/nuxt.config.ts index 693693853c..3fbc024304 100644 --- a/apps/frontend/nuxt.config.ts +++ b/apps/frontend/nuxt.config.ts @@ -207,10 +207,19 @@ export default defineNuxtConfig({ // @ts-ignore rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY, pyroBaseUrl: process.env.PYRO_BASE_URL, + intercomIdentitySecret: + process.env.INTERCOM_IDENTITY_SECRET || + // @ts-ignore + globalThis.INTERCOM_IDENTITY_SECRET, public: { apiBaseUrl: getApiUrl(), pyroBaseUrl: process.env.PYRO_BASE_URL, siteUrl: getDomain(), + intercomAppId: + process.env.INTERCOM_APP_ID || + // @ts-ignore + globalThis.INTERCOM_APP_ID || + 'ykeritl9', production: isProduction(), buildEnv: process.env.BUILD_ENV, preview: process.env.PREVIEW === 'true', diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 1c023e0cec..22012b479f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -63,6 +63,7 @@ "highlight.js": "^11.7.0", "intl-messageformat": "^10.7.7", "iso-3166-2": "1.0.0", + "jose": "^6.2.2", "js-yaml": "^4.1.0", "jszip": "^3.10.1", "lru-cache": "^11.2.4", diff --git a/apps/frontend/src/pages/hosting/manage/[id].vue b/apps/frontend/src/pages/hosting/manage/[id].vue index be76662c72..6ea25b17c4 100644 --- a/apps/frontend/src/pages/hosting/manage/[id].vue +++ b/apps/frontend/src/pages/hosting/manage/[id].vue @@ -414,17 +414,16 @@ const isLoading = ref(true) const isMounted = ref(true) const unsubscribers = ref<(() => void)[]>([]) const flags = useFeatureFlags() +const config = useRuntimeConfig() -const INTERCOM_APP_ID = ref('ykeritl9') -const auth = (await useAuth()) as unknown as { - value: { user: { id: string; username: string; email: string; created: string } } +type AuthUser = { + id: string + username: string + email?: string + created: string } -const userId = ref(auth.value?.user?.id ?? null) -const username = ref(auth.value?.user?.username ?? null) -const email = ref(auth.value?.user?.email ?? null) -const createdAt = ref( - auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null, -) + +const auth = (await useAuth()) as unknown as { value: { user: AuthUser | null } } const debug = useDebugLogger('ServerManage') const route = useNativeRoute() @@ -1332,6 +1331,22 @@ const openInstallLog = () => { }) } +async function initializeIntercom() { + if (!auth.value?.user) return + + try { + const intercomData = await $fetch<{ token: string }>('/api/intercom/messenger-jwt') + + Intercom({ + app_id: config.public.intercomAppId, + intercom_user_jwt: intercomData.token, + session_duration: 1000 * 60 * 60 * 24, + }) + } catch (error) { + console.warn('[PYROSERVERS][INTERCOM] failed to initialize secure support chat', error) + } +} + const cleanup = () => { isMounted.value = false @@ -1490,26 +1505,7 @@ onMounted(() => { }) } - if (username.value && email.value && userId.value && createdAt.value) { - const currentUser = auth.value?.user as any - const matches = - username.value === currentUser?.username && - email.value === currentUser?.email && - userId.value === currentUser?.id && - createdAt.value === Math.floor(new Date(currentUser?.created).getTime() / 1000) - - if (matches) { - Intercom({ - app_id: INTERCOM_APP_ID.value, - userId: userId.value, - name: username.value, - email: email.value, - created_at: createdAt.value, - }) - } else { - console.warn('[PYROSERVERS][INTERCOM] mismatch') - } - } + void initializeIntercom() DOMPurify.addHook( 'afterSanitizeAttributes', diff --git a/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts new file mode 100644 index 0000000000..047f85d865 --- /dev/null +++ b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts @@ -0,0 +1,99 @@ +import { type Labrinth, ModrinthApiError } from '@modrinth/api-client' +import { SignJWT } from 'jose' + +import { useServerModrinthClient } from '~/server/utils/api-client' + +type IntercomTokenResponse = { + token: string +} + +async function signIntercomUserJwt( + user: { id: string; username: string; email?: string; created: string }, + secret: string, +): Promise { + const createdAt = Math.floor(new Date(user.created).getTime() / 1000) + + const payload: Record = { + user_id: user.id, + name: user.username, + } + + if (user.email) { + payload.email = user.email + } + + if (Number.isFinite(createdAt)) { + payload.created_at = createdAt + } + + return await new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(new TextEncoder().encode(secret)) +} + +export default defineEventHandler(async (event): Promise => { + if (event.method !== 'GET') { + throw createError({ + statusCode: 405, + message: 'Method not allowed', + }) + } + + const authToken = getCookie(event, 'auth-token') + if (!authToken) { + throw createError({ + statusCode: 401, + message: 'Authentication required', + }) + } + + setHeader(event, 'cache-control', 'private, no-store, max-age=0') + + const config = useRuntimeConfig(event) + if (!config.intercomIdentitySecret) { + throw createError({ + statusCode: 500, + message: 'Intercom identity secret is not configured', + }) + } + + const client = useServerModrinthClient({ + event, + authToken, + }) + + let user: { id: string; username: string; email?: string; created: string } + try { + const currentUser = await client.request('/user', { + api: 'labrinth', + version: 2, + method: 'GET', + }) + user = { + id: currentUser.id, + username: currentUser.username, + email: currentUser.email, + created: currentUser.created, + } + } catch (error) { + if (error instanceof ModrinthApiError && error.statusCode === 401) { + throw createError({ + statusCode: 401, + message: 'Authentication required', + }) + } + + throw createError({ + statusCode: 502, + message: 'Failed to resolve current user', + }) + } + + const token = await signIntercomUserJwt(user, config.intercomIdentitySecret) + + return { + token, + } +}) diff --git a/apps/frontend/wrangler.jsonc b/apps/frontend/wrangler.jsonc index 5239054eb4..c63b22f592 100644 --- a/apps/frontend/wrangler.jsonc +++ b/apps/frontend/wrangler.jsonc @@ -24,6 +24,11 @@ "binding": "RATE_LIMIT_IGNORE_KEY", "store_id": "c9024fef252d4a53adf513feca64417d", "secret_name": "labrinth-production-ratelimit-key" + }, + { + "binding": "INTERCOM_IDENTITY_SECRET", + "store_id": "c9024fef252d4a53adf513feca64417d", + "secret_name": "intercom-identity-secret" } ], "version_metadata": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acb12e2cc8..d81867dc9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: iso-3166-2: specifier: 1.0.0 version: 1.0.0 + jose: + specifier: ^6.2.2 + version: 6.2.2 js-yaml: specifier: ^4.1.0 version: 4.1.1 @@ -6727,6 +6730,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -16355,6 +16361,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13 diff --git a/turbo.jsonc b/turbo.jsonc index 542fa76bec..ec7db4e25b 100644 --- a/turbo.jsonc +++ b/turbo.jsonc @@ -24,6 +24,8 @@ "CF_PAGES_*", "HEROKU_APP_NAME", "STRIPE_PUBLISHABLE_KEY", + "INTERCOM_APP_ID", + "INTERCOM_IDENTITY_SECRET", "PYRO_BASE_URL", "PROD_OVERRIDE", "PYRO_MASTER_KEY",