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
This commit is contained in:
Apoorv Mishra
2024-03-09 14:48:59 +05:30
committed by GitHub
parent 0983dd91b6
commit 34e8a64b50
13 changed files with 119 additions and 93 deletions

View File

@@ -69,8 +69,8 @@ export const copyId = createAction({
name: "Copy Release ID", name: "Copy Release ID",
icon: <CopyIcon />, icon: <CopyIcon />,
section: DeveloperSection, section: DeveloperSection,
visible: () => !!env.RELEASE, visible: () => !!env.VERSION,
perform: () => copyAndToast(env.RELEASE), perform: () => copyAndToast(env.VERSION),
}), }),
]; ];
}, },

View File

@@ -1,8 +1,6 @@
import { PublicEnv } from "../shared/types";
declare global { declare global {
interface Window { interface Window {
env: PublicEnv; env: Record<string, any>;
} }
} }

View File

@@ -7,7 +7,7 @@ export function initSentry(history: History) {
Sentry.init({ Sentry.init({
dsn: env.SENTRY_DSN, dsn: env.SENTRY_DSN,
environment: env.ENVIRONMENT, environment: env.ENVIRONMENT,
release: env.RELEASE, release: env.VERSION,
tunnel: env.SENTRY_TUNNEL, tunnel: env.SENTRY_TUNNEL,
allowUrls: [env.URL, env.CDN_URL, env.COLLABORATION_URL], allowUrls: [env.URL, env.CDN_URL, env.COLLABORATION_URL],
integrations: [ integrations: [

View File

@@ -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 { Environment } from "@server/env";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment"; import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators"; import { CannotUseWithout } from "@server/utils/validators";
@@ -74,6 +75,28 @@ class OIDCPluginEnvironment extends Environment {
* profile email". * profile email".
*/ */
public OIDC_SCOPES = environment.OIDC_SCOPES ?? "openid 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(); export default new OIDCPluginEnvironment();

View File

@@ -1,6 +1,7 @@
import { IsBoolean, IsOptional } from "class-validator"; import { IsBoolean, IsOptional } from "class-validator";
import { Environment } from "@server/env"; import { Environment } from "@server/env";
import Deprecated from "@server/models/decorators/Deprecated"; import Deprecated from "@server/models/decorators/Deprecated";
import { Public } from "@server/utils/decorators/Public";
import environment from "@server/utils/environment"; import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators"; import { CannotUseWithout } from "@server/utils/validators";
@@ -8,6 +9,20 @@ class SlackPluginEnvironment extends Environment {
/** /**
* Slack OAuth2 client credentials. To enable authentication with Slack. * 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() @IsOptional()
@Deprecated("Use SLACK_CLIENT_SECRET instead") @Deprecated("Use SLACK_CLIENT_SECRET instead")
public SLACK_SECRET = this.toOptionalString(environment.SLACK_SECRET); public SLACK_SECRET = this.toOptionalString(environment.SLACK_SECRET);

View File

@@ -18,6 +18,7 @@ import { languages } from "@shared/i18n";
import { CannotUseWithout } from "@server/utils/validators"; import { CannotUseWithout } from "@server/utils/validators";
import Deprecated from "./models/decorators/Deprecated"; import Deprecated from "./models/decorators/Deprecated";
import { getArg } from "./utils/args"; import { getArg } from "./utils/args";
import { Public, PublicEnvironmentRegister } from "./utils/decorators/Public";
export class Environment { export class Environment {
constructor() { 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. * The current environment name.
*/ */
@Public
@IsIn(["development", "production", "staging", "test"]) @IsIn(["development", "production", "staging", "test"])
public ENVIRONMENT = environment.NODE_ENV ?? "production"; public ENVIRONMENT = environment.NODE_ENV ?? "production";
@@ -121,13 +132,14 @@ export class Environment {
/** /**
* The fully qualified, external facing domain name of the server. * The fully qualified, external facing domain name of the server.
*/ */
@Public
@IsNotEmpty() @IsNotEmpty()
@IsUrl({ @IsUrl({
protocols: ["http", "https"], protocols: ["http", "https"],
require_protocol: true, require_protocol: true,
require_tld: false, 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. * 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 * the hostname defined in CDN_URL. In your CDN configuration the origin server
* should be set to the same as URL. * should be set to the same as URL.
*/ */
@Public
@IsOptional() @IsOptional()
@IsUrl({ @IsUrl({
protocols: ["http", "https"], protocols: ["http", "https"],
require_protocol: true, require_protocol: true,
require_tld: false, 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 * The fully qualified, external facing domain name of the collaboration
* service, if different (unlikely) * service, if different (unlikely)
*/ */
@Public
@IsUrl({ @IsUrl({
require_tld: false, require_tld: false,
require_protocol: true, require_protocol: true,
protocols: ["http", "https", "ws", "wss"], protocols: ["http", "https", "ws", "wss"],
}) })
@IsOptional() @IsOptional()
public COLLABORATION_URL = this.toOptionalString( public COLLABORATION_URL = (environment.COLLABORATION_URL || this.URL)
environment.COLLABORATION_URL .replace(/\/$/, "")
); .replace(/^http/, "ws");
/** /**
* The maximum number of network clients that can be connected to a single * 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 * The default interface language. See translate.getoutline.com for a list of
* available language codes and their percentage translated. * available language codes and their percentage translated.
*/ */
@Public
@IsIn(languages) @IsIn(languages)
public DEFAULT_LANGUAGE = environment.DEFAULT_LANGUAGE ?? "en_US"; public DEFAULT_LANGUAGE = environment.DEFAULT_LANGUAGE ?? "en_US";
@@ -270,6 +287,9 @@ export class Environment {
*/ */
public SMTP_HOST = environment.SMTP_HOST; 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 * Optional hostname of the client, used for identifying to the server
* defaults to hostname of the machine. * defaults to hostname of the machine.
@@ -325,6 +345,7 @@ export class Environment {
/** /**
* Sentry DSN for capturing errors and frontend performance. * Sentry DSN for capturing errors and frontend performance.
*/ */
@Public
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
public SENTRY_DSN = this.toOptionalString(environment.SENTRY_DSN); public SENTRY_DSN = this.toOptionalString(environment.SENTRY_DSN);
@@ -332,6 +353,7 @@ export class Environment {
/** /**
* Sentry tunnel URL for bypassing ad blockers * Sentry tunnel URL for bypassing ad blockers
*/ */
@Public
@IsUrl() @IsUrl()
@IsOptional() @IsOptional()
public SENTRY_TUNNEL = this.toOptionalString(environment.SENTRY_TUNNEL); 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. * A Google Analytics tracking ID, supports v3 or v4 properties.
*/ */
@Public
@IsOptional() @IsOptional()
public GOOGLE_ANALYTICS_ID = this.toOptionalString( public GOOGLE_ANALYTICS_ID = this.toOptionalString(
environment.GOOGLE_ANALYTICS_ID environment.GOOGLE_ANALYTICS_ID
@@ -359,44 +382,13 @@ export class Environment {
*/ */
public DD_SERVICE = environment.DD_SERVICE ?? "outline"; 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. * A string representing the version of the software.
* *
* SOURCE_COMMIT is used by Docker Hub * SOURCE_COMMIT is used by Docker Hub
* SOURCE_VERSION is used by Heroku * SOURCE_VERSION is used by Heroku
*/ */
@Public
public VERSION = this.toOptionalString( public VERSION = this.toOptionalString(
environment.SOURCE_COMMIT || environment.SOURCE_VERSION environment.SOURCE_COMMIT || environment.SOURCE_VERSION
); );
@@ -477,14 +469,14 @@ export class Environment {
/** /**
* Optional AWS S3 endpoint URL for file attachments. * Optional AWS S3 endpoint URL for file attachments.
*/ */
@Public
@IsOptional() @IsOptional()
public AWS_S3_ACCELERATE_URL = this.toOptionalString( public AWS_S3_ACCELERATE_URL = environment.AWS_S3_ACCELERATE_URL ?? "";
environment.AWS_S3_ACCELERATE_URL
);
/** /**
* Optional AWS S3 endpoint URL for file attachments. * Optional AWS S3 endpoint URL for file attachments.
*/ */
@Public
@IsOptional() @IsOptional()
public AWS_S3_UPLOAD_BUCKET_URL = environment.AWS_S3_UPLOAD_BUCKET_URL ?? ""; 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. * Set max allowed upload size for document imports.
*/ */
@Public
@IsNumber() @IsNumber()
public FILE_STORAGE_IMPORT_MAX_SIZE = public FILE_STORAGE_IMPORT_MAX_SIZE =
this.toOptionalNumber(environment.FILE_STORAGE_IMPORT_MAX_SIZE) ?? this.toOptionalNumber(environment.FILE_STORAGE_IMPORT_MAX_SIZE) ??
@@ -586,6 +579,7 @@ export class Environment {
/** /**
* The product name * The product name
*/ */
@Public
public APP_NAME = "Outline"; public APP_NAME = "Outline";
/** /**

View File

@@ -12,32 +12,11 @@ export default function present(
} = {} } = {}
): PublicEnv { ): PublicEnv {
return { 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, ROOT_SHARE_ID: options.rootShareId || undefined,
OIDC_DISABLE_REDIRECT: env.OIDC_DISABLE_REDIRECT || undefined,
OIDC_LOGOUT_URI: env.OIDC_LOGOUT_URI || undefined,
analytics: { analytics: {
service: options.analytics?.service, service: options.analytics?.service,
settings: options.analytics?.settings, settings: options.analytics?.settings,
}, },
...env.public,
}; };
} }

View File

@@ -98,7 +98,7 @@ export const renderApp = async (
.replace(/\{canonical-url\}/g, canonical) .replace(/\{canonical-url\}/g, canonical)
.replace(/\{shortcut-icon\}/g, shortcutIcon) .replace(/\{shortcut-icon\}/g, shortcutIcon)
.replace(/\{prefetch\}/g, shareId ? "" : prefetchTags) .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(/\{cdn-url\}/g, env.CDN_URL || "")
.replace(/\{script-tags\}/g, scriptTags) .replace(/\{script-tags\}/g, scriptTags)
.replace(/\{csp-nonce\}/g, ctx.state.cspNonce); .replace(/\{csp-nonce\}/g, ctx.state.cspNonce);

View File

@@ -1,3 +1,4 @@
import "reflect-metadata";
import sharedEnv from "@shared/env"; import sharedEnv from "@shared/env";
import env from "@server/env"; import env from "@server/env";
import Redis from "@server/storage/redis"; import Redis from "@server/storage/redis";

View File

@@ -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<string, any> = {};
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;
}
}

View File

@@ -1,5 +1,3 @@
import { PublicEnv } from "./types";
const env = typeof window === "undefined" ? process.env : window.env; const env = typeof window === "undefined" ? process.env : window.env;
export default env as PublicEnv; export default env as Record<string, any>;

View File

@@ -49,26 +49,7 @@ export enum MentionType {
} }
export type PublicEnv = { 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; ROOT_SHARE_ID?: string;
OIDC_DISABLE_REDIRECT?: boolean;
OIDC_LOGOUT_URI?: string;
analytics: { analytics: {
service?: IntegrationService | UserCreatableIntegrationService; service?: IntegrationService | UserCreatableIntegrationService;
settings?: IntegrationSettings<IntegrationType.Analytics>; settings?: IntegrationSettings<IntegrationType.Analytics>;

View File

@@ -9,7 +9,7 @@ import { RESERVED_SUBDOMAINS, getBaseDomain, parseDomain } from "./domains";
* @returns The path with the CDN url prepended. * @returns The path with the CDN url prepended.
*/ */
export function cdnPath(path: string): string { export function cdnPath(path: string): string {
return `${env.CDN_URL}${path}`; return `${env.CDN_URL ?? ""}${path}`;
} }
/** /**