From 34e8a64b50402f9374d243b5028c4f76a41f68ed Mon Sep 17 00:00:00 2001 From: Apoorv Mishra Date: Sat, 9 Mar 2024 14:48:59 +0530 Subject: [PATCH] Share env vars client-side using `@Public` decorator (#6627) * fix: public env vars using decorator * fix: relocate * fix: use env.public * fix: register public env vars across plugins * fix: test * fix: tsc * fix: mark remaining ones as public * fix: move oidc ones to plugin * fix: prevent overwrite * fix: review --- app/actions/definitions/developer.tsx | 4 +- app/env.ts | 4 +- app/utils/sentry.ts | 2 +- plugins/oidc/server/env.ts | 25 ++++++++- plugins/slack/server/env.ts | 15 ++++++ server/env.ts | 74 ++++++++++++--------------- server/presenters/env.ts | 23 +-------- server/routes/app.ts | 2 +- server/test/setup.ts | 1 + server/utils/decorators/Public.ts | 37 ++++++++++++++ shared/env.ts | 4 +- shared/types.ts | 19 ------- shared/utils/urls.ts | 2 +- 13 files changed, 119 insertions(+), 93 deletions(-) create mode 100644 server/utils/decorators/Public.ts diff --git a/app/actions/definitions/developer.tsx b/app/actions/definitions/developer.tsx index 67ee49fe7..e9cddd4df 100644 --- a/app/actions/definitions/developer.tsx +++ b/app/actions/definitions/developer.tsx @@ -69,8 +69,8 @@ export const copyId = createAction({ name: "Copy Release ID", icon: , section: DeveloperSection, - visible: () => !!env.RELEASE, - perform: () => copyAndToast(env.RELEASE), + visible: () => !!env.VERSION, + perform: () => copyAndToast(env.VERSION), }), ]; }, diff --git a/app/env.ts b/app/env.ts index 6b7ebdb48..fabe1e1cd 100644 --- a/app/env.ts +++ b/app/env.ts @@ -1,8 +1,6 @@ -import { PublicEnv } from "../shared/types"; - declare global { interface Window { - env: PublicEnv; + env: Record; } } diff --git a/app/utils/sentry.ts b/app/utils/sentry.ts index aaba4ad20..e6b354596 100644 --- a/app/utils/sentry.ts +++ b/app/utils/sentry.ts @@ -7,7 +7,7 @@ export function initSentry(history: History) { Sentry.init({ dsn: env.SENTRY_DSN, environment: env.ENVIRONMENT, - release: env.RELEASE, + release: env.VERSION, tunnel: env.SENTRY_TUNNEL, allowUrls: [env.URL, env.CDN_URL, env.COLLABORATION_URL], integrations: [ diff --git a/plugins/oidc/server/env.ts b/plugins/oidc/server/env.ts index 9d1bb1a73..ba40a28a0 100644 --- a/plugins/oidc/server/env.ts +++ b/plugins/oidc/server/env.ts @@ -1,5 +1,6 @@ -import { IsOptional, IsUrl, MaxLength } from "class-validator"; +import { IsBoolean, IsOptional, IsUrl, MaxLength } from "class-validator"; import { Environment } from "@server/env"; +import { Public } from "@server/utils/decorators/Public"; import environment from "@server/utils/environment"; import { CannotUseWithout } from "@server/utils/validators"; @@ -74,6 +75,28 @@ class OIDCPluginEnvironment extends Environment { * profile email". */ public OIDC_SCOPES = environment.OIDC_SCOPES ?? "openid profile email"; + + /** + * Disable autoredirect to the OIDC login page if there is only one + * authentication method and that method is OIDC. + */ + @Public + @IsOptional() + @IsBoolean() + public OIDC_DISABLE_REDIRECT = this.toOptionalBoolean( + environment.OIDC_DISABLE_REDIRECT + ); + + /** + * The OIDC logout endpoint. + */ + @Public + @IsOptional() + @IsUrl({ + require_tld: false, + allow_underscores: true, + }) + public OIDC_LOGOUT_URI = this.toOptionalString(environment.OIDC_LOGOUT_URI); } export default new OIDCPluginEnvironment(); diff --git a/plugins/slack/server/env.ts b/plugins/slack/server/env.ts index a19195df5..eaccb2783 100644 --- a/plugins/slack/server/env.ts +++ b/plugins/slack/server/env.ts @@ -1,6 +1,7 @@ import { IsBoolean, IsOptional } from "class-validator"; import { Environment } from "@server/env"; import Deprecated from "@server/models/decorators/Deprecated"; +import { Public } from "@server/utils/decorators/Public"; import environment from "@server/utils/environment"; import { CannotUseWithout } from "@server/utils/validators"; @@ -8,6 +9,20 @@ class SlackPluginEnvironment extends Environment { /** * Slack OAuth2 client credentials. To enable authentication with Slack. */ + @Public + @IsOptional() + public SLACK_CLIENT_ID = this.toOptionalString( + environment.SLACK_CLIENT_ID ?? environment.SLACK_KEY + ); + + /** + * Injected into the `slack-app-id` header meta tag if provided. + */ + @Public + @IsOptional() + @CannotUseWithout("SLACK_CLIENT_ID") + public SLACK_APP_ID = this.toOptionalString(environment.SLACK_APP_ID); + @IsOptional() @Deprecated("Use SLACK_CLIENT_SECRET instead") public SLACK_SECRET = this.toOptionalString(environment.SLACK_SECRET); diff --git a/server/env.ts b/server/env.ts index 0c025bde6..76eb2bf1f 100644 --- a/server/env.ts +++ b/server/env.ts @@ -18,6 +18,7 @@ import { languages } from "@shared/i18n"; import { CannotUseWithout } from "@server/utils/validators"; import Deprecated from "./models/decorators/Deprecated"; import { getArg } from "./utils/args"; +import { Public, PublicEnvironmentRegister } from "./utils/decorators/Public"; export class Environment { constructor() { @@ -34,11 +35,21 @@ export class Environment { } }); }); + + PublicEnvironmentRegister.registerEnv(this); + } + + /** + * Returns an object consisting of env vars annotated with `@Public` decorator + */ + get public() { + return PublicEnvironmentRegister.getEnv(); } /** * The current environment name. */ + @Public @IsIn(["development", "production", "staging", "test"]) public ENVIRONMENT = environment.NODE_ENV ?? "production"; @@ -121,13 +132,14 @@ export class Environment { /** * The fully qualified, external facing domain name of the server. */ + @Public @IsNotEmpty() @IsUrl({ protocols: ["http", "https"], require_protocol: true, require_tld: false, }) - public URL = environment.URL || ""; + public URL = (environment.URL ?? "").replace(/\/$/, ""); /** * If using a Cloudfront/Cloudflare distribution or similar it can be set below. @@ -135,27 +147,31 @@ export class Environment { * the hostname defined in CDN_URL. In your CDN configuration the origin server * should be set to the same as URL. */ + @Public @IsOptional() @IsUrl({ protocols: ["http", "https"], require_protocol: true, require_tld: false, }) - public CDN_URL = this.toOptionalString(environment.CDN_URL); + public CDN_URL = this.toOptionalString( + environment.CDN_URL ? environment.CDN_URL.replace(/\/$/, "") : undefined + ); /** * The fully qualified, external facing domain name of the collaboration * service, if different (unlikely) */ + @Public @IsUrl({ require_tld: false, require_protocol: true, protocols: ["http", "https", "ws", "wss"], }) @IsOptional() - public COLLABORATION_URL = this.toOptionalString( - environment.COLLABORATION_URL - ); + public COLLABORATION_URL = (environment.COLLABORATION_URL || this.URL) + .replace(/\/$/, "") + .replace(/^http/, "ws"); /** * The maximum number of network clients that can be connected to a single @@ -221,6 +237,7 @@ export class Environment { * The default interface language. See translate.getoutline.com for a list of * available language codes and their percentage translated. */ + @Public @IsIn(languages) public DEFAULT_LANGUAGE = environment.DEFAULT_LANGUAGE ?? "en_US"; @@ -270,6 +287,9 @@ export class Environment { */ public SMTP_HOST = environment.SMTP_HOST; + @Public + public EMAIL_ENABLED = !!this.SMTP_HOST || this.isDevelopment; + /** * Optional hostname of the client, used for identifying to the server * defaults to hostname of the machine. @@ -325,6 +345,7 @@ export class Environment { /** * Sentry DSN for capturing errors and frontend performance. */ + @Public @IsUrl() @IsOptional() public SENTRY_DSN = this.toOptionalString(environment.SENTRY_DSN); @@ -332,6 +353,7 @@ export class Environment { /** * Sentry tunnel URL for bypassing ad blockers */ + @Public @IsUrl() @IsOptional() public SENTRY_TUNNEL = this.toOptionalString(environment.SENTRY_TUNNEL); @@ -344,6 +366,7 @@ export class Environment { /** * A Google Analytics tracking ID, supports v3 or v4 properties. */ + @Public @IsOptional() public GOOGLE_ANALYTICS_ID = this.toOptionalString( environment.GOOGLE_ANALYTICS_ID @@ -359,44 +382,13 @@ export class Environment { */ public DD_SERVICE = environment.DD_SERVICE ?? "outline"; - @IsOptional() - public SLACK_CLIENT_ID = this.toOptionalString( - environment.SLACK_CLIENT_ID ?? environment.SLACK_KEY - ); - - /** - * Injected into the `slack-app-id` header meta tag if provided. - */ - @IsOptional() - @CannotUseWithout("SLACK_CLIENT_ID") - public SLACK_APP_ID = this.toOptionalString(environment.SLACK_APP_ID); - - /** - * Disable autoredirect to the OIDC login page if there is only one - * authentication method and that method is OIDC. - */ - @IsOptional() - @IsBoolean() - public OIDC_DISABLE_REDIRECT = this.toOptionalBoolean( - environment.OIDC_DISABLE_REDIRECT - ); - - /** - * The OIDC logout endpoint. - */ - @IsOptional() - @IsUrl({ - require_tld: false, - allow_underscores: true, - }) - public OIDC_LOGOUT_URI = this.toOptionalString(environment.OIDC_LOGOUT_URI); - /** * A string representing the version of the software. * * SOURCE_COMMIT is used by Docker Hub * SOURCE_VERSION is used by Heroku */ + @Public public VERSION = this.toOptionalString( environment.SOURCE_COMMIT || environment.SOURCE_VERSION ); @@ -477,14 +469,14 @@ export class Environment { /** * Optional AWS S3 endpoint URL for file attachments. */ + @Public @IsOptional() - public AWS_S3_ACCELERATE_URL = this.toOptionalString( - environment.AWS_S3_ACCELERATE_URL - ); + public AWS_S3_ACCELERATE_URL = environment.AWS_S3_ACCELERATE_URL ?? ""; /** * Optional AWS S3 endpoint URL for file attachments. */ + @Public @IsOptional() public AWS_S3_UPLOAD_BUCKET_URL = environment.AWS_S3_UPLOAD_BUCKET_URL ?? ""; @@ -536,6 +528,7 @@ export class Environment { /** * Set max allowed upload size for document imports. */ + @Public @IsNumber() public FILE_STORAGE_IMPORT_MAX_SIZE = this.toOptionalNumber(environment.FILE_STORAGE_IMPORT_MAX_SIZE) ?? @@ -586,6 +579,7 @@ export class Environment { /** * The product name */ + @Public public APP_NAME = "Outline"; /** diff --git a/server/presenters/env.ts b/server/presenters/env.ts index fe7095e97..e49d2b398 100644 --- a/server/presenters/env.ts +++ b/server/presenters/env.ts @@ -12,32 +12,11 @@ export default function present( } = {} ): PublicEnv { return { - URL: env.URL.replace(/\/$/, ""), - AWS_S3_UPLOAD_BUCKET_URL: env.AWS_S3_UPLOAD_BUCKET_URL || "", - AWS_S3_ACCELERATE_URL: env.AWS_S3_ACCELERATE_URL || "", - CDN_URL: (env.CDN_URL || "").replace(/\/$/, ""), - COLLABORATION_URL: (env.COLLABORATION_URL || env.URL) - .replace(/\/$/, "") - .replace(/^http/, "ws"), - ENVIRONMENT: env.ENVIRONMENT, - SENTRY_DSN: env.SENTRY_DSN, - SENTRY_TUNNEL: env.SENTRY_TUNNEL, - SLACK_CLIENT_ID: env.SLACK_CLIENT_ID, - SLACK_APP_ID: env.SLACK_APP_ID, - FILE_STORAGE_IMPORT_MAX_SIZE: env.FILE_STORAGE_IMPORT_MAX_SIZE, - PDF_EXPORT_ENABLED: false, - DEFAULT_LANGUAGE: env.DEFAULT_LANGUAGE, - EMAIL_ENABLED: !!env.SMTP_HOST || env.isDevelopment, - GOOGLE_ANALYTICS_ID: env.GOOGLE_ANALYTICS_ID, - RELEASE: - process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined, - APP_NAME: env.APP_NAME, ROOT_SHARE_ID: options.rootShareId || undefined, - OIDC_DISABLE_REDIRECT: env.OIDC_DISABLE_REDIRECT || undefined, - OIDC_LOGOUT_URI: env.OIDC_LOGOUT_URI || undefined, analytics: { service: options.analytics?.service, settings: options.analytics?.settings, }, + ...env.public, }; } diff --git a/server/routes/app.ts b/server/routes/app.ts index 3c0266fb5..540c56441 100644 --- a/server/routes/app.ts +++ b/server/routes/app.ts @@ -98,7 +98,7 @@ export const renderApp = async ( .replace(/\{canonical-url\}/g, canonical) .replace(/\{shortcut-icon\}/g, shortcutIcon) .replace(/\{prefetch\}/g, shareId ? "" : prefetchTags) - .replace(/\{slack-app-id\}/g, env.SLACK_APP_ID || "") + .replace(/\{slack-app-id\}/g, env.public.SLACK_APP_ID || "") .replace(/\{cdn-url\}/g, env.CDN_URL || "") .replace(/\{script-tags\}/g, scriptTags) .replace(/\{csp-nonce\}/g, ctx.state.cspNonce); diff --git a/server/test/setup.ts b/server/test/setup.ts index fae7654ab..f262462c8 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -1,3 +1,4 @@ +import "reflect-metadata"; import sharedEnv from "@shared/env"; import env from "@server/env"; import Redis from "@server/storage/redis"; diff --git a/server/utils/decorators/Public.ts b/server/utils/decorators/Public.ts new file mode 100644 index 000000000..b1c606d83 --- /dev/null +++ b/server/utils/decorators/Public.ts @@ -0,0 +1,37 @@ +import "reflect-metadata"; +import isUndefined from "lodash/isUndefined"; +import type { Environment } from "@server/env"; + +const key = Symbol("env:public"); + +/** + * This decorator on an environment variable makes that variable available client-side + */ +export function Public(target: any, propertyKey: string) { + const publicVars: string[] = Reflect.getMetadata(key, target); + + if (!publicVars) { + return Reflect.defineMetadata(key, [propertyKey], target); + } + + publicVars.push(propertyKey); +} + +export class PublicEnvironmentRegister { + private static publicEnv: Record = {}; + + static registerEnv(env: Environment) { + process.nextTick(() => { + const vars: string[] = Reflect.getMetadata(key, env); + (vars ?? []).forEach((key: string) => { + if (isUndefined(this.publicEnv[key])) { + this.publicEnv[key] = env[key]; + } + }); + }); + } + + static getEnv() { + return this.publicEnv; + } +} diff --git a/shared/env.ts b/shared/env.ts index 695e28be9..c7bfed84f 100644 --- a/shared/env.ts +++ b/shared/env.ts @@ -1,5 +1,3 @@ -import { PublicEnv } from "./types"; - const env = typeof window === "undefined" ? process.env : window.env; -export default env as PublicEnv; +export default env as Record; diff --git a/shared/types.ts b/shared/types.ts index d8692f141..88916428f 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -49,26 +49,7 @@ export enum MentionType { } export type PublicEnv = { - URL: string; - CDN_URL: string; - COLLABORATION_URL: string; - AWS_S3_UPLOAD_BUCKET_URL: string; - AWS_S3_ACCELERATE_URL: string; - ENVIRONMENT: string; - SENTRY_DSN: string | undefined; - SENTRY_TUNNEL: string | undefined; - SLACK_CLIENT_ID: string | undefined; - SLACK_APP_ID: string | undefined; - FILE_STORAGE_IMPORT_MAX_SIZE: number; - EMAIL_ENABLED: boolean; - PDF_EXPORT_ENABLED: boolean; - DEFAULT_LANGUAGE: string; - GOOGLE_ANALYTICS_ID: string | undefined; - RELEASE: string | undefined; - APP_NAME: string; ROOT_SHARE_ID?: string; - OIDC_DISABLE_REDIRECT?: boolean; - OIDC_LOGOUT_URI?: string; analytics: { service?: IntegrationService | UserCreatableIntegrationService; settings?: IntegrationSettings; diff --git a/shared/utils/urls.ts b/shared/utils/urls.ts index f35cdd4d0..4409eaabd 100644 --- a/shared/utils/urls.ts +++ b/shared/utils/urls.ts @@ -9,7 +9,7 @@ import { RESERVED_SUBDOMAINS, getBaseDomain, parseDomain } from "./domains"; * @returns The path with the CDN url prepended. */ export function cdnPath(path: string): string { - return `${env.CDN_URL}${path}`; + return `${env.CDN_URL ?? ""}${path}`; } /**