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}`;
}
/**