diff --git a/.github/workflows/frontend-docker.yml b/.github/workflows/frontend-docker.yml new file mode 100644 index 0000000000..484f13125c --- /dev/null +++ b/.github/workflows/frontend-docker.yml @@ -0,0 +1,104 @@ +name: Frontend Docker + +on: + push: + branches: + - main + - prod + paths: + - 'apps/frontend/**/*' + - 'packages/ui/**/*' + - 'packages/utils/**/*' + - 'packages/assets/**/*' + - 'packages/moderation/**/*' + - 'packages/api-client/**/*' + - 'packages/blog/**/*' + - '**/pnpm-*.yaml' + - .github/workflows/frontend-docker.yml + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/prod' }} + +jobs: + build: + name: Build frontend + runs-on: namespace-profile-modrinth-frontend + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Configure environment + id: meta + env: + REF: ${{ github.ref }} + run: | + if [ "$REF" = "refs/heads/main" ]; then + echo "env=staging" >> $GITHUB_OUTPUT + else + echo "env=production" >> $GITHUB_OUTPUT + fi + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + + - name: Enable Corepack + run: corepack enable + + - name: Set up caches + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + cache: | + pnpm + + - name: Inject build variables + working-directory: ./apps/frontend + run: | + if [ "${{ steps.meta.outputs.env }}" == "staging" ]; then + echo "Injecting staging variables from wrangler.jsonc..." + jq -r '.env.staging.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV + else + echo "Injecting production variables from wrangler.jsonc..." + jq -r '.vars | to_entries[] | "\(.key)=\(.value)"' wrangler.jsonc >> $GITHUB_ENV + fi + + - name: Install dependencies + working-directory: ./apps/frontend + run: pnpm install + + # nuxi is called directly because `pnpm build` hardcodes NODE_OPTIONS, which would + # drop the semi-space bump that cuts roughly 5% off the build. + - name: Build frontend + working-directory: ./apps/frontend + run: pnpm exec nuxi build + env: + NITRO_PRESET: node-server + NODE_OPTIONS: '--max-old-space-size=8192 --max-semi-space-size=64' + BUILD_ENV: ${{ steps.meta.outputs.env }} + CF_PAGES_BRANCH: ${{ github.ref_name }} + CF_PAGES_COMMIT_SHA: ${{ github.sha }} + + - name: Stage Docker context + run: | + mkdir -p apps/frontend/docker-stage + cp -r apps/frontend/.output apps/frontend/docker-stage/.output + + - name: Upload Docker context + uses: namespace-actions/upload-artifact@f6ccaacc655aec41b93af180d1d7eef21af862d2 # v1.0.3 + with: + name: frontend-docker-context + retention-days: 1 + path: apps/frontend/docker-stage + + docker-build: + needs: [build] + uses: SparkUniverse/workflows/.github/workflows/docker-build.yaml@main + with: + image-name: frontend + dockerfile-path: apps/frontend/Dockerfile + artifacts-name: frontend-docker-context diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 0000000000..439ea2fefa --- /dev/null +++ b/apps/frontend/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1 + +FROM node:24-slim + +LABEL org.opencontainers.image.source=https://github.com/modrinth/code +LABEL org.opencontainers.image.title=frontend +LABEL org.opencontainers.image.description="Modrinth website" +LABEL org.opencontainers.image.licenses=AGPL-3.0-only + +RUN apt-get update \ + && apt-get install -y --no-install-recommends dumb-init \ + && rm -rf /var/lib/apt/lists/* + +ENV NODE_ENV=production +ENV HOST=0.0.0.0 +ENV PORT=3000 + +WORKDIR /app + +# Nitro bundles every runtime dependency into .output, so no node_modules is needed. +COPY --chown=node:node .output ./.output + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/robots.txt').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" + +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "/app/.output/server/index.mjs"] diff --git a/apps/frontend/Dockerfile.dockerignore b/apps/frontend/Dockerfile.dockerignore new file mode 100644 index 0000000000..4d88ab014b --- /dev/null +++ b/apps/frontend/Dockerfile.dockerignore @@ -0,0 +1,2 @@ +* +!.output diff --git a/apps/frontend/nuxt.config.ts b/apps/frontend/nuxt.config.ts index 7322ac32a9..2bc14539f4 100644 --- a/apps/frontend/nuxt.config.ts +++ b/apps/frontend/nuxt.config.ts @@ -122,23 +122,6 @@ export default defineNuxtConfig({ }, }, hooks: { - async 'nitro:config'(nitroConfig) { - const emailTemplates = Object.keys( - await import('./src/templates/emails/index.ts').then((m) => m.default), - ) - const docTemplates = Object.keys( - await import('./src/templates/docs/index.ts').then((m) => m.default), - ) - - nitroConfig.prerender = nitroConfig.prerender || {} - nitroConfig.prerender.routes = nitroConfig.prerender.routes || [] - for (const template of emailTemplates) { - nitroConfig.prerender.routes.push(`/_internal/templates/email/${template}`) - } - for (const template of docTemplates) { - nitroConfig.prerender.routes.push(`/_internal/templates/doc/${template}`) - } - }, async 'build:before'() { // 30 minutes const TTL = 30 * 60 * 1000 @@ -290,6 +273,7 @@ export default defineNuxtConfig({ external: ['cloudflare:workers'], }, preset: 'cloudflare_module', + noExternals: true, cloudflare: { nodeCompat: true, }, @@ -325,14 +309,12 @@ export default defineNuxtConfig({ redirect: '/_internal/templates/email/**', }, '/_internal/templates/email/**': { - prerender: true, headers: { 'Content-Type': 'text/html', 'Cache-Control': 'public, max-age=3600', }, }, '/_internal/templates/doc/**': { - prerender: true, headers: { 'Content-Type': 'text/html', 'Cache-Control': 'public, max-age=3600', diff --git a/apps/frontend/src/composables/fetch.js b/apps/frontend/src/composables/fetch.js index 6533fb1ce4..dfa6650392 100644 --- a/apps/frontend/src/composables/fetch.js +++ b/apps/frontend/src/composables/fetch.js @@ -3,7 +3,10 @@ * This composable is kept for legacy code that hasn't been migrated yet. */ +import { useVisitorUserAgent } from '~/composables/visitor-user-agent.ts' import { withLabrinthCanaryHeader } from '~/helpers/canary.ts' +import { readEnv } from '~/helpers/env.ts' +import { getFrontendUserAgent, VISITOR_USER_AGENT_HEADER } from '~/helpers/user-agent.ts' let cachedRateLimitKey = undefined let rateLimitKeyPromise = undefined @@ -13,15 +16,7 @@ async function getRateLimitKey(config) { if (cachedRateLimitKey !== undefined) return cachedRateLimitKey if (!rateLimitKeyPromise) { - rateLimitKeyPromise = (async () => { - try { - const mod = 'cloudflare:workers' - const { env } = await import(/* @vite-ignore */ mod) - return await env.RATE_LIMIT_IGNORE_KEY?.get() - } catch { - return undefined - } - })() + rateLimitKeyPromise = readEnv('RATE_LIMIT_IGNORE_KEY') } cachedRateLimitKey = await rateLimitKeyPromise @@ -40,6 +35,12 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => { if (import.meta.server) { options.headers['x-ratelimit-key'] = await getRateLimitKey(config) + options.headers['User-Agent'] = getFrontendUserAgent(config.public.hash) + + const visitorUserAgent = useVisitorUserAgent() + if (visitorUserAgent) { + options.headers[VISITOR_USER_AGENT_HEADER] = visitorUserAgent + } } if (!skipAuth) { diff --git a/apps/frontend/src/composables/visitor-user-agent.ts b/apps/frontend/src/composables/visitor-user-agent.ts new file mode 100644 index 0000000000..24fff46660 --- /dev/null +++ b/apps/frontend/src/composables/visitor-user-agent.ts @@ -0,0 +1,9 @@ +import { tryUseNuxtApp } from '#imports' + +// Nuxt's `useRequestHeaders` throws outside of a setup context, and `useBaseFetch` is +// called from plenty of places that no longer have one. +export function useVisitorUserAgent(): string | undefined { + if (!import.meta.server) return undefined + + return tryUseNuxtApp()?.ssrContext?.event?.node?.req?.headers['user-agent'] +} diff --git a/apps/frontend/src/helpers/api.ts b/apps/frontend/src/helpers/api.ts index fba7385268..4a2af135c0 100644 --- a/apps/frontend/src/helpers/api.ts +++ b/apps/frontend/src/helpers/api.ts @@ -14,18 +14,10 @@ import { import type { Ref } from 'vue' import { useFeatureFlags } from '~/composables/featureFlags.ts' +import { useVisitorUserAgent } from '~/composables/visitor-user-agent.ts' import { withStagingArchonBaseUrl } from '~/helpers/archon.ts' - -async function getRateLimitKeyFromSecretsStore(): Promise { - try { - const mod = 'cloudflare:workers' - const { env } = await import(/* @vite-ignore */ mod) - return await env.RATE_LIMIT_IGNORE_KEY?.get() - } catch { - // Not running in Cloudflare Workers environment - return undefined - } -} +import { readEnv } from '~/helpers/env.ts' +import { getFrontendUserAgent, VISITOR_USER_AGENT_HEADER } from '~/helpers/user-agent.ts' export function createModrinthClient( auth: Ref<{ token: string | undefined }>, @@ -33,10 +25,12 @@ export function createModrinthClient( apiBaseUrl: string archonBaseUrl: string sharedInstancesBaseUrl: string + commitHash: string rateLimitKey?: string }, ): NuxtModrinthClient { const flags = useFeatureFlags() + const visitorUserAgent = useVisitorUserAgent() const optionalFeatures = [ import.meta.dev ? (new VerboseLoggingFeature() as AbstractFeature) : undefined, ].filter(Boolean) as AbstractFeature[] @@ -46,8 +40,10 @@ export function createModrinthClient( archonBaseUrl: () => withStagingArchonBaseUrl(config.archonBaseUrl, flags.value.archonApiStaging), sharedInstancesBaseUrl: config.sharedInstancesBaseUrl, + userAgent: () => (import.meta.server ? getFrontendUserAgent(config.commitHash) : undefined), + headers: visitorUserAgent ? { [VISITOR_USER_AGENT_HEADER]: visitorUserAgent } : undefined, archonSentryCapture: () => flags.value.archonSentryCapture, - rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore, + rateLimitKey: config.rateLimitKey || (() => readEnv('RATE_LIMIT_IGNORE_KEY')), features: [ // for modrinth hosting // is skipped for normal reqs diff --git a/apps/frontend/src/helpers/env.ts b/apps/frontend/src/helpers/env.ts new file mode 100644 index 0000000000..f7bc455177 --- /dev/null +++ b/apps/frontend/src/helpers/env.ts @@ -0,0 +1,22 @@ +type SecretsStoreBinding = { get: () => Promise } + +// Cloudflare exposes plain vars and Secrets Store bindings on the Workers env. Every +// other runtime (the Docker image, local dev) only has process.env. +async function getWorkersEnv(): Promise | undefined> { + try { + const mod = 'cloudflare:workers' + const { env } = await import(/* @vite-ignore */ mod) + return env as Record + } catch { + return undefined + } +} + +export async function readEnv(name: string): Promise { + const binding = (await getWorkersEnv())?.[name] + + if (typeof binding === 'string') return binding + if (binding) return await (binding as SecretsStoreBinding).get() + + return globalThis.process?.env?.[name] +} diff --git a/apps/frontend/src/helpers/user-agent.ts b/apps/frontend/src/helpers/user-agent.ts new file mode 100644 index 0000000000..16216e8cf1 --- /dev/null +++ b/apps/frontend/src/helpers/user-agent.ts @@ -0,0 +1,5 @@ +export const VISITOR_USER_AGENT_HEADER = 'X-Forwarded-User-Agent' + +export function getFrontendUserAgent(commitHash: string): string { + return `modrinth/frontend/${commitHash || 'unknown'} (support@modrinth.com)` +} diff --git a/apps/frontend/src/providers/setup/modrinth-client.ts b/apps/frontend/src/providers/setup/modrinth-client.ts index a6f2c89118..1d2749a077 100644 --- a/apps/frontend/src/providers/setup/modrinth-client.ts +++ b/apps/frontend/src/providers/setup/modrinth-client.ts @@ -8,6 +8,7 @@ export function setupModrinthClientProvider(auth: Awaited { - try { - const mod = 'cloudflare:workers' - const { env } = await import(/* @vite-ignore */ mod) - const cfEnv = env as any - const config = useRuntimeConfig(event) +import { readEnv } from '~/helpers/env' - if (cfEnv.CF_PAGES_URL) config.public.siteUrl = cfEnv.CF_PAGES_URL - if (cfEnv.BROWSER_BASE_URL) config.public.apiBaseUrl = cfEnv.BROWSER_BASE_URL - if (cfEnv.BASE_URL) config.apiBaseUrl = cfEnv.BASE_URL - if (cfEnv.PYRO_BASE_URL) { - config.public.pyroBaseUrl = cfEnv.PYRO_BASE_URL - config.pyroBaseUrl = cfEnv.PYRO_BASE_URL - } - if (cfEnv.STRIPE_PUBLISHABLE_KEY) - config.public.stripePublishableKey = cfEnv.STRIPE_PUBLISHABLE_KEY - } catch { - /* empty */ +export default defineEventHandler(async (event) => { + const config = useRuntimeConfig(event) + + const siteUrl = await readEnv('CF_PAGES_URL') + const browserBaseUrl = await readEnv('BROWSER_BASE_URL') + const baseUrl = await readEnv('BASE_URL') + const pyroBaseUrl = await readEnv('PYRO_BASE_URL') + const stripePublishableKey = await readEnv('STRIPE_PUBLISHABLE_KEY') + + if (siteUrl) config.public.siteUrl = siteUrl + if (browserBaseUrl) config.public.apiBaseUrl = browserBaseUrl + if (baseUrl) config.apiBaseUrl = baseUrl + if (pyroBaseUrl) { + config.public.pyroBaseUrl = pyroBaseUrl + config.pyroBaseUrl = pyroBaseUrl } + if (stripePublishableKey) config.public.stripePublishableKey = stripePublishableKey }) 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 index 25bc71d63c..398a72b2f5 100644 --- a/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts +++ b/apps/frontend/src/server/routes/api/intercom/messenger-jwt.get.ts @@ -1,23 +1,13 @@ import { type Labrinth, ModrinthApiError } from '@modrinth/api-client' import { SignJWT } from 'jose' +import { readEnv } from '~/helpers/env' import { useServerModrinthClient } from '~/server/utils/api-client' type IntercomTokenResponse = { token: string } -async function getIntercomKeyFromSecretsStore(): Promise { - try { - const mod = 'cloudflare:workers' - const { env } = await import(/* @vite-ignore */ mod) - return await env.INTERCOM_IDENTITY_SECRET?.get() - } catch { - // Not running in Cloudflare Workers environment - return undefined - } -} - async function signIntercomUserJwt( user: { id: string; username: string; email?: string; created: string }, secret: string, @@ -72,7 +62,7 @@ export default defineEventHandler(async (event): Promise setHeader(event, 'cache-control', 'private, no-store, max-age=0') const intercomSecret = - (await getIntercomKeyFromSecretsStore()) ?? useRuntimeConfig(event).intercomIdentitySecret + (await readEnv('INTERCOM_IDENTITY_SECRET')) ?? useRuntimeConfig(event).intercomIdentitySecret if (!intercomSecret) { throw createError({ diff --git a/apps/frontend/src/server/utils/api-client.ts b/apps/frontend/src/server/utils/api-client.ts index 193a8d09ad..9350b05cef 100644 --- a/apps/frontend/src/server/utils/api-client.ts +++ b/apps/frontend/src/server/utils/api-client.ts @@ -5,17 +5,10 @@ import { type NuxtClientConfig, NuxtModrinthClient, } from '@modrinth/api-client' -import type { H3Event } from 'h3' +import { getRequestHeader, type H3Event } from 'h3' -async function getRateLimitKeyFromSecretsStore(): Promise { - try { - const mod = 'cloudflare:workers' - const { env } = await import(/* @vite-ignore */ mod) - return await env.RATE_LIMIT_IGNORE_KEY?.get() - } catch { - return undefined - } -} +import { readEnv } from '~/helpers/env' +import { getFrontendUserAgent, VISITOR_USER_AGENT_HEADER } from '~/helpers/user-agent' export interface ServerModrinthClientOptions { event?: H3Event @@ -28,6 +21,10 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions): const sharedInstancesBaseUrl = config.sharedInstancesBaseUrl || config.public.sharedInstancesBaseUrl + const visitorUserAgent = options?.event + ? getRequestHeader(options.event, 'user-agent') + : undefined + const features = [] if (options?.authToken) { @@ -42,7 +39,9 @@ export function useServerModrinthClient(options?: ServerModrinthClientOptions): const clientConfig: NuxtClientConfig = { labrinthBaseUrl: apiBaseUrl, sharedInstancesBaseUrl, - rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore, + userAgent: getFrontendUserAgent(config.public.hash), + headers: visitorUserAgent ? { [VISITOR_USER_AGENT_HEADER]: visitorUserAgent } : undefined, + rateLimitKey: config.rateLimitKey || (() => readEnv('RATE_LIMIT_IGNORE_KEY')), features, }