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

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