From 3c002f82ccb552e135021b983ba4c12680e27721 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 19 May 2022 08:05:11 -0700 Subject: [PATCH] chore: Centralize env parsing, validation, defaults, and deprecation notices (#3487) * chore: Centralize env parsing, defaults, deprecation * wip * test * test * tsc * docs, more validation * fix: Allow empty REDIS_URL (defaults to localhost) * test * fix: SLACK_MESSAGE_ACTIONS not bool * fix: Add SMTP port validation --- .env.sample | 4 +- app.json | 4 +- app/components/CopyToClipboard.ts | 3 +- app/hooks/useAuthorizedSettingsConfig.ts | 2 +- app/scenes/Settings/Slack.tsx | 2 +- .../Settings/components/SlackButton.tsx | 13 +- app/utils/sentry.ts | 2 +- package.json | 1 + server/commands/teamCreator.test.ts | 7 +- server/commands/teamCreator.ts | 3 +- server/commands/teamUpdater.ts | 3 +- server/commands/userInviter.ts | 5 +- server/database/sequelize.ts | 11 +- server/database/vaults.ts | 3 +- server/emails/mailer.tsx | 35 +- .../templates/CollectionNotificationEmail.tsx | 5 +- server/emails/templates/SigninEmail.tsx | 5 +- server/emails/templates/components/Footer.tsx | 3 +- server/emails/templates/components/Header.tsx | 3 +- server/env.ts | 447 +++++++++++++++++- server/errors.ts | 9 +- server/index.ts | 22 +- server/logging/logger.ts | 7 +- server/logging/metrics.ts | 7 +- server/logging/sentry.ts | 2 +- server/logging/tracing.ts | 5 +- server/middlewares/passport.ts | 3 +- server/models/NotificationSetting.ts | 5 +- server/models/Team.ts | 8 +- server/models/User.ts | 8 +- server/models/decorators/Deprecated.ts | 14 + server/presenters/env.ts | 21 +- server/presenters/user.ts | 4 +- server/queues/processors/SlackProcessor.ts | 3 +- server/redis.ts | 25 +- server/routes/api/auth.test.ts | 11 +- server/routes/api/auth.ts | 8 +- server/routes/api/cron.ts | 3 +- server/routes/api/hooks.test.ts | 25 +- server/routes/api/hooks.ts | 9 +- server/routes/api/notificationSettings.ts | 3 +- server/routes/api/users.ts | 5 +- server/routes/auth/providers/azure.ts | 13 +- server/routes/auth/providers/email.test.ts | 13 +- server/routes/auth/providers/email.ts | 3 +- server/routes/auth/providers/google.ts | 10 +- server/routes/auth/providers/oidc.ts | 31 +- server/routes/auth/providers/slack.ts | 14 +- server/routes/index.ts | 12 +- server/services/web.ts | 7 +- server/test/setup.ts | 19 +- server/utils/authentication.ts | 3 +- server/utils/avatars.ts | 10 +- server/utils/domains.ts | 7 +- server/utils/parseAttachmentIds.test.ts | 7 +- server/utils/queue.ts | 3 +- server/utils/robots.ts | 4 +- server/utils/slack.ts | 7 +- server/utils/startup.ts | 94 +--- server/utils/updates.ts | 10 +- server/utils/validators.ts | 31 ++ shared/i18n/index.test.ts | 20 +- shared/i18n/index.ts | 7 +- shared/types.ts | 7 +- shared/utils/urlHelpers.ts | 9 +- yarn.lock | 15 +- 66 files changed, 783 insertions(+), 341 deletions(-) create mode 100644 server/models/decorators/Deprecated.ts create mode 100644 server/utils/validators.ts diff --git a/.env.sample b/.env.sample index 8e61333bc..55dd59806 100644 --- a/.env.sample +++ b/.env.sample @@ -65,8 +65,8 @@ AWS_S3_ACL=private # # When configuring the Client ID, add a redirect URL under "OAuth & Permissions": # https:///auth/slack.callback -SLACK_KEY=get_a_key_from_slack -SLACK_SECRET=get_the_secret_of_above_key +SLACK_CLIENT_ID=get_a_key_from_slack +SLACK_CLIENT_SECRET=get_the_secret_of_above_key # To configure Google auth, you'll need to create an OAuth Client ID at # => https://console.cloud.google.com/apis/credentials diff --git a/app.json b/app.json index 1085b5c27..21275ecc1 100644 --- a/app.json +++ b/app.json @@ -102,11 +102,11 @@ "value": "openid profile email", "required": false }, - "SLACK_KEY": { + "SLACK_CLIENT_ID": { "description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.", "required": false }, - "SLACK_SECRET": { + "SLACK_CLIENT_SECRET": { "description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY", "required": false }, diff --git a/app/components/CopyToClipboard.ts b/app/components/CopyToClipboard.ts index e9982a141..3f17db8eb 100644 --- a/app/components/CopyToClipboard.ts +++ b/app/components/CopyToClipboard.ts @@ -1,5 +1,6 @@ import copy from "copy-to-clipboard"; import * as React from "react"; +import env from "~/env"; type Props = { text: string; @@ -14,7 +15,7 @@ class CopyToClipboard extends React.PureComponent { const elem = React.Children.only(children); copy(text, { - debug: process.env.NODE_ENV !== "production", + debug: env.ENVIRONMENT !== "production", format: "text/plain", }); diff --git a/app/hooks/useAuthorizedSettingsConfig.ts b/app/hooks/useAuthorizedSettingsConfig.ts index 016af6ff9..0ccad4c6e 100644 --- a/app/hooks/useAuthorizedSettingsConfig.ts +++ b/app/hooks/useAuthorizedSettingsConfig.ts @@ -163,7 +163,7 @@ const useAuthorizedSettingsConfig = () => { name: "Slack", path: "/settings/integrations/slack", component: Slack, - enabled: can.update && (!!env.SLACK_KEY || isHosted), + enabled: can.update && (!!env.SLACK_CLIENT_ID || isHosted), group: t("Integrations"), icon: SlackIcon, }, diff --git a/app/scenes/Settings/Slack.tsx b/app/scenes/Settings/Slack.tsx index d018aaf79..acfbd0c8d 100644 --- a/app/scenes/Settings/Slack.tsx +++ b/app/scenes/Settings/Slack.tsx @@ -83,7 +83,7 @@ function Slack() { }} /> - {env.SLACK_KEY ? ( + {env.SLACK_CLIENT_ID ? ( <>

{commandIntegration ? ( diff --git a/app/scenes/Settings/components/SlackButton.tsx b/app/scenes/Settings/components/SlackButton.tsx index 0076bd6b4..79c157ac8 100644 --- a/app/scenes/Settings/components/SlackButton.tsx +++ b/app/scenes/Settings/components/SlackButton.tsx @@ -15,13 +15,18 @@ type Props = { function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) { const { t } = useTranslation(); - const handleClick = () => - (window.location.href = slackAuth( + const handleClick = () => { + if (!env.SLACK_CLIENT_ID) { + return; + } + + window.location.href = slackAuth( state, scopes, - env.SLACK_KEY, + env.SLACK_CLIENT_ID, redirectUri - )); + ); + }; return (

diff --git a/server/emails/templates/SigninEmail.tsx b/server/emails/templates/SigninEmail.tsx index 898009ab2..eda385730 100644 --- a/server/emails/templates/SigninEmail.tsx +++ b/server/emails/templates/SigninEmail.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import env from "@server/env"; import logger from "@server/logging/logger"; import BaseEmail from "./BaseEmail"; import Body from "./components/Body"; @@ -39,7 +40,7 @@ signin page at: ${teamUrl} } protected render({ token, teamUrl }: Props) { - if (process.env.NODE_ENV === "development") { + if (env.ENVIRONMENT === "development") { logger.debug("email", `Sign-In link: ${this.signinLink(token)}`); } @@ -67,6 +68,6 @@ signin page at: ${teamUrl} } private signinLink(token: string): string { - return `${process.env.URL}/auth/email.callback?token=${token}`; + return `${env.URL}/auth/email.callback?token=${token}`; } } diff --git a/server/emails/templates/components/Footer.tsx b/server/emails/templates/components/Footer.tsx index a10e1a6cf..12aa48e75 100644 --- a/server/emails/templates/components/Footer.tsx +++ b/server/emails/templates/components/Footer.tsx @@ -2,6 +2,7 @@ import { Table, TBody, TR, TD } from "oy-vey"; import * as React from "react"; import theme from "@shared/styles/theme"; import { twitterUrl } from "@shared/utils/urlHelpers"; +import env from "@server/env"; type Props = { unsubscribeUrl?: string; @@ -35,7 +36,7 @@ export default ({ unsubscribeUrl }: Props) => { - + Outline diff --git a/server/emails/templates/components/Header.tsx b/server/emails/templates/components/Header.tsx index 26cef479e..6bcda48d0 100644 --- a/server/emails/templates/components/Header.tsx +++ b/server/emails/templates/components/Header.tsx @@ -1,8 +1,9 @@ import { Table, TBody, TR, TD } from "oy-vey"; import * as React from "react"; +import env from "@server/env"; import EmptySpace from "./EmptySpace"; -const url = process.env.CDN_URL || process.env.URL; +const url = env.CDN_URL ?? env.URL; export default () => { return ( diff --git a/server/env.ts b/server/env.ts index e2962c8b4..d562ff047 100644 --- a/server/env.ts +++ b/server/env.ts @@ -1,6 +1,449 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires +/* eslint-disable @typescript-eslint/no-var-requires */ + +// Load the process environment variables require("dotenv").config({ silent: true, }); -export default process.env; +import { + validate, + IsNotEmpty, + IsUrl, + IsOptional, + IsByteLength, + Equals, + IsNumber, + IsIn, + IsBoolean, + IsEmail, + Contains, + MaxLength, +} from "class-validator"; +import { languages } from "@shared/i18n"; +import { CannotUseWithout } from "@server/utils/validators"; +import Deprecated from "./models/decorators/Deprecated"; + +export class Environment { + private validationPromise; + + constructor() { + this.validationPromise = validate(this); + } + + /** + * Allows waiting on the environment to be validated. + * + * @returns A promise that resolves when the environment is validated. + */ + public validate() { + return this.validationPromise; + } + + /** + * The current envionment name. + */ + @IsIn(["development", "production", "staging", "test"]) + public ENVIRONMENT = process.env.NODE_ENV ?? "production"; + + /** + * The secret key is used for encrypting data. Do not change this value once + * set or your users will be unable to login. + */ + @IsByteLength(32, 64) + public SECRET_KEY = `${process.env.SECRET_KEY}`; + + /** + * The secret that should be passed to the cron utility endpoint to enable + * triggering of scheduled tasks. + */ + @IsNotEmpty() + public UTILS_SECRET = `${process.env.UTILS_SECRET}`; + + /** + * The url of the database. + */ + @IsNotEmpty() + @IsUrl({ require_tld: false, protocols: ["postgres"] }) + public DATABASE_URL = `${process.env.DATABASE_URL}`; + + /** + * The url of the database pool. + */ + @IsOptional() + @IsUrl({ require_tld: false, protocols: ["postgres"] }) + public DATABASE_CONNECTION_POOL_URL = `${process.env.DATABASE_CONNECTION_POOL_URL}`; + + /** + * Database connection pool configuration. + */ + @IsNumber() + @IsOptional() + public DATABASE_CONNECTION_POOL_MIN = this.toOptionalNumber( + process.env.DATABASE_CONNECTION_POOL_MIN + ); + + /** + * Database connection pool configuration. + */ + @IsNumber() + @IsOptional() + public DATABASE_CONNECTION_POOL_MAX = this.toOptionalNumber( + process.env.DATABASE_CONNECTION_POOL_MAX + ); + + /** + * Set to "disable" to disable SSL connection to the database. This option is + * passed through to Postgres. See: + * + * https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE + */ + @IsIn(["disable", "allow", "require", "prefer", "verify-ca", "verify-full"]) + @IsOptional() + public PGSSLMODE = process.env.PGSSLMODE; + + /** + * The url of redis. Note that redis does not have a database after the port. + */ + @IsOptional() + @IsNotEmpty() + @IsUrl({ require_tld: false, protocols: ["redis", "rediss", "ioredis"] }) + public REDIS_URL = process.env.REDIS_URL; + + /** + * The fully qualified, external facing domain name of the server. + */ + @IsNotEmpty() + @IsUrl({ require_tld: false }) + public URL = `${process.env.URL}`; + + /** + * If using a Cloudfront/Cloudflare distribution or similar it can be set below. + * This will cause paths to javascript, stylesheets, and images to be updated to + * the hostname defined in CDN_URL. In your CDN configuration the origin server + * should be set to the same as URL. + */ + @IsOptional() + @IsUrl() + public CDN_URL = process.env.CDN_URL; + + /** + * The fully qualified, external facing domain name of the collaboration + * service, if different (unlikely) + */ + @IsUrl({ require_tld: false }) + @IsOptional() + public COLLABORATION_URL = process.env.COLLABORATION_URL; + + /** + * The port that the server will listen on, defaults to 3000. + */ + @IsNumber() + @IsOptional() + public PORT = this.toOptionalNumber(process.env.PORT); + + /** + * Optional extra debugging. Comma separated + */ + public DEBUG = `${process.env.DEBUG}`; + + /** + * How many processes should be spawned. As a reasonable rule divide your + * server's available memory by 512 for a rough estimate + */ + @IsNumber() + @IsOptional() + public WEB_CONCURRENCY = this.toOptionalNumber(process.env.WEB_CONCURRENCY); + + /** + * Base64 encoded private key if Outline is to perform SSL termination. + */ + @IsOptional() + @CannotUseWithout("SSL_CERT") + public SSL_KEY = process.env.SSL_KEY; + + /** + * Base64 encoded public certificate if Outline is to perform SSL termination. + */ + @IsOptional() + @CannotUseWithout("SSL_KEY") + public SSL_CERT = process.env.SSL_CERT; + + /** + * Should always be left unset in a self-hosted environment. + */ + @Equals("hosted") + @IsOptional() + public DEPLOYMENT = process.env.DEPLOYMENT; + + /** + * Custom company logo that displays on the authentication screen. + */ + public TEAM_LOGO = process.env.TEAM_LOGO; + + /** + * The default interface language. See translate.getoutline.com for a list of + * available language codes and their percentage translated. + */ + @IsIn(languages) + public DEFAULT_LANGUAGE = process.env.DEFAULT_LANGUAGE ?? "en_US"; + + /** + * A comma separated list of which services should be enabled on this + * instance – defaults to all. + */ + public SERVICES = + process.env.SERVICES ?? "collaboration,websockets,worker,web"; + + /** + * Auto-redirect to https in production. The default is true but you may set + * to false if you can be sure that SSL is terminated at an external + * loadbalancer. + */ + @IsBoolean() + public FORCE_HTTPS = Boolean(process.env.FORCE_HTTPS ?? "true"); + + /** + * Whether to support multiple subdomains in a single instance. + */ + @IsBoolean() + @Deprecated("The community edition of Outline does not support subdomains") + public SUBDOMAINS_ENABLED = Boolean( + process.env.SUBDOMAINS_ENABLED ?? "false" + ); + + /** + * Should the installation send anonymized statistics to the maintainers. + * Defaults to true. + */ + @IsBoolean() + public TELEMETRY = Boolean( + process.env.ENABLE_UPDATES ?? process.env.TELEMETRY ?? "true" + ); + + /** + * Because imports can be much larger than regular file attachments and are + * deleted automatically we allow an optional separate limit on the size of + * imports. + */ + @IsNumber() + public MAXIMUM_IMPORT_SIZE = + this.toOptionalNumber(process.env.MAXIMUM_IMPORT_SIZE) ?? 5120000; + + /** + * An optional comma separated list of allowed domains. + */ + public ALLOWED_DOMAINS = + process.env.ALLOWED_DOMAINS ?? process.env.GOOGLE_ALLOWED_DOMAINS; + + // Third-party services + + /** + * The host of your SMTP server for enabling emails. + */ + public SMTP_HOST = process.env.SMTP_HOST; + + /** + * The port of your SMTP server. + */ + @IsNumber() + @IsOptional() + public SMTP_PORT = this.toOptionalNumber(process.env.SMTP_PORT); + + /** + * The username of your SMTP server, if any. + */ + public SMTP_USERNAME = process.env.SMTP_USERNAME; + + /** + * The password for the SMTP username, if any. + */ + public SMTP_PASSWORD = process.env.SMTP_PASSWORD; + + /** + * The email address from which emails are sent. + */ + @IsEmail() + @IsOptional() + public SMTP_FROM_EMAIL = process.env.SMTP_FROM_EMAIL; + + /** + * The reply-to address for emails sent from Outline. If unset the from + * address is used by default. + */ + @IsEmail() + @IsOptional() + public SMTP_REPLY_EMAIL = process.env.SMTP_REPLY_EMAIL; + + /** + * Override the cipher used for SMTP SSL connections. + */ + public SMTP_TLS_CIPHERS = process.env.SMTP_TLS_CIPHERS; + + /** + * If true (the default) the connection will use TLS when connecting to server. + * If false then TLS is used only if server supports the STARTTLS extension. + * + * Setting secure to false therefore does not mean that you would not use an + * encrypted connection. + */ + public SMTP_SECURE = Boolean(process.env.SMTP_SECURE ?? "true"); + + /** + * Sentry DSN for capturing errors and frontend performance. + */ + @IsUrl() + @IsOptional() + public SENTRY_DSN = process.env.SENTRY_DSN; + + /** + * A release SHA or other identifier for Sentry. + */ + public RELEASE = process.env.RELEASE; + + /** + * An optional host from which to load default avatars. + */ + @IsUrl() + public DEFAULT_AVATAR_HOST = + process.env.DEFAULT_AVATAR_HOST ?? "https://tiley.herokuapp.com"; + + /** + * A Google Analytics tracking ID, only v3 supported at this time. + */ + @Contains("UA-") + @IsOptional() + public GOOGLE_ANALYTICS_ID = process.env.GOOGLE_ANALYTICS_ID; + + /** + * A DataDog API key for tracking server metrics. + */ + public DD_API_KEY = process.env.DD_API_KEY; + + /** + * Google OAuth2 client credentials. To enable authentication with Google. + */ + @IsOptional() + @CannotUseWithout("GOOGLE_CLIENT_SECRET") + public GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; + + @IsOptional() + @CannotUseWithout("GOOGLE_CLIENT_ID") + public GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; + + /** + * Slack OAuth2 client credentials. To enable authentication with Slack. + */ + @IsOptional() + @CannotUseWithout("SLACK_CLIENT_SECRET") + public SLACK_CLIENT_ID = process.env.SLACK_CLIENT_ID ?? process.env.SLACK_KEY; + + @IsOptional() + @CannotUseWithout("SLACK_CLIENT_ID") + public SLACK_CLIENT_SECRET = + process.env.SLACK_CLIENT_SECRET ?? process.env.SLACK_SECRET; + + /** + * This is injected into the HTML page headers for Slack. + */ + @IsOptional() + @CannotUseWithout("SLACK_CLIENT_ID") + public SLACK_VERIFICATION_TOKEN = process.env.SLACK_VERIFICATION_TOKEN; + + /** + * This is injected into the slack-app-id header meta tag if provided. + */ + @IsOptional() + @CannotUseWithout("SLACK_CLIENT_ID") + public SLACK_APP_ID = process.env.SLACK_APP_ID; + + /** + * If enabled a "Post to Channel" button will be added to search result + * messages inside of Slack. This also requires setup in Slack UI. + */ + @IsOptional() + @IsBoolean() + public SLACK_MESSAGE_ACTIONS = Boolean( + process.env.SLACK_MESSAGE_ACTIONS ?? "false" + ); + + /** + * Azure OAuth2 client credentials. To enable authentication with Azure. + */ + @IsOptional() + @CannotUseWithout("AZURE_CLIENT_SECRET") + public AZURE_CLIENT_ID = process.env.AZURE_CLIENT_ID; + + @IsOptional() + @CannotUseWithout("AZURE_CLIENT_ID") + public AZURE_CLIENT_SECRET = process.env.AZURE_CLIENT_SECRET; + + @IsOptional() + @CannotUseWithout("AZURE_CLIENT_ID") + public AZURE_RESOURCE_APP_ID = process.env.AZURE_RESOURCE_APP_ID; + + /** + * OICD client credentials. To enable authentication with any + * compatible provider. + */ + @IsOptional() + @CannotUseWithout("OIDC_CLIENT_SECRET") + @CannotUseWithout("OIDC_AUTH_URI") + @CannotUseWithout("OIDC_TOKEN_URI") + @CannotUseWithout("OIDC_USERINFO_URI") + @CannotUseWithout("OIDC_DISPLAY_NAME") + public OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID; + + @IsOptional() + @CannotUseWithout("OIDC_CLIENT_ID") + public OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET; + + /** + * The name of the OIDC provider, eg "GitLab" – this will be displayed on the + * sign-in button and other places in the UI. The default value is: + * "OpenID Connect". + */ + @MaxLength(50) + public OIDC_DISPLAY_NAME = process.env.OIDC_DISPLAY_NAME ?? "OpenID Connect"; + + /** + * The OIDC authorization endpoint. + */ + @IsOptional() + @IsUrl() + public OIDC_AUTH_URI = process.env.OIDC_AUTH_URI; + + /** + * The OIDC token endpoint. + */ + @IsOptional() + @IsUrl() + public OIDC_TOKEN_URI = process.env.OIDC_TOKEN_URI; + + /** + * The OIDC userinfo endpoint. + */ + @IsOptional() + @IsUrl() + public OIDC_USERINFO_URI = process.env.OIDC_USERINFO_URI; + + /** + * The OIDC profile field to use as the username. The default value is + * "preferred_username". + */ + public OIDC_USERNAME_CLAIM = + process.env.OIDC_USERNAME_CLAIM ?? "preferred_username"; + + /** + * A space separated list of OIDC scopes to request. Defaults to "openid + * profile email". + */ + public OIDC_SCOPES = process.env.OIDC_SCOPES ?? "openid profile email"; + + private toOptionalNumber(value: string | undefined) { + return value ? parseInt(value, 10) : undefined; + } +} + +const env = new Environment(); + +export default env; diff --git a/server/errors.ts b/server/errors.ts index e8437fc40..1730e2449 100644 --- a/server/errors.ts +++ b/server/errors.ts @@ -3,8 +3,7 @@ import env from "./env"; export function AuthenticationError( message = "Invalid authentication", - // @ts-expect-error ts-migrate(2322) FIXME: Type 'string | undefined' is not assignable to typ... Remove this comment to see the full error message - redirectUrl: string = env.URL + redirectUrl = env.URL ) { return httpErrors(401, message, { redirectUrl, @@ -113,8 +112,7 @@ export function MaximumTeamsError( export function EmailAuthenticationRequiredError( message = "User must authenticate with email", - // @ts-expect-error ts-migrate(2322) FIXME: Type 'string | undefined' is not assignable to typ... Remove this comment to see the full error message - redirectUrl: string = env.URL + redirectUrl = env.URL ) { return httpErrors(400, message, { redirectUrl, @@ -148,8 +146,7 @@ export function OIDCMalformedUserInfoError( export function AuthenticationProviderDisabledError( message = "Authentication method has been disabled by an admin", - // @ts-expect-error ts-migrate(2322) FIXME: Type 'string | undefined' is not assignable to typ... Remove this comment to see the full error message - redirectUrl: string = env.URL + redirectUrl = env.URL ) { return httpErrors(400, message, { redirectUrl, diff --git a/server/index.ts b/server/index.ts index 0cea30bf2..ad1541d91 100644 --- a/server/index.ts +++ b/server/index.ts @@ -23,27 +23,21 @@ import { getSSLOptions } from "./utils/ssl"; import { checkEnv, checkMigrations } from "./utils/startup"; import { checkUpdates } from "./utils/updates"; -// If a services flag is passed it takes priority over the enviroment variable +// If a services flag is passed it takes priority over the environment variable // for example: --services=web,worker const normalizedServiceFlag = getArg("services"); // The default is to run all services to make development and OSS installations // easier to deal with. Separate services are only needed at scale. const serviceNames = uniq( - ( - normalizedServiceFlag || - env.SERVICES || - "collaboration,websockets,worker,web" - ) + (normalizedServiceFlag || env.SERVICES) .split(",") .map((service) => service.trim()) ); // The number of processes to run, defaults to the number of CPU's available // for the web service, and 1 for collaboration during the beta period. -let processCount = env.WEB_CONCURRENCY - ? parseInt(env.WEB_CONCURRENCY, 10) - : undefined; +let processCount = env.WEB_CONCURRENCY; if (serviceNames.includes("collaboration")) { if (processCount !== 1) { @@ -57,11 +51,11 @@ if (serviceNames.includes("collaboration")) { } // This function will only be called once in the original process -function master() { - checkEnv(); - checkMigrations(); +async function master() { + await checkEnv(); + await checkMigrations(); - if (env.ENABLE_UPDATES !== "false" && process.env.NODE_ENV === "production") { + if (env.TELEMETRY && env.ENVIRONMENT === "production") { checkUpdates(); setInterval(checkUpdates, 24 * 3600 * 1000); } @@ -84,7 +78,7 @@ async function start(id: number, disconnect: () => void) { const router = new Router(); // install basic middleware shared by all services - if ((env.DEBUG || "").includes("http")) { + if (env.DEBUG.includes("http")) { app.use(logger((str) => Logger.info("http", str))); } diff --git a/server/logging/logger.ts b/server/logging/logger.ts index b07bd1ae4..aa13a67ac 100644 --- a/server/logging/logger.ts +++ b/server/logging/logger.ts @@ -6,7 +6,8 @@ import Metrics from "@server/logging/metrics"; import Sentry from "@server/logging/sentry"; import * as Tracing from "./tracing"; -const isProduction = env.NODE_ENV === "production"; +const isProduction = env.ENVIRONMENT === "production"; + type LogCategory = | "lifecycle" | "hocuspocus" @@ -72,7 +73,7 @@ class Logger { warn(message: string, extra?: Extra) { Metrics.increment("logger.warning"); - if (process.env.SENTRY_DSN) { + if (env.SENTRY_DSN) { Sentry.withScope(function (scope) { scope.setLevel(Sentry.Severity.Warning); @@ -104,7 +105,7 @@ class Logger { Metrics.increment("logger.error"); Tracing.setError(error); - if (process.env.SENTRY_DSN) { + if (env.SENTRY_DSN) { Sentry.withScope(function (scope) { scope.setLevel(Sentry.Severity.Error); diff --git a/server/logging/metrics.ts b/server/logging/metrics.ts index 1f7935ae9..674bb2b6c 100644 --- a/server/logging/metrics.ts +++ b/server/logging/metrics.ts @@ -1,7 +1,8 @@ import ddMetrics from "datadog-metrics"; +import env from "@server/env"; class Metrics { - enabled = !!process.env.DD_API_KEY; + enabled = !!env.DD_API_KEY; constructor() { if (!this.enabled) { @@ -9,9 +10,9 @@ class Metrics { } ddMetrics.init({ - apiKey: process.env.DD_API_KEY, + apiKey: env.DD_API_KEY, prefix: "outline.", - defaultTags: [`env:${process.env.DD_ENV || process.env.NODE_ENV}`], + defaultTags: [`env:${process.env.DD_ENV ?? env.ENVIRONMENT}`], }); } diff --git a/server/logging/sentry.ts b/server/logging/sentry.ts index c1948c6aa..2dd5be617 100644 --- a/server/logging/sentry.ts +++ b/server/logging/sentry.ts @@ -27,7 +27,7 @@ export function requestErrorHandler(error: any, ctx: ContextWithState) { return; } - if (process.env.SENTRY_DSN) { + if (env.SENTRY_DSN) { Sentry.withScope(function (scope) { const requestId = ctx.headers["x-request-id"]; diff --git a/server/logging/tracing.ts b/server/logging/tracing.ts index d03e36d3a..b3ee8b507 100644 --- a/server/logging/tracing.ts +++ b/server/logging/tracing.ts @@ -1,10 +1,11 @@ import { init, tracer, addTags, markAsError } from "@theo.gravity/datadog-apm"; +import env from "@server/env"; export * as APM from "@theo.gravity/datadog-apm"; // If the DataDog agent is installed and the DD_API_KEY environment variable is // in the environment then we can safely attempt to start the DD tracer -if (process.env.DD_API_KEY) { +if (env.DD_API_KEY) { init( { // SOURCE_COMMIT is used by Docker Hub @@ -13,7 +14,7 @@ if (process.env.DD_API_KEY) { service: process.env.DD_SERVICE || "outline", }, { - useMock: process.env.NODE_ENV === "test", + useMock: env.ENVIRONMENT === "test", } ); } diff --git a/server/middlewares/passport.ts b/server/middlewares/passport.ts index dd138cd16..b99aaecea 100644 --- a/server/middlewares/passport.ts +++ b/server/middlewares/passport.ts @@ -1,5 +1,6 @@ import passport from "@outlinewiki/koa-passport"; import { Context } from "koa"; +import env from "@server/env"; import Logger from "@server/logging/logger"; import { signIn } from "@server/utils/authentication"; import { AccountProvisionerResult } from "../commands/accountProvisioner"; @@ -20,7 +21,7 @@ export default function createMiddleware(providerName: string) { return ctx.redirect(`${err.redirectUrl || "/"}?notice=${notice}`); } - if (process.env.NODE_ENV === "development") { + if (env.ENVIRONMENT === "development") { throw err; } diff --git a/server/models/NotificationSetting.ts b/server/models/NotificationSetting.ts index d794ad98f..29ab28b54 100644 --- a/server/models/NotificationSetting.ts +++ b/server/models/NotificationSetting.ts @@ -12,6 +12,7 @@ import { Default, DataType, } from "sequelize-typescript"; +import env from "@server/env"; import Team from "./Team"; import User from "./User"; import Fix from "./decorators/Fix"; @@ -48,7 +49,7 @@ class NotificationSetting extends Model { get unsubscribeUrl() { const token = NotificationSetting.getUnsubscribeToken(this.userId); - return `${process.env.URL}/api/notificationSettings.unsubscribe?token=${token}&id=${this.id}`; + return `${env.URL}/api/notificationSettings.unsubscribe?token=${token}&id=${this.id}`; } get unsubscribeToken() { @@ -73,7 +74,7 @@ class NotificationSetting extends Model { static getUnsubscribeToken = (userId: string) => { const hash = crypto.createHash("sha256"); - hash.update(`${userId}-${process.env.SECRET_KEY}`); + hash.update(`${userId}-${env.SECRET_KEY}`); return hash.digest("hex"); }; } diff --git a/server/models/Team.ts b/server/models/Team.ts index b9192c5ac..5370efd7a 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -117,7 +117,7 @@ class Team extends ParanoidModel { */ get emailSigninEnabled(): boolean { return ( - this.guestSignin && (!!env.SMTP_HOST || env.NODE_ENV === "development") + this.guestSignin && (!!env.SMTP_HOST || env.ENVIRONMENT === "development") ); } @@ -126,11 +126,11 @@ class Team extends ParanoidModel { return `https://${this.domain}`; } - if (!this.subdomain || process.env.SUBDOMAINS_ENABLED !== "true") { - return process.env.URL; + if (!this.subdomain || !env.SUBDOMAINS_ENABLED) { + return env.URL; } - const url = new URL(process.env.URL || ""); + const url = new URL(env.URL); url.host = `${this.subdomain}.${stripSubdomain(url.host)}`; return url.href.replace(/\/$/, ""); } diff --git a/server/models/User.ts b/server/models/User.ts index 6a72202d7..3f439755d 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -23,8 +23,8 @@ import { import { v4 as uuidv4 } from "uuid"; import { languages } from "@shared/i18n"; import { stringToColor } from "@shared/utils/color"; +import env from "@server/env"; import Logger from "@server/logging/logger"; -import { DEFAULT_AVATAR_HOST } from "@server/utils/avatars"; import { publicS3Endpoint, uploadToS3FromUrl } from "@server/utils/s3"; import { ValidationError } from "../errors"; import ApiKey from "./ApiKey"; @@ -137,7 +137,7 @@ class User extends ParanoidModel { @Column(DataType.JSONB) flags: { [key in UserFlag]?: number } | null; - @Default(process.env.DEFAULT_LANGUAGE) + @Default(env.DEFAULT_LANGUAGE) @IsIn([languages]) @Column language: string; @@ -156,7 +156,7 @@ class User extends ParanoidModel { .createHash("md5") .update(this.email || "") .digest("hex"); - return `${DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`; + return `${env.DEFAULT_AVATAR_HOST}/avatar/${hash}/${initial}.png?c=${color}`; } set avatarUrl(value: string | null) { @@ -439,7 +439,7 @@ class User extends ParanoidModel { avatarUrl && !avatarUrl.startsWith("/api") && !avatarUrl.startsWith(endpoint) && - !avatarUrl.startsWith(DEFAULT_AVATAR_HOST) + !avatarUrl.startsWith(env.DEFAULT_AVATAR_HOST) ) { try { const newUrl = await uploadToS3FromUrl( diff --git a/server/models/decorators/Deprecated.ts b/server/models/decorators/Deprecated.ts new file mode 100644 index 000000000..b3ac0f861 --- /dev/null +++ b/server/models/decorators/Deprecated.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/ban-types */ + +const Deprecated = (message?: string) => ( + target: Object, + propertyKey: string +) => { + if (process.env[propertyKey]) { + console.warn( + `The environment variable ${propertyKey} is deprecated and will be removed in a future release. ${message}` + ); + } +}; + +export default Deprecated; diff --git a/server/presenters/env.ts b/server/presenters/env.ts index 270683fd3..09bd996ce 100644 --- a/server/presenters/env.ts +++ b/server/presenters/env.ts @@ -1,26 +1,29 @@ import { PublicEnv } from "@shared/types"; +import { Environment } from "@server/env"; // Note: This entire object is stringified in the HTML exposed to the client // do not add anything here that should be a secret or password -export default function present(env: Record): PublicEnv { +export default function present(env: Environment): 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, + AWS_S3_UPLOAD_BUCKET_URL: process.env.AWS_S3_UPLOAD_BUCKET_URL || "", + AWS_S3_ACCELERATE_URL: process.env.AWS_S3_ACCELERATE_URL || "", CDN_URL: (env.CDN_URL || "").replace(/\/$/, ""), COLLABORATION_URL: (env.COLLABORATION_URL || env.URL) .replace(/\/$/, "") .replace(/^http/, "ws"), DEPLOYMENT: env.DEPLOYMENT, - ENVIRONMENT: env.NODE_ENV, + ENVIRONMENT: env.ENVIRONMENT, SENTRY_DSN: env.SENTRY_DSN, TEAM_LOGO: env.TEAM_LOGO, - SLACK_KEY: env.SLACK_KEY, + SLACK_CLIENT_ID: env.SLACK_CLIENT_ID, SLACK_APP_ID: env.SLACK_APP_ID, - MAXIMUM_IMPORT_SIZE: env.MAXIMUM_IMPORT_SIZE || 1024 * 1000 * 5, - SUBDOMAINS_ENABLED: env.SUBDOMAINS_ENABLED === "true", - EMAIL_ENABLED: !!env.SMTP_HOST || env.NODE_ENV === "development", + MAXIMUM_IMPORT_SIZE: env.MAXIMUM_IMPORT_SIZE, + SUBDOMAINS_ENABLED: env.SUBDOMAINS_ENABLED, + DEFAULT_LANGUAGE: env.DEFAULT_LANGUAGE, + EMAIL_ENABLED: !!env.SMTP_HOST || env.ENVIRONMENT === "development", GOOGLE_ANALYTICS_ID: env.GOOGLE_ANALYTICS_ID, - RELEASE: env.SOURCE_COMMIT || env.SOURCE_VERSION || undefined, + RELEASE: + process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION || undefined, }; } diff --git a/server/presenters/user.ts b/server/presenters/user.ts index b7e7e165c..2c9e39e43 100644 --- a/server/presenters/user.ts +++ b/server/presenters/user.ts @@ -1,3 +1,4 @@ +import env from "@server/env"; import { User } from "@server/models"; type Options = { @@ -38,8 +39,7 @@ export default ( if (options.includeDetails) { userData.email = user.email; - userData.language = - user.language || process.env.DEFAULT_LANGUAGE || "en_US"; + userData.language = user.language || env.DEFAULT_LANGUAGE; } return userData; diff --git a/server/queues/processors/SlackProcessor.ts b/server/queues/processors/SlackProcessor.ts index 748b39480..c33f5f736 100644 --- a/server/queues/processors/SlackProcessor.ts +++ b/server/queues/processors/SlackProcessor.ts @@ -1,5 +1,6 @@ import fetch from "fetch-with-proxy"; import { Op } from "sequelize"; +import env from "@server/env"; import { Document, Integration, Collection, Team } from "@server/models"; import { presentSlackAttachment } from "@server/presenters"; import { @@ -65,7 +66,7 @@ export default class SlackProcessor extends BaseProcessor { { color: collection.color, title: collection.name, - title_link: `${process.env.URL}${collection.url}`, + title_link: `${env.URL}${collection.url}`, text: collection.description, }, ], diff --git a/server/redis.ts b/server/redis.ts index 35fc0edf6..634cbc8ee 100644 --- a/server/redis.ts +++ b/server/redis.ts @@ -1,5 +1,6 @@ import Redis from "ioredis"; import { defaults } from "lodash"; +import env from "@server/env"; import Logger from "./logging/logger"; const defaultOptions = { @@ -12,23 +13,21 @@ const defaultOptions = { // support Heroku Redis, see: // https://devcenter.heroku.com/articles/heroku-redis#ioredis-module - tls: - process.env.REDIS_URL && process.env.REDIS_URL.startsWith("rediss://") - ? { - rejectUnauthorized: false, - } - : undefined, + tls: (env.REDIS_URL || "").startsWith("rediss://") + ? { + rejectUnauthorized: false, + } + : undefined, }; export default class RedisAdapter extends Redis { constructor(url: string | undefined) { - if (!(url || "").startsWith("ioredis://")) { - super(process.env.REDIS_URL, defaultOptions); + if (!url || !url.startsWith("ioredis://")) { + super(env.REDIS_URL, defaultOptions); } else { let customOptions = {}; try { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const decodedString = Buffer.from(url!.slice(10), "base64").toString(); + const decodedString = Buffer.from(url.slice(10), "base64").toString(); customOptions = JSON.parse(decodedString); } catch (error) { throw new Error(`Failed to decode redis adapter options: ${error}`); @@ -52,12 +51,10 @@ export default class RedisAdapter extends Redis { private static _subscriber: RedisAdapter; public static get defaultClient(): RedisAdapter { - return this._client || (this._client = new this(process.env.REDIS_URL)); + return this._client || (this._client = new this(env.REDIS_URL)); } public static get defaultSubscriber(): RedisAdapter { - return ( - this._subscriber || (this._subscriber = new this(process.env.REDIS_URL)) - ); + return this._subscriber || (this._subscriber = new this(env.REDIS_URL)); } } diff --git a/server/routes/api/auth.test.ts b/server/routes/api/auth.test.ts index 1f025f6ef..a9d9f3ee1 100644 --- a/server/routes/api/auth.test.ts +++ b/server/routes/api/auth.test.ts @@ -1,4 +1,5 @@ import TestServer from "fetch-test-server"; +import env from "@server/env"; import webService from "@server/services/web"; import { buildUser, buildTeam } from "@server/test/factories"; import { flushdb } from "@server/test/support"; @@ -57,7 +58,7 @@ describe("#auth.config", () => { }); it("should return available providers for team subdomain", async () => { - process.env.URL = "http://localoutline.com"; + env.URL = "http://localoutline.com"; await buildTeam({ guestSignin: false, subdomain: "example", @@ -102,7 +103,7 @@ describe("#auth.config", () => { }); it("should return email provider for team when guest signin enabled", async () => { - process.env.URL = "http://localoutline.com"; + env.URL = "http://localoutline.com"; await buildTeam({ guestSignin: true, subdomain: "example", @@ -126,7 +127,7 @@ describe("#auth.config", () => { }); it("should not return provider when disabled", async () => { - process.env.URL = "http://localoutline.com"; + env.URL = "http://localoutline.com"; await buildTeam({ guestSignin: false, subdomain: "example", @@ -149,7 +150,7 @@ describe("#auth.config", () => { }); describe("self hosted", () => { it("should return available providers for team", async () => { - process.env.DEPLOYMENT = ""; + env.DEPLOYMENT = ""; await buildTeam({ guestSignin: false, authenticationProviders: [ @@ -166,7 +167,7 @@ describe("#auth.config", () => { expect(body.data.providers[0].name).toBe("Slack"); }); it("should return email provider for team when guest signin enabled", async () => { - process.env.DEPLOYMENT = ""; + env.DEPLOYMENT = ""; await buildTeam({ guestSignin: true, authenticationProviders: [ diff --git a/server/routes/api/auth.ts b/server/routes/api/auth.ts index 5bb478639..1172e7fd6 100644 --- a/server/routes/api/auth.ts +++ b/server/routes/api/auth.ts @@ -2,6 +2,7 @@ import invariant from "invariant"; import Router from "koa-router"; import { find } from "lodash"; import { parseDomain, isCustomSubdomain } from "@shared/utils/domains"; +import env from "@server/env"; import auth from "@server/middlewares/authentication"; import { Team, TeamDomain } from "@server/models"; import { presentUser, presentTeam, presentPolicies } from "@server/presenters"; @@ -10,7 +11,7 @@ import providers from "../auth/providers"; const router = new Router(); -function filterProviders(team: Team) { +function filterProviders(team?: Team) { return providers .sort((provider) => (provider.id === "email" ? 1 : -1)) .filter((provider) => { @@ -39,7 +40,7 @@ router.post("auth.config", async (ctx) => { // If self hosted AND there is only one team then that team becomes the // brand for the knowledge base and it's guest signin option is used for the // root login page. - if (process.env.DEPLOYMENT !== "hosted") { + if (env.DEPLOYMENT !== "hosted") { const teams = await Team.scope("withAuthenticationProviders").findAll(); if (teams.length === 1) { @@ -76,7 +77,7 @@ router.post("auth.config", async (ctx) => { // If subdomain signin page then we return minimal team details to allow // for a custom screen showing only relevant signin options for that team. if ( - process.env.SUBDOMAINS_ENABLED === "true" && + env.SUBDOMAINS_ENABLED && isCustomSubdomain(ctx.request.hostname) && !isCustomDomain(ctx.request.hostname) ) { @@ -103,7 +104,6 @@ router.post("auth.config", async (ctx) => { // Otherwise, we're requesting from the standard root signin page ctx.body = { data: { - // @ts-expect-error ts-migrate(2554) FIXME: Expected 1 arguments, but got 0. providers: filterProviders(), }, }; diff --git a/server/routes/api/cron.ts b/server/routes/api/cron.ts index 6e0560cb9..b6ee646db 100644 --- a/server/routes/api/cron.ts +++ b/server/routes/api/cron.ts @@ -1,5 +1,6 @@ import { Context } from "koa"; import Router from "koa-router"; +import env from "@server/env"; import { AuthenticationError } from "@server/errors"; import CleanupDeletedDocumentsTask from "@server/queues/tasks/CleanupDeletedDocumentsTask"; import CleanupDeletedTeamsTask from "@server/queues/tasks/CleanupDeletedTeamsTask"; @@ -11,7 +12,7 @@ const router = new Router(); const cronHandler = async (ctx: Context) => { const { token, limit = 500 } = ctx.body as { token?: string; limit: number }; - if (process.env.UTILS_SECRET !== token) { + if (env.UTILS_SECRET !== token) { throw AuthenticationError("Invalid secret token"); } diff --git a/server/routes/api/hooks.test.ts b/server/routes/api/hooks.test.ts index 7555479e2..f78732568 100644 --- a/server/routes/api/hooks.test.ts +++ b/server/routes/api/hooks.test.ts @@ -1,4 +1,5 @@ import TestServer from "fetch-test-server"; +import env from "@server/env"; import { IntegrationAuthentication, SearchQuery } from "@server/models"; import webService from "@server/services/web"; import { buildDocument, buildIntegration } from "@server/test/factories"; @@ -24,7 +25,7 @@ describe("#hooks.unfurl", () => { }); const res = await server.post("/api/hooks.unfurl", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, team_id: "TXXXXXXXX", api_app_id: "AXXXXXXXXX", event: { @@ -51,7 +52,7 @@ describe("#hooks.slack", () => { const { user, team } = await seed(); const res = await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: user.authentications[0].providerId, team_id: team.authenticationProviders[0].providerId, text: "dsfkndfskndsfkn", @@ -71,7 +72,7 @@ describe("#hooks.slack", () => { }); const res = await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: user.authentications[0].providerId, team_id: team.authenticationProviders[0].providerId, text: "contains", @@ -93,7 +94,7 @@ describe("#hooks.slack", () => { }); const res = await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: user.authentications[0].providerId, team_id: team.authenticationProviders[0].providerId, text: "*contains", @@ -113,7 +114,7 @@ describe("#hooks.slack", () => { }); const res = await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: user.authentications[0].providerId, team_id: team.authenticationProviders[0].providerId, text: "contains", @@ -132,7 +133,7 @@ describe("#hooks.slack", () => { const { user, team } = await seed(); await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: user.authentications[0].providerId, team_id: team.authenticationProviders[0].providerId, text: "contains", @@ -160,7 +161,7 @@ describe("#hooks.slack", () => { const { user, team } = await seed(); const res = await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: user.authentications[0].providerId, team_id: team.authenticationProviders[0].providerId, text: "help", @@ -175,7 +176,7 @@ describe("#hooks.slack", () => { const { user, team } = await seed(); const res = await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: user.authentications[0].providerId, team_id: team.authenticationProviders[0].providerId, text: "", @@ -202,7 +203,7 @@ describe("#hooks.slack", () => { }); const res = await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: "unknown-slack-user-id", team_id: team.authenticationProviders[0].providerId, text: "contains", @@ -234,7 +235,7 @@ describe("#hooks.slack", () => { }); const res = await server.post("/api/hooks.slack", { body: { - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user_id: "unknown-slack-user-id", team_id: serviceTeamId, text: "contains", @@ -273,7 +274,7 @@ describe("#hooks.interactive", () => { teamId: user.teamId, }); const payload = JSON.stringify({ - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user: { id: user.authentications[0].providerId, }, @@ -302,7 +303,7 @@ describe("#hooks.interactive", () => { teamId: user.teamId, }); const payload = JSON.stringify({ - token: process.env.SLACK_VERIFICATION_TOKEN, + token: env.SLACK_VERIFICATION_TOKEN, user: { id: "unknown-slack-user-id", }, diff --git a/server/routes/api/hooks.ts b/server/routes/api/hooks.ts index 3655ed2ec..8b1c4cdd4 100644 --- a/server/routes/api/hooks.ts +++ b/server/routes/api/hooks.ts @@ -1,6 +1,7 @@ import invariant from "invariant"; import Router from "koa-router"; import { escapeRegExp } from "lodash"; +import env from "@server/env"; import { AuthenticationError, InvalidRequestError } from "@server/errors"; import Logger from "@server/logging/logger"; import { @@ -26,7 +27,7 @@ router.post("hooks.unfurl", async (ctx) => { return (ctx.body = ctx.body.challenge); } - if (token !== process.env.SLACK_VERIFICATION_TOKEN) { + if (token !== env.SLACK_VERIFICATION_TOKEN) { throw AuthenticationError("Invalid token"); } @@ -89,7 +90,7 @@ router.post("hooks.interactive", async (ctx) => { assertPresent(token, "token is required"); assertPresent(callback_id, "callback_id is required"); - if (token !== process.env.SLACK_VERIFICATION_TOKEN) { + if (token !== env.SLACK_VERIFICATION_TOKEN) { throw AuthenticationError("Invalid verification token"); } @@ -127,7 +128,7 @@ router.post("hooks.slack", async (ctx) => { assertPresent(team_id, "team_id is required"); assertPresent(user_id, "user_id is required"); - if (token !== process.env.SLACK_VERIFICATION_TOKEN) { + if (token !== env.SLACK_VERIFICATION_TOKEN) { throw AuthenticationError("Invalid verification token"); } @@ -289,7 +290,7 @@ router.post("hooks.slack", async (ctx) => { result.document.collection, team, queryIsInTitle ? undefined : result.context, - process.env.SLACK_MESSAGE_ACTIONS + env.SLACK_MESSAGE_ACTIONS ? [ { name: "post", diff --git a/server/routes/api/notificationSettings.ts b/server/routes/api/notificationSettings.ts index 9729dd5d7..ef0c929f1 100644 --- a/server/routes/api/notificationSettings.ts +++ b/server/routes/api/notificationSettings.ts @@ -1,4 +1,5 @@ import Router from "koa-router"; +import env from "@server/env"; import auth from "@server/middlewares/authentication"; import { Team, NotificationSetting } from "@server/models"; import { authorize } from "@server/policies"; @@ -75,7 +76,7 @@ router.post("notificationSettings.unsubscribe", async (ctx) => { return; } - ctx.redirect(`${process.env.URL}?notice=invalid-auth`); + ctx.redirect(`${env.URL}?notice=invalid-auth`); }); export default router; diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts index 3a230bd2c..65def8e4b 100644 --- a/server/routes/api/users.ts +++ b/server/routes/api/users.ts @@ -5,6 +5,7 @@ import userInviter from "@server/commands/userInviter"; import userSuspender from "@server/commands/userSuspender"; import { sequelize } from "@server/database/sequelize"; import InviteEmail from "@server/emails/templates/InviteEmail"; +import env from "@server/env"; import { ValidationError } from "@server/errors"; import logger from "@server/logging/logger"; import auth from "@server/middlewares/authentication"; @@ -338,11 +339,11 @@ router.post("users.resendInvite", auth(), async (ctx) => { user.incrementFlag(UserFlag.InviteSent); await user.save({ transaction }); - if (process.env.NODE_ENV === "development") { + if (env.ENVIRONMENT === "development") { logger.info( "email", `Sign in immediately: ${ - process.env.URL + env.URL }/auth/email.callback?token=${user.getEmailSigninToken()}` ); } diff --git a/server/routes/auth/providers/azure.ts b/server/routes/auth/providers/azure.ts index 446ebe667..557fc548a 100644 --- a/server/routes/auth/providers/azure.ts +++ b/server/routes/auth/providers/azure.ts @@ -15,25 +15,22 @@ import { StateStore, request } from "@server/utils/passport"; const router = new Router(); const providerName = "azure"; -const AZURE_CLIENT_ID = process.env.AZURE_CLIENT_ID; -const AZURE_CLIENT_SECRET = process.env.AZURE_CLIENT_SECRET; -const AZURE_RESOURCE_APP_ID = process.env.AZURE_RESOURCE_APP_ID; const scopes: string[] = []; export const config = { name: "Microsoft", - enabled: !!AZURE_CLIENT_ID, + enabled: !!env.AZURE_CLIENT_ID, }; -if (AZURE_CLIENT_ID && AZURE_CLIENT_SECRET) { +if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) { const strategy = new AzureStrategy( { - clientID: AZURE_CLIENT_ID, - clientSecret: AZURE_CLIENT_SECRET, + clientID: env.AZURE_CLIENT_ID, + clientSecret: env.AZURE_CLIENT_SECRET, callbackURL: `${env.URL}/auth/azure.callback`, useCommonEndpoint: true, passReqToCallback: true, - resource: AZURE_RESOURCE_APP_ID, + resource: env.AZURE_RESOURCE_APP_ID, // @ts-expect-error StateStore store: new StateStore(), scope: scopes, diff --git a/server/routes/auth/providers/email.test.ts b/server/routes/auth/providers/email.test.ts index 43d0e7015..3000e3648 100644 --- a/server/routes/auth/providers/email.test.ts +++ b/server/routes/auth/providers/email.test.ts @@ -1,6 +1,7 @@ import TestServer from "fetch-test-server"; import SigninEmail from "@server/emails/templates/SigninEmail"; import WelcomeEmail from "@server/emails/templates/WelcomeEmail"; +import env from "@server/env"; import webService from "@server/services/web"; import { buildUser, buildGuestUser, buildTeam } from "@server/test/factories"; import { flushdb } from "@server/test/support"; @@ -40,8 +41,8 @@ describe("email", () => { }); it("should respond with redirect location when user is SSO enabled on another subdomain", async () => { - process.env.URL = "http://localoutline.com"; - process.env.SUBDOMAINS_ENABLED = "true"; + env.URL = "http://localoutline.com"; + env.SUBDOMAINS_ENABLED = true; const user = await buildUser(); const spy = jest.spyOn(WelcomeEmail, "schedule"); await buildTeam({ @@ -93,8 +94,8 @@ describe("email", () => { describe("with multiple users matching email", () => { it("should default to current subdomain with SSO", async () => { const spy = jest.spyOn(SigninEmail, "schedule"); - process.env.URL = "http://localoutline.com"; - process.env.SUBDOMAINS_ENABLED = "true"; + env.URL = "http://localoutline.com"; + env.SUBDOMAINS_ENABLED = true; const email = "sso-user@example.org"; const team = await buildTeam({ subdomain: "example", @@ -123,8 +124,8 @@ describe("email", () => { it("should default to current subdomain with guest email", async () => { const spy = jest.spyOn(SigninEmail, "schedule"); - process.env.URL = "http://localoutline.com"; - process.env.SUBDOMAINS_ENABLED = "true"; + env.URL = "http://localoutline.com"; + env.SUBDOMAINS_ENABLED = true; const email = "guest-user@example.org"; const team = await buildTeam({ subdomain: "example", diff --git a/server/routes/auth/providers/email.ts b/server/routes/auth/providers/email.ts index 5fc6b1d30..30375571b 100644 --- a/server/routes/auth/providers/email.ts +++ b/server/routes/auth/providers/email.ts @@ -4,6 +4,7 @@ import { find } from "lodash"; import { parseDomain, isCustomSubdomain } from "@shared/utils/domains"; import SigninEmail from "@server/emails/templates/SigninEmail"; import WelcomeEmail from "@server/emails/templates/WelcomeEmail"; +import env from "@server/env"; import { AuthorizationError } from "@server/errors"; import errorHandling from "@server/middlewares/errorHandling"; import methodOverride from "@server/middlewares/methodOverride"; @@ -43,7 +44,7 @@ router.post("email", errorHandling(), async (ctx) => { } if ( - process.env.SUBDOMAINS_ENABLED === "true" && + env.SUBDOMAINS_ENABLED && isCustomSubdomain(ctx.request.hostname) && !isCustomDomain(ctx.request.hostname) ) { diff --git a/server/routes/auth/providers/google.ts b/server/routes/auth/providers/google.ts index fe5840290..e85cd1de2 100644 --- a/server/routes/auth/providers/google.ts +++ b/server/routes/auth/providers/google.ts @@ -15,8 +15,6 @@ import { StateStore } from "@server/utils/passport"; const router = new Router(); const providerName = "google"; -const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; -const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; const scopes = [ "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email", @@ -24,7 +22,7 @@ const scopes = [ export const config = { name: "Google", - enabled: !!GOOGLE_CLIENT_ID, + enabled: !!env.GOOGLE_CLIENT_ID, }; type GoogleProfile = Profile & { @@ -35,12 +33,12 @@ type GoogleProfile = Profile & { }; }; -if (GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET) { +if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { passport.use( new GoogleStrategy( { - clientID: GOOGLE_CLIENT_ID, - clientSecret: GOOGLE_CLIENT_SECRET, + clientID: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, callbackURL: `${env.URL}/auth/google.callback`, passReqToCallback: true, // @ts-expect-error StateStore diff --git a/server/routes/auth/providers/oidc.ts b/server/routes/auth/providers/oidc.ts index 3cdc32d49..4e53e4d0e 100644 --- a/server/routes/auth/providers/oidc.ts +++ b/server/routes/auth/providers/oidc.ts @@ -1,4 +1,5 @@ import passport from "@outlinewiki/koa-passport"; +import { Request } from "koa"; import Router from "koa-router"; import { get } from "lodash"; import { Strategy } from "passport-oauth2"; @@ -13,21 +14,15 @@ import { StateStore, request } from "@server/utils/passport"; const router = new Router(); const providerName = "oidc"; -const OIDC_DISPLAY_NAME = process.env.OIDC_DISPLAY_NAME || "OpenID Connect"; -const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ""; -const OIDC_CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || ""; -const OIDC_AUTH_URI = process.env.OIDC_AUTH_URI || ""; -const OIDC_TOKEN_URI = process.env.OIDC_TOKEN_URI || ""; -const OIDC_USERINFO_URI = process.env.OIDC_USERINFO_URI || ""; -const OIDC_SCOPES = process.env.OIDC_SCOPES || ""; -const OIDC_USERNAME_CLAIM = - process.env.OIDC_USERNAME_CLAIM || "preferred_username"; +const OIDC_AUTH_URI = env.OIDC_AUTH_URI || ""; +const OIDC_TOKEN_URI = env.OIDC_TOKEN_URI || ""; +const OIDC_USERINFO_URI = env.OIDC_USERINFO_URI || ""; export const config = { - name: OIDC_DISPLAY_NAME, - enabled: !!OIDC_CLIENT_ID, + name: env.OIDC_DISPLAY_NAME, + enabled: !!env.OIDC_CLIENT_ID, }; -const scopes = OIDC_SCOPES.split(" "); +const scopes = env.OIDC_SCOPES.split(" "); Strategy.prototype.userProfile = async function (accessToken, done) { try { @@ -38,18 +33,18 @@ Strategy.prototype.userProfile = async function (accessToken, done) { } }; -if (OIDC_CLIENT_ID) { +if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) { passport.use( providerName, new Strategy( { authorizationURL: OIDC_AUTH_URI, tokenURL: OIDC_TOKEN_URI, - clientID: OIDC_CLIENT_ID, - clientSecret: OIDC_CLIENT_SECRET, + clientID: env.OIDC_CLIENT_ID, + clientSecret: env.OIDC_CLIENT_SECRET, callbackURL: `${env.URL}/auth/${providerName}.callback`, passReqToCallback: true, - scope: OIDC_SCOPES, + scope: env.OIDC_SCOPES, // @ts-expect-error custom state store store: new StateStore(), state: true, @@ -62,7 +57,7 @@ if (OIDC_CLIENT_ID) { // Any claim supplied in response to the userinfo request will be // available on the `profile` parameter async function ( - req: any, + req: Request, accessToken: string, refreshToken: string, profile: Record, @@ -97,7 +92,7 @@ if (OIDC_CLIENT_ID) { avatarUrl: profile.picture, // Claim name can be overriden using an env variable. // Default is 'preferred_username' as per OIDC spec. - username: get(profile, OIDC_USERNAME_CLAIM), + username: get(profile, env.OIDC_USERNAME_CLAIM), }, authenticationProvider: { name: providerName, diff --git a/server/routes/auth/providers/slack.ts b/server/routes/auth/providers/slack.ts index caafdb2bb..694a6b39b 100644 --- a/server/routes/auth/providers/slack.ts +++ b/server/routes/auth/providers/slack.ts @@ -39,8 +39,6 @@ type SlackProfile = Profile & { const router = new Router(); const providerName = "slack"; -const SLACK_CLIENT_ID = process.env.SLACK_KEY; -const SLACK_CLIENT_SECRET = process.env.SLACK_SECRET; const scopes = [ "identity.email", "identity.basic", @@ -50,14 +48,14 @@ const scopes = [ export const config = { name: "Slack", - enabled: !!SLACK_CLIENT_ID, + enabled: !!env.SLACK_CLIENT_ID, }; -if (SLACK_CLIENT_ID && SLACK_CLIENT_SECRET) { +if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { const strategy = new SlackStrategy( { - clientID: SLACK_CLIENT_ID, - clientSecret: SLACK_CLIENT_SECRET, + clientID: env.SLACK_CLIENT_ID, + clientSecret: env.SLACK_CLIENT_SECRET, callbackURL: `${env.URL}/auth/slack.callback`, passReqToCallback: true, // @ts-expect-error StateStore @@ -151,7 +149,7 @@ if (SLACK_CLIENT_ID && SLACK_CLIENT_SECRET) { } } - const endpoint = `${process.env.URL || ""}/auth/slack.commands`; + const endpoint = `${env.URL}/auth/slack.commands`; // @ts-expect-error ts-migrate(2345) FIXME: Argument of type 'string | string[] | undefined' i... Remove this comment to see the full error message const data = await Slack.oauthAccess(code, endpoint); const authentication = await IntegrationAuthentication.create({ @@ -210,7 +208,7 @@ if (SLACK_CLIENT_ID && SLACK_CLIENT_SECRET) { } } - const endpoint = `${process.env.URL || ""}/auth/slack.post`; + const endpoint = `${env.URL}/auth/slack.post`; const data = await Slack.oauthAccess(code as string, endpoint); const authentication = await IntegrationAuthentication.create({ service: "slack", diff --git a/server/routes/index.ts b/server/routes/index.ts index fb7a421e5..eb1198c67 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -16,8 +16,8 @@ import { robotsResponse } from "@server/utils/robots"; import apexRedirect from "../middlewares/apexRedirect"; import presentEnv from "../presenters/env"; -const isProduction = process.env.NODE_ENV === "production"; -const isTest = process.env.NODE_ENV === "test"; +const isProduction = env.ENVIRONMENT === "production"; +const isTest = env.ENVIRONMENT === "test"; const koa = new Koa(); const router = new Router(); const readFile = util.promisify(fs.readFile); @@ -62,7 +62,7 @@ const renderApp = async (ctx: Context, next: Next, title = "Outline") => { .replace(/\/\/inject-env\/\//g, environment) .replace(/\/\/inject-title\/\//g, title) .replace(/\/\/inject-prefetch\/\//g, shareId ? "" : prefetchTags) - .replace(/\/\/inject-slack-app-id\/\//g, process.env.SLACK_APP_ID || ""); + .replace(/\/\/inject-slack-app-id\/\//g, env.SLACK_APP_ID || ""); }; const renderShare = async (ctx: Context, next: Next) => { @@ -93,7 +93,7 @@ koa.use( }) ); -if (process.env.NODE_ENV === "production") { +if (isProduction) { router.get("/static/*", async (ctx) => { try { const pathname = ctx.path.substring(8); @@ -135,9 +135,7 @@ router.get("/locales/:lng.json", async (ctx) => { setHeaders: (res) => { res.setHeader( "Cache-Control", - process.env.NODE_ENV === "production" - ? `max-age=${7 * 24 * 60 * 60}` - : "no-cache" + isProduction ? `max-age=${7 * 24 * 60 * 60}` : "no-cache" ); }, root: path.join(__dirname, "../../shared/i18n/locales"), diff --git a/server/services/web.ts b/server/services/web.ts index 9ec1c5fd7..0ab4d524d 100644 --- a/server/services/web.ts +++ b/server/services/web.ts @@ -13,8 +13,9 @@ import routes from "../routes"; import api from "../routes/api"; import auth from "../routes/auth"; -const isProduction = env.NODE_ENV === "production"; -const isTest = env.NODE_ENV === "test"; +const isProduction = env.ENVIRONMENT === "production"; +const isTest = env.ENVIRONMENT === "test"; + // Construct scripts CSP based on services in use by this installation const defaultSrc = ["'self'"]; const scriptSrc = [ @@ -36,7 +37,7 @@ if (env.CDN_URL) { export default function init(app: Koa = new Koa()): Koa { if (isProduction) { // Force redirect to HTTPS protocol unless explicitly disabled - if (process.env.FORCE_HTTPS !== "false") { + if (env.FORCE_HTTPS) { app.use( enforceHttps({ // @ts-expect-error ts-migrate(2345) FIXME: Argument of type '{ trustProtoHeader: boolean; }' ... Remove this comment to see the full error message diff --git a/server/test/setup.ts b/server/test/setup.ts index f57020f9a..1a0d93fbd 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -1,12 +1,17 @@ -import "../env"; +import env from "../env"; // test environment variables -process.env.SMTP_HOST = "smtp.example.com"; -process.env.DATABASE_URL = process.env.DATABASE_URL_TEST; -process.env.NODE_ENV = "test"; -process.env.GOOGLE_CLIENT_ID = "123"; -process.env.SLACK_KEY = "123"; -process.env.DEPLOYMENT = ""; +env.SMTP_HOST = "smtp.example.com"; +env.ENVIRONMENT = "test"; +env.GOOGLE_CLIENT_ID = "123"; +env.GOOGLE_CLIENT_SECRET = "123"; +env.SLACK_CLIENT_ID = "123"; +env.SLACK_CLIENT_SECRET = "123"; +env.DEPLOYMENT = undefined; + +if (process.env.DATABASE_URL_TEST) { + env.DATABASE_URL = process.env.DATABASE_URL_TEST; +} // NOTE: this require must come after the ENV var override above // so that sequelize uses the test config variables diff --git a/server/utils/authentication.ts b/server/utils/authentication.ts index 76ca0588b..049470192 100644 --- a/server/utils/authentication.ts +++ b/server/utils/authentication.ts @@ -2,6 +2,7 @@ import querystring from "querystring"; import { addMonths } from "date-fns"; import { Context } from "koa"; import { pick } from "lodash"; +import env from "@server/env"; import Logger from "@server/logging/logger"; import { User, Event, Team, Collection, View } from "@server/models"; import { getCookieDomain } from "@server/utils/domains"; @@ -64,7 +65,7 @@ export async function signIn( // set a transfer cookie for the access token itself and redirect // to the teams subdomain if subdomains are enabled - if (process.env.SUBDOMAINS_ENABLED === "true" && team.subdomain) { + if (env.SUBDOMAINS_ENABLED && team.subdomain) { // get any existing sessions (teams signed in) and add this team const existing = JSON.parse( decodeURIComponent(ctx.cookies.get("sessions") || "") || "{}" diff --git a/server/utils/avatars.ts b/server/utils/avatars.ts index 46f2e6535..826b4afb7 100644 --- a/server/utils/avatars.ts +++ b/server/utils/avatars.ts @@ -1,8 +1,6 @@ import crypto from "crypto"; import fetch from "fetch-with-proxy"; - -export const DEFAULT_AVATAR_HOST = - process.env.DEFAULT_AVATAR_HOST || "https://tiley.herokuapp.com"; +import env from "@server/env"; export async function generateAvatarUrl({ id, @@ -30,8 +28,8 @@ export async function generateAvatarUrl({ } } - const tileyUrl = `${DEFAULT_AVATAR_HOST}/avatar/${hashedId}/${encodeURIComponent( - name[0] - )}.png`; + const tileyUrl = `${ + env.DEFAULT_AVATAR_HOST + }/avatar/${hashedId}/${encodeURIComponent(name[0])}.png`; return cbUrl && cbResponse && cbResponse.status === 200 ? cbUrl : tileyUrl; } diff --git a/server/utils/domains.ts b/server/utils/domains.ts index 4e7b6d414..6793c952b 100644 --- a/server/utils/domains.ts +++ b/server/utils/domains.ts @@ -1,14 +1,13 @@ import { parseDomain, stripSubdomain } from "@shared/utils/domains"; +import env from "@server/env"; export function getCookieDomain(domain: string) { - return process.env.SUBDOMAINS_ENABLED === "true" - ? stripSubdomain(domain) - : domain; + return env.SUBDOMAINS_ENABLED ? stripSubdomain(domain) : domain; } export function isCustomDomain(hostname: string) { const parsed = parseDomain(hostname); - const main = parseDomain(process.env.URL); + const main = parseDomain(env.URL); return ( parsed && main && (main.domain !== parsed.domain || main.tld !== parsed.tld) diff --git a/server/utils/parseAttachmentIds.test.ts b/server/utils/parseAttachmentIds.test.ts index 2ea74cf66..b2052012f 100644 --- a/server/utils/parseAttachmentIds.test.ts +++ b/server/utils/parseAttachmentIds.test.ts @@ -1,5 +1,6 @@ import { expect } from "@jest/globals"; import { v4 as uuidv4 } from "uuid"; +import env from "@server/env"; import parseAttachmentIds from "./parseAttachmentIds"; it("should return an empty array with no matches", () => { @@ -32,9 +33,8 @@ it("should parse attachment ID from markdown with additional query params", () = it("should parse attachment ID from markdown with fully qualified url", () => { const uuid = uuidv4(); const results = parseAttachmentIds( - `![caption text](${process.env.URL}/api/attachments.redirect?id=${uuid})` + `![caption text](${env.URL}/api/attachments.redirect?id=${uuid})` ); - expect(process.env.URL).toBeTruthy(); expect(results.length).toBe(1); expect(results[0]).toBe(uuid); }); @@ -69,9 +69,8 @@ it("should parse attachment ID from html", () => { it("should parse attachment ID from html with fully qualified url", () => { const uuid = uuidv4(); const results = parseAttachmentIds( - `` + `` ); - expect(process.env.URL).toBeTruthy(); expect(results.length).toBe(1); expect(results[0]).toBe(uuid); }); diff --git a/server/utils/queue.ts b/server/utils/queue.ts index 09f3f3d67..1732119a8 100644 --- a/server/utils/queue.ts +++ b/server/utils/queue.ts @@ -1,5 +1,6 @@ import Queue from "bull"; import { snakeCase } from "lodash"; +import env from "@server/env"; import Metrics from "@server/logging/metrics"; import Redis from "../redis"; @@ -18,7 +19,7 @@ export function createQueue( return Redis.defaultSubscriber; default: - return new Redis(process.env.REDIS_URL); + return new Redis(env.REDIS_URL); } }, diff --git a/server/utils/robots.ts b/server/utils/robots.ts index 8f2bf469f..702639d1c 100644 --- a/server/utils/robots.ts +++ b/server/utils/robots.ts @@ -1,8 +1,10 @@ +import env from "@server/env"; + const DISALLOW_ROBOTS = `User-agent: * Disallow: /`; export const robotsResponse = () => { - if (process.env.DEPLOYMENT !== "hosted") { + if (env.DEPLOYMENT !== "hosted") { return DISALLOW_ROBOTS; } diff --git a/server/utils/slack.ts b/server/utils/slack.ts index 8ee6f496e..131369984 100644 --- a/server/utils/slack.ts +++ b/server/utils/slack.ts @@ -1,5 +1,6 @@ import querystring from "querystring"; import fetch from "fetch-with-proxy"; +import env from "@server/env"; import { InvalidRequestError } from "../errors"; const SLACK_API_URL = "https://slack.com/api"; @@ -48,11 +49,11 @@ export async function request(endpoint: string, body: Record) { export async function oauthAccess( code: string, - redirect_uri = `${process.env.URL || ""}/auth/slack.callback` + redirect_uri = `${env.URL}/auth/slack.callback` ) { return request("oauth.access", { - client_id: process.env.SLACK_KEY, - client_secret: process.env.SLACK_SECRET, + client_id: env.SLACK_CLIENT_ID, + client_secret: env.SLACK_CLIENT_SECRET, redirect_uri, code, }); diff --git a/server/utils/startup.ts b/server/utils/startup.ts index 9166d969d..6ec279222 100644 --- a/server/utils/startup.ts +++ b/server/utils/startup.ts @@ -1,10 +1,11 @@ import chalk from "chalk"; +import env from "@server/env"; import Logger from "@server/logging/logger"; import AuthenticationProvider from "@server/models/AuthenticationProvider"; import Team from "@server/models/Team"; export async function checkMigrations() { - if (process.env.DEPLOYMENT === "hosted") { + if (env.DEPLOYMENT === "hosted") { return; } @@ -22,92 +23,27 @@ $ node ./build/server/scripts/20210226232041-migrate-authentication.js } } -export function checkEnv() { - const errors = []; - - if ( - !process.env.SECRET_KEY || - process.env.SECRET_KEY === "generate_a_new_key" - ) { - errors.push( - `The ${chalk.bold( - "SECRET_KEY" - )} env variable must be set with the output of ${chalk.bold( - "$ openssl rand -hex 32" - )}` - ); - } - - if ( - !process.env.UTILS_SECRET || - process.env.UTILS_SECRET === "generate_a_new_key" - ) { - errors.push( - `The ${chalk.bold( - "UTILS_SECRET" - )} env variable must be set with a secret value, it is recommended to use the output of ${chalk.bold( - "$ openssl rand -hex 32" - )}` - ); - } - - if (process.env.AWS_ACCESS_KEY_ID) { - [ - "AWS_REGION", - "AWS_SECRET_ACCESS_KEY", - "AWS_S3_UPLOAD_BUCKET_URL", - "AWS_S3_UPLOAD_MAX_SIZE", - ].forEach((key) => { - if (!process.env[key]) { - errors.push( - `The ${chalk.bold( - key - )} env variable must be set when using S3 compatible storage` - ); +export async function checkEnv() { + await env.validate().then((errors) => { + if (errors.length > 0) { + Logger.warn( + "Environment configuration is invalid, please check the following:\n\n" + ); + for (const error of errors) { + Logger.warn("- " + Object.values(error.constraints ?? {}).join(", ")); } - }); - } + process.exit(1); + } + }); - if (!process.env.URL) { - errors.push( - `The ${chalk.bold( - "URL" - )} env variable must be set to the fully qualified, externally accessible URL, e.g https://wiki.mycompany.com` - ); - } - - if (!process.env.DATABASE_URL && !process.env.DATABASE_CONNECTION_POOL_URL) { - errors.push( - `The ${chalk.bold( - "DATABASE_URL" - )} env variable must be set to the location of your postgres server, including username, password, and port` - ); - } - - if (!process.env.REDIS_URL) { - errors.push( - `The ${chalk.bold( - "REDIS_URL" - )} env variable must be set to the location of your redis server, including username, password, and port` - ); - } - - if (errors.length) { - Logger.warn( - "\n\nThe server could not start, please fix the following configuration errors and try again:\n" + - errors.map((e) => `- ${e}`).join("\n") - ); - process.exit(1); - } - - if (process.env.NODE_ENV === "production") { + if (env.ENVIRONMENT === "production") { Logger.info( "lifecycle", chalk.green(` Is your team enjoying Outline? Consider supporting future development by sponsoring the project:\n\nhttps://github.com/sponsors/outline `) ); - } else if (process.env.NODE_ENV === "development") { + } else if (env.ENVIRONMENT === "development") { Logger.warn( `Running Outline in ${chalk.bold( "development mode" diff --git a/server/utils/updates.ts b/server/utils/updates.ts index ee31781ac..ea1435dc6 100644 --- a/server/utils/updates.ts +++ b/server/utils/updates.ts @@ -1,22 +1,18 @@ import crypto from "crypto"; import fetch from "fetch-with-proxy"; -import invariant from "invariant"; +import env from "@server/env"; import Collection from "@server/models/Collection"; import Document from "@server/models/Document"; import Team from "@server/models/Team"; import User from "@server/models/User"; +import Redis from "@server/redis"; import packageInfo from "../../package.json"; -import Redis from "../redis"; const UPDATES_URL = "https://updates.getoutline.com"; const UPDATES_KEY = "UPDATES_KEY"; export async function checkUpdates() { - invariant( - process.env.SECRET_KEY && process.env.URL, - "SECRET_KEY or URL env var is not set" - ); - const secret = process.env.SECRET_KEY.slice(0, 6) + process.env.URL; + const secret = env.SECRET_KEY.slice(0, 6) + env.URL; const id = crypto.createHash("sha256").update(secret).digest("hex"); const [ userCount, diff --git a/server/utils/validators.ts b/server/utils/validators.ts new file mode 100644 index 000000000..63b37c196 --- /dev/null +++ b/server/utils/validators.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from "class-validator"; + +export function CannotUseWithout( + property: string, + validationOptions?: ValidationOptions +) { + return function (object: Object, propertyName: string) { + registerDecorator({ + name: "cannotUseWithout", + target: object.constructor, + propertyName: propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: T, args: ValidationArguments) { + const object = (args.object as unknown) as T; + const required = args.constraints[0] as string; + return object[required] !== undefined; + }, + defaultMessage(args: ValidationArguments) { + return `${propertyName} cannot be used without ${args.constraints[0]}.`; + }, + }, + }); + }; +} diff --git a/shared/i18n/index.test.ts b/shared/i18n/index.test.ts index 508fd8678..64b7fdab5 100644 --- a/shared/i18n/index.test.ts +++ b/shared/i18n/index.test.ts @@ -4,9 +4,8 @@ import en_US from "./locales/en_US/translation.json"; import pt_PT from "./locales/pt_PT/translation.json"; import { initI18n } from "."; -describe("i18n process.env is unset", () => { +describe("i18n env is unset", () => { beforeEach(() => { - delete process.env.DEFAULT_LANGUAGE; initI18n() .addResources("en-US", "translation", en_US) .addResources("de-DE", "translation", de_DE) @@ -26,10 +25,9 @@ describe("i18n process.env is unset", () => { expect(i18n.t("Saving")).toBe("A guardar"); }); }); -describe("i18n process.env is en-US", () => { +describe("i18n env is en-US", () => { beforeEach(() => { - process.env.DEFAULT_LANGUAGE = "en-US"; - initI18n() + initI18n("en-US") .addResources("en-US", "translation", en_US) .addResources("de-DE", "translation", de_DE) .addResources("pt-PT", "translation", pt_PT); @@ -48,10 +46,10 @@ describe("i18n process.env is en-US", () => { expect(i18n.t("Saving")).toBe("A guardar"); }); }); -describe("i18n process.env is de-DE", () => { + +describe("i18n env is de-DE", () => { beforeEach(() => { - process.env.DEFAULT_LANGUAGE = "de-DE"; - initI18n() + initI18n("de-DE") .addResources("en-US", "translation", en_US) .addResources("de-DE", "translation", de_DE) .addResources("pt-PT", "translation", pt_PT); @@ -70,10 +68,10 @@ describe("i18n process.env is de-DE", () => { expect(i18n.t("Saving")).toBe("A guardar"); }); }); -describe("i18n process.env is pt-PT", () => { + +describe("i18n env is pt-PT", () => { beforeEach(() => { - process.env.DEFAULT_LANGUAGE = "pt-PT"; - initI18n() + initI18n("pt-PT") .addResources("en-US", "translation", en_US) .addResources("de-DE", "translation", de_DE) .addResources("pt-PT", "translation", pt_PT); diff --git a/shared/i18n/index.ts b/shared/i18n/index.ts index 4c2b22ee4..8d295a287 100644 --- a/shared/i18n/index.ts +++ b/shared/i18n/index.ts @@ -79,10 +79,8 @@ const underscoreToDash = (text: string) => text.replace("_", "-"); const dashToUnderscore = (text: string) => text.replace("-", "_"); -export const initI18n = () => { - const lng = underscoreToDash( - "DEFAULT_LANGUAGE" in process.env ? process.env.DEFAULT_LANGUAGE! : "en_US" - ); +export const initI18n = (defaultLanguage = "en_US") => { + const lng = underscoreToDash(defaultLanguage); i18n .use(backend) .use(initReactI18next) @@ -104,7 +102,6 @@ export const initI18n = () => { fallbackLng: lng, supportedLngs: languages.map(underscoreToDash), // Uncomment when debugging translation framework, otherwise it's noisy - // debug: process.env.NODE_ENV === "development", keySeparator: false, }); return i18n; diff --git a/shared/types.ts b/shared/types.ts index 8587840c3..86514bf17 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -8,15 +8,16 @@ export type PublicEnv = { COLLABORATION_URL: string; AWS_S3_UPLOAD_BUCKET_URL: string; AWS_S3_ACCELERATE_URL: string; - DEPLOYMENT: "hosted" | ""; - ENVIRONMENT: "production" | "development"; + DEPLOYMENT: string | undefined; + ENVIRONMENT: string; SENTRY_DSN: string | undefined; TEAM_LOGO: string | undefined; - SLACK_KEY: string | undefined; + SLACK_CLIENT_ID: string | undefined; SLACK_APP_ID: string | undefined; MAXIMUM_IMPORT_SIZE: number; SUBDOMAINS_ENABLED: boolean; EMAIL_ENABLED: boolean; + DEFAULT_LANGUAGE: string; GOOGLE_ANALYTICS_ID: string | undefined; RELEASE: string | undefined; }; diff --git a/shared/utils/urlHelpers.ts b/shared/utils/urlHelpers.ts index e665d0052..76215feb4 100644 --- a/shared/utils/urlHelpers.ts +++ b/shared/utils/urlHelpers.ts @@ -1,3 +1,5 @@ +import env from "../env"; + export function slackAuth( state: string, scopes: string[] = [ @@ -6,9 +8,8 @@ export function slackAuth( "identity.avatar", "identity.team", ], - // @ts-expect-error ts-migrate(2322) FIXME: Type 'string | undefined' is not assignable to typ... Remove this comment to see the full error message - clientId: string = process.env.SLACK_KEY, - redirectUri = `${process.env.URL}/auth/slack.callback` + clientId: string, + redirectUri = `${env.URL}/auth/slack.callback` ): string { const baseUrl = "https://slack.com/oauth/authorize"; const params = { @@ -48,7 +49,7 @@ export function changelogUrl(): string { } export function signin(service = "slack"): string { - return `${process.env.URL}/auth/${service}`; + return `${env.URL}/auth/${service}`; } export const SLUG_URL_REGEX = /^(?:[0-9a-zA-Z-_~]*-)?([a-zA-Z0-9]{10,15})$/; diff --git a/yarn.lock b/yarn.lock index cb3b8b070..0d9e835f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5189,6 +5189,14 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +class-validator@^0.13.2: + version "0.13.2" + resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.13.2.tgz#64b031e9f3f81a1e1dcd04a5d604734608b24143" + integrity sha512-yBUcQy07FPlGzUjoLuUfIOXzgynnQPPruyK1Ge2B74k9ROwnle1E+NxLWnUv5OLU8hA/qL5leAE9XnXq3byaBw== + dependencies: + libphonenumber-js "^1.9.43" + validator "^13.7.0" + clean-css@^4.0.12, clean-css@^4.2.3: version "4.2.4" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178" @@ -10080,6 +10088,11 @@ lib0@^0.2.35, lib0@^0.2.42, lib0@^0.2.46, lib0@^0.2.47, lib0@^0.2.49: dependencies: isomorphic.js "^0.2.4" +libphonenumber-js@^1.9.43: + version "1.9.52" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.9.52.tgz#662ea92dcb6761ceb2dc9a8cd036aadd355bc999" + integrity sha512-8k83chc+zMj+J/RkaBxi0PpSTAdzHmpqzCMqquSJVRfbZFr8DCp6vPC7ms2PIPGxeqajZLI6CBLW5nLCJCJrYg== + lie@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" @@ -15002,7 +15015,7 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -validator@13.7.0, validator@^13.6.0: +validator@13.7.0, validator@^13.6.0, validator@^13.7.0: version "13.7.0" resolved "https://registry.yarnpkg.com/validator/-/validator-13.7.0.tgz#4f9658ba13ba8f3d82ee881d3516489ea85c0857" integrity sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==