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
This commit is contained in:
Tom Moor
2022-05-19 08:05:11 -07:00
committed by GitHub
parent 51001cfac1
commit 3c002f82cc
66 changed files with 783 additions and 341 deletions

View File

@@ -65,8 +65,8 @@ AWS_S3_ACL=private
#
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
# https://<URL>/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

View File

@@ -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
},

View File

@@ -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<Props> {
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
debug: env.ENVIRONMENT !== "production",
format: "text/plain",
});

View File

@@ -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,
},

View File

@@ -83,7 +83,7 @@ function Slack() {
}}
/>
</Text>
{env.SLACK_KEY ? (
{env.SLACK_CLIENT_ID ? (
<>
<p>
{commandIntegration ? (

View File

@@ -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 (
<Button onClick={handleClick} icon={icon} neutral>

View File

@@ -13,7 +13,7 @@ export function initSentry(history: History) {
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),
}),
],
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
tracesSampleRate: env.ENVIRONMENT === "production" ? 0.1 : 1,
ignoreErrors: [
"ResizeObserver loop completed with undelivered notifications",
"ResizeObserver loop limit exceeded",

View File

@@ -71,6 +71,7 @@
"bull": "^3.29.0",
"cancan": "3.1.0",
"chalk": "^4.1.0",
"class-validator": "^0.13.2",
"compressorjs": "^1.0.7",
"copy-to-clipboard": "^3.3.1",
"core-js": "^3.10.2",

View File

@@ -1,3 +1,4 @@
import env from "@server/env";
import TeamDomain from "@server/models/TeamDomain";
import { buildTeam, buildUser } from "@server/test/factories";
import { flushdb } from "@server/test/support";
@@ -25,7 +26,7 @@ describe("teamCreator", () => {
});
it("should not allow creating multiple teams in installation", async () => {
delete process.env.DEPLOYMENT;
env.DEPLOYMENT = undefined;
await buildTeam();
let error;
@@ -47,7 +48,7 @@ describe("teamCreator", () => {
});
it("should return existing team when within allowed domains", async () => {
delete process.env.DEPLOYMENT;
env.DEPLOYMENT = undefined;
const existing = await buildTeam();
const user = await buildUser({
teamId: existing.id,
@@ -106,7 +107,7 @@ describe("teamCreator", () => {
});
it("should return exising team", async () => {
delete process.env.DEPLOYMENT;
env.DEPLOYMENT = undefined;
const authenticationProvider = {
name: "google",
providerId: "example.com",

View File

@@ -1,4 +1,5 @@
import invariant from "invariant";
import env from "@server/env";
import { DomainNotAllowedError, MaximumTeamsError } from "@server/errors";
import Logger from "@server/logging/logger";
import { APM } from "@server/logging/tracing";
@@ -53,7 +54,7 @@ async function teamCreator({
// This team has never been seen before, if self hosted the logic is different
// to the multi-tenant version, we want to restrict to a single team that MAY
// have multiple authentication providers
if (process.env.DEPLOYMENT !== "hosted") {
if (env.DEPLOYMENT !== "hosted") {
const teamCount = await Team.count();
// If the self-hosted installation has a single team and the domain for the

View File

@@ -1,5 +1,6 @@
import { Transaction } from "sequelize";
import { sequelize } from "@server/database/sequelize";
import env from "@server/env";
import { Event, Team, TeamDomain, User } from "@server/models";
type TeamUpdaterProps = {
@@ -27,7 +28,7 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
const transaction: Transaction = await sequelize.transaction();
if (subdomain !== undefined && process.env.SUBDOMAINS_ENABLED === "true") {
if (subdomain !== undefined && env.SUBDOMAINS_ENABLED) {
team.subdomain = subdomain === "" ? null : subdomain;
}

View File

@@ -2,6 +2,7 @@ import invariant from "invariant";
import { uniqBy } from "lodash";
import { Role } from "@shared/types";
import InviteEmail from "@server/emails/templates/InviteEmail";
import env from "@server/env";
import Logger from "@server/logging/logger";
import { User, Event, Team } from "@server/models";
import { UserFlag } from "@server/models/User";
@@ -89,11 +90,11 @@ export default async function userInviter({
teamUrl: team.url,
});
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=${newUser.getEmailSigninToken()}`
);
}

View File

@@ -1,14 +1,15 @@
import { Sequelize } from "sequelize-typescript";
import env from "@server/env";
import Logger from "../logging/logger";
import * as models from "../models";
const isProduction = process.env.NODE_ENV === "production";
const isSSLDisabled = process.env.PGSSLMODE === "disable";
const poolMax = parseInt(process.env.DATABASE_CONNECTION_POOL_MAX || "5", 10);
const poolMin = parseInt(process.env.DATABASE_CONNECTION_POOL_MIN || "0", 10);
const isProduction = env.ENVIRONMENT === "production";
const isSSLDisabled = env.PGSSLMODE === "disable";
const poolMax = env.DATABASE_CONNECTION_POOL_MAX ?? 5;
const poolMin = env.DATABASE_CONNECTION_POOL_MIN ?? 0;
export const sequelize = new Sequelize(
process.env.DATABASE_URL || process.env.DATABASE_CONNECTION_POOL_URL || "",
env.DATABASE_URL ?? env.DATABASE_CONNECTION_POOL_URL ?? "",
{
logging: (msg) => Logger.debug("database", msg),
typeValidation: true,

View File

@@ -1,9 +1,10 @@
import SequelizeEncrypted from "sequelize-encrypted";
import { Sequelize } from "sequelize-typescript";
import env from "@server/env";
/**
* Encrypted field storage, use via the Encrypted decorator, not directly.
*/
export default function vaults() {
return SequelizeEncrypted(Sequelize, process.env.SECRET_KEY);
return SequelizeEncrypted(Sequelize, env.SECRET_KEY);
}

View File

@@ -1,12 +1,13 @@
import nodemailer, { Transporter } from "nodemailer";
import Oy from "oy-vey";
import * as React from "react";
import env from "@server/env";
import Logger from "@server/logging/logger";
import { APM } from "@server/logging/tracing";
import { baseStyles } from "./templates/components/EmailLayout";
const useTestEmailService =
process.env.NODE_ENV === "development" && !process.env.SMTP_USERNAME;
env.ENVIRONMENT === "development" && !env.SMTP_USERNAME;
type SendMailOptions = {
to: string;
@@ -27,7 +28,7 @@ export class Mailer {
transporter: Transporter | undefined;
constructor() {
if (process.env.SMTP_HOST) {
if (env.SMTP_HOST) {
this.transporter = nodemailer.createTransport(this.getOptions());
}
if (useTestEmailService) {
@@ -69,8 +70,8 @@ export class Mailer {
try {
Logger.info("email", `Sending email "${data.subject}" to ${data.to}`);
const info = await transporter.sendMail({
from: process.env.SMTP_FROM_EMAIL,
replyTo: process.env.SMTP_REPLY_EMAIL || process.env.SMTP_FROM_EMAIL,
from: env.SMTP_FROM_EMAIL,
replyTo: env.SMTP_REPLY_EMAIL ?? env.SMTP_FROM_EMAIL,
to: data.to,
subject: data.subject,
html,
@@ -91,24 +92,20 @@ export class Mailer {
private getOptions() {
return {
host: process.env.SMTP_HOST || "",
port: parseInt(process.env.SMTP_PORT || "", 10),
secure:
"SMTP_SECURE" in process.env
? process.env.SMTP_SECURE === "true"
: process.env.NODE_ENV === "production",
auth: process.env.SMTP_USERNAME
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE ?? env.ENVIRONMENT === "production",
auth: env.SMTP_USERNAME
? {
user: process.env.SMTP_USERNAME || "",
pass: process.env.SMTP_PASSWORD,
user: env.SMTP_USERNAME,
pass: env.SMTP_PASSWORD,
}
: undefined,
tls: env.SMTP_TLS_CIPHERS
? {
ciphers: env.SMTP_TLS_CIPHERS,
}
: undefined,
tls:
"SMTP_TLS_CIPHERS" in process.env
? {
ciphers: process.env.SMTP_TLS_CIPHERS,
}
: undefined,
};
}

View File

@@ -1,5 +1,6 @@
import invariant from "invariant";
import * as React from "react";
import env from "@server/env";
import { Collection } from "@server/models";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
@@ -54,7 +55,7 @@ ${collection.name}
${collection.user.name} ${eventName} the collection "${collection.name}"
Open Collection: ${process.env.URL}${collection.url}
Open Collection: ${env.URL}${collection.url}
`;
}
@@ -75,7 +76,7 @@ Open Collection: ${process.env.URL}${collection.url}
</p>
<EmptySpace height={10} />
<p>
<Button href={`${process.env.URL}${collection.url}`}>
<Button href={`${env.URL}${collection.url}`}>
Open Collection
</Button>
</p>

View File

@@ -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}`;
}
}

View File

@@ -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) => {
<TBody>
<TR>
<TD style={footerStyle}>
<a href={process.env.URL} style={linkStyle}>
<a href={env.URL} style={linkStyle}>
Outline
</a>
<a href={twitterUrl()} style={externalLinkStyle}>

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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,

View File

@@ -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)));
}

View File

@@ -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);

View File

@@ -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}`],
});
}

View File

@@ -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"];

View File

@@ -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",
}
);
}

View File

@@ -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;
}

View File

@@ -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");
};
}

View File

@@ -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(/\/$/, "");
}

View File

@@ -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(

View File

@@ -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;

View File

@@ -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<string, any>): 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,
};
}

View File

@@ -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;

View File

@@ -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,
},
],

View File

@@ -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));
}
}

View File

@@ -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: [

View File

@@ -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(),
},
};

View File

@@ -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");
}

View File

@@ -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",
},

View File

@@ -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",

View File

@@ -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;

View File

@@ -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()}`
);
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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)
) {

View File

@@ -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

View File

@@ -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<string, string>,
@@ -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,

View File

@@ -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",

View File

@@ -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"),

View File

@@ -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

View File

@@ -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

View File

@@ -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") || "") || "{}"

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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(
`<img src="${process.env.URL}/api/attachments.redirect?id=${uuid}" />`
`<img src="${env.URL}/api/attachments.redirect?id=${uuid}" />`
);
expect(process.env.URL).toBeTruthy();
expect(results.length).toBe(1);
expect(results[0]).toBe(uuid);
});

View File

@@ -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);
}
},

View File

@@ -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;
}

View File

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

View File

@@ -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"

View File

@@ -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,

View File

@@ -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<T>(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]}.`;
},
},
});
};
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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})$/;

View File

@@ -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==