From 60e52d0423532dbcb67010564b159cc557f6b829 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Tue, 27 Feb 2024 09:24:23 -0800 Subject: [PATCH] Separate environment configs (#6597) * Separate environment configs * wip * wip * test * plugins * test * test * .sequelizerc, unfortunately can't go through /utils/environment due to not supporting TS * docker-compose -> docker compose * fix: .local wipes .development * Add custom validation message for invalid SECRET_KEY (often confused) --- .circleci/config.yml | 7 +- .env.development | 10 + .env.sample | 19 +- .env.test | 26 ++ .gitignore | 2 + .jestconfig.json | 2 +- .sequelizerc | 5 +- Makefile | 24 +- app/actions/definitions/navigation.tsx | 4 - app/scenes/Logout.tsx | 5 - app/stores/AuthStore.ts | 29 +- package.json | 3 +- plugins/azure/server/auth/azure.ts | 2 +- .../utils => plugins/azure/server}/azure.ts | 12 +- plugins/azure/server/env.ts | 27 ++ plugins/google/server/auth/google.ts | 2 +- plugins/google/server/env.ts | 21 ++ plugins/google/server/google.ts | 18 + plugins/iframely/plugin.json | 2 +- plugins/iframely/server/env.ts | 27 ++ plugins/iframely/server/iframely.ts | 2 +- plugins/oidc/plugin.json | 9 +- plugins/oidc/server/auth/oidc.ts | 2 +- plugins/oidc/server/env.ts | 79 +++++ plugins/oidc/server/oidc.ts | 18 + plugins/slack/server/api/hooks.test.ts | 2 +- plugins/slack/server/api/hooks.ts | 2 +- plugins/slack/server/auth/slack.ts | 2 +- plugins/slack/server/env.ts | 44 +++ .../slack/server/processors/SlackProcessor.ts | 2 +- plugins/slack/server/slack.ts | 2 +- plugins/storage/server/api/files.test.ts | 1 - server/config/database.json | 4 +- server/env.ts | 332 +++++------------- server/models/AuthenticationProvider.ts | 24 +- server/models/helpers/AuthenticationHelper.ts | 3 +- server/test/env.ts | 27 -- server/test/globalSetup.js | 1 - server/typings/index.d.ts | 2 - server/utils/environment.ts | 39 ++ server/utils/google.ts | 9 - server/utils/oidc.ts | 10 - server/utils/unfurl.ts | 3 +- vite.config.ts | 15 +- yarn.lock | 17 +- 45 files changed, 489 insertions(+), 409 deletions(-) create mode 100644 .env.development create mode 100644 .env.test rename {server/utils => plugins/azure/server}/azure.ts (73%) create mode 100644 plugins/azure/server/env.ts create mode 100644 plugins/google/server/env.ts create mode 100644 plugins/google/server/google.ts create mode 100644 plugins/iframely/server/env.ts create mode 100644 plugins/oidc/server/env.ts create mode 100644 plugins/oidc/server/oidc.ts create mode 100644 plugins/slack/server/env.ts delete mode 100644 server/test/env.ts create mode 100644 server/utils/environment.ts delete mode 100644 server/utils/google.ts delete mode 100644 server/utils/oidc.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 6a7663e84..451a77910 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,13 +13,8 @@ defaults: &defaults resource_class: large environment: NODE_ENV: test - SECRET_KEY: F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B - DATABASE_URL_TEST: postgres://postgres:password@localhost:5432/circle_test DATABASE_URL: postgres://postgres:password@localhost:5432/circle_test URL: http://localhost:3000 - SMTP_FROM_EMAIL: hello@example.com - AWS_S3_UPLOAD_BUCKET_URL: https://s3.amazonaws.com - AWS_S3_UPLOAD_BUCKET_NAME: outline-circle NODE_OPTIONS: --max-old-space-size=8000 executors: @@ -89,7 +84,7 @@ jobs: key: dependency-cache-v1-{{ checksum "package.json" }} - run: name: migrate - command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST + command: ./node_modules/.bin/sequelize db:migrate - run: name: test command: | diff --git a/.env.development b/.env.development new file mode 100644 index 000000000..2edaa9d12 --- /dev/null +++ b/.env.development @@ -0,0 +1,10 @@ +URL=https://local.outline.dev:3000 + +SMTP_FROM_EMAIL=hello@example.com + +# Enable unsafe-inline in script-src CSP directive +# Setting it to true allows React dev tools add-on in Firefox to successfully detect the project +DEVELOPMENT_UNSAFE_INLINE_CSP=true + +# Increase the log level to debug for development +LOG_LEVEL=debug diff --git a/.env.sample b/.env.sample index 4b8d84b63..355e2f199 100644 --- a/.env.sample +++ b/.env.sample @@ -13,7 +13,6 @@ UTILS_SECRET=generate_a_new_key # For production point these at your databases, in development the default # should work out of the box. DATABASE_URL=postgres://user:pass@localhost:5432/outline -DATABASE_URL_TEST=postgres://user:pass@localhost:5432/outline-test DATABASE_CONNECTION_POOL_MIN= DATABASE_CONNECTION_POOL_MAX= # Uncomment this to disable SSL for connecting to Postgres @@ -30,7 +29,7 @@ REDIS_URL=redis://localhost:6379 # URL should point to the fully qualified, publicly accessible URL. If using a # proxy the port in URL and PORT may be different. -URL=https://app.outline.dev:3000 +URL= PORT=3000 # See [documentation](docs/SERVICES.md) on running a separate collaboration @@ -166,9 +165,6 @@ SLACK_VERIFICATION_TOKEN=your_token SLACK_APP_ID=A0XXXXXXX SLACK_MESSAGE_ACTIONS=true -# Optionally enable google analytics to track pageviews in the knowledge base -GOOGLE_ANALYTICS_ID= - # Optionally enable Sentry (sentry.io) to track errors and performance, # and optionally add a Sentry proxy tunnel for bypassing ad blockers in the UI: # https://docs.sentry.io/platforms/javascript/troubleshooting/#using-the-tunnel-option) @@ -181,8 +177,8 @@ SMTP_HOST= SMTP_PORT= SMTP_USERNAME= SMTP_PASSWORD= -SMTP_FROM_EMAIL=hello@example.com -SMTP_REPLY_EMAIL=hello@example.com +SMTP_FROM_EMAIL= +SMTP_REPLY_EMAIL= SMTP_TLS_CIPHERS= SMTP_SECURE=true @@ -198,10 +194,5 @@ RATE_LIMITER_REQUESTS=1000 RATE_LIMITER_DURATION_WINDOW=60 # Iframely API config -# IFRAMELY_URL= -# IFRAMELY_API_KEY= - -# Enable unsafe-inline in script-src CSP directive -# Setting it to true allows React dev tools add-on in -# Firefox to successfully detect the project -DEVELOPMENT_UNSAFE_INLINE_CSP=false +IFRAMELY_URL= +IFRAMELY_API_KEY= \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 000000000..ab2700f2f --- /dev/null +++ b/.env.test @@ -0,0 +1,26 @@ +NODE_ENV=test +DATABASE_URL=postgres://user:pass@127.0.0.1:5432/outline-test +SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B + +SMTP_HOST=smtp.example.com +SMTP_FROM_EMAIL=hello@example.com +SMTP_REPLY_EMAIL=hello@example.com + +GOOGLE_CLIENT_ID=123 +GOOGLE_CLIENT_SECRET=123 + +SLACK_CLIENT_ID=123 +SLACK_CLIENT_SECRET=123 + +OIDC_CLIENT_ID=client-id +OIDC_CLIENT_SECRET=client-secret +OIDC_AUTH_URI=http://localhost/authorize +OIDC_TOKEN_URI=http://localhost/token +OIDC_USERINFO_URI=http://localhost/userinfo + +IFRAMELY_API_KEY=123 + +RATE_LIMITER_ENABLED=false + +FILE_STORAGE=local +FILE_STORAGE_LOCAL_ROOT_DIR=/tmp diff --git a/.gitignore b/.gitignore index 84aa962b3..83dc8ad39 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ dist build node_modules/* .env +.env.local +.env.production .log .vscode/* npm-debug.log diff --git a/.jestconfig.json b/.jestconfig.json index 89207fab6..2e8ae9004 100644 --- a/.jestconfig.json +++ b/.jestconfig.json @@ -9,7 +9,7 @@ "^@server/(.*)$": "/server/$1", "^@shared/(.*)$": "/shared/$1" }, - "setupFiles": ["/__mocks__/console.js", "/server/test/env.ts"], + "setupFiles": ["/__mocks__/console.js"], "setupFilesAfterEnv": ["/server/test/setup.ts"], "globalSetup": "/server/test/globalSetup.js", "globalTeardown": "/server/test/globalTeardown.js", diff --git a/.sequelizerc b/.sequelizerc index aa05bd025..f8d239ac1 100644 --- a/.sequelizerc +++ b/.sequelizerc @@ -1,4 +1,6 @@ -require('dotenv').config({ silent: true }); +require("dotenv").config({ + path: process.env.NODE_ENV === "test" ? ".env.test" : ".env", +}); var path = require('path'); @@ -6,5 +8,4 @@ module.exports = { 'config': path.resolve('server/config', 'database.json'), 'migrations-path': path.resolve('server', 'migrations'), 'models-path': path.resolve('server', 'models'), - 'seeders-path': path.resolve('server/models', 'fixtures'), } diff --git a/Makefile b/Makefile index 1b9fc94e2..8d773aaa0 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,28 @@ up: - docker-compose up -d redis postgres + docker compose up -d redis postgres yarn install-local-ssl yarn install --pure-lockfile yarn dev:watch build: - docker-compose build --pull outline + docker compose build --pull outline test: - docker-compose up -d redis postgres - yarn sequelize db:drop --env=test - yarn sequelize db:create --env=test - NODE_ENV=test yarn sequelize db:migrate --env=test + docker compose up -d redis postgres + NODE_ENV=test yarn sequelize db:drop + NODE_ENV=test yarn sequelize db:create + NODE_ENV=test yarn sequelize db:migrate yarn test watch: - docker-compose up -d redis postgres - yarn sequelize db:drop --env=test - yarn sequelize db:create --env=test - NODE_ENV=test yarn sequelize db:migrate --env=test + docker compose up -d redis postgres + NODE_ENV=test yarn sequelize db:drop + NODE_ENV=test yarn sequelize db:create + NODE_ENV=test yarn sequelize db:migrate yarn test:watch destroy: - docker-compose stop - docker-compose rm -f + docker compose stop + docker compose rm -f .PHONY: up build destroy test watch # let's go to reserve rules names diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx index 844dfd76b..7b2d4ff86 100644 --- a/app/actions/definitions/navigation.tsx +++ b/app/actions/definitions/navigation.tsx @@ -26,7 +26,6 @@ import SearchQuery from "~/models/SearchQuery"; import KeyboardShortcuts from "~/scenes/KeyboardShortcuts"; import { createAction } from "~/actions"; import { NavigationSection, RecentSearchesSection } from "~/actions/sections"; -import env from "~/env"; import Desktop from "~/utils/Desktop"; import history from "~/utils/history"; import isCloudHosted from "~/utils/isCloudHosted"; @@ -212,9 +211,6 @@ export const logout = createAction({ icon: , perform: () => { void stores.auth.logout(); - if (env.OIDC_LOGOUT_URI) { - window.location.replace(env.OIDC_LOGOUT_URI); - } }, }); diff --git a/app/scenes/Logout.tsx b/app/scenes/Logout.tsx index 32f4e27d8..2f21dc099 100644 --- a/app/scenes/Logout.tsx +++ b/app/scenes/Logout.tsx @@ -1,15 +1,10 @@ import * as React from "react"; import { Redirect } from "react-router-dom"; -import env from "~/env"; import useStores from "~/hooks/useStores"; const Logout = () => { const { auth } = useStores(); void auth.logout(); - if (env.OIDC_LOGOUT_URI) { - window.location.replace(env.OIDC_LOGOUT_URI); - return null; - } return ; }; diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index 4eb68efb2..88f0e755b 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -14,6 +14,7 @@ import { PartialWithId } from "~/types"; import { client } from "~/utils/ApiClient"; import Desktop from "~/utils/Desktop"; import Logger from "~/utils/Logger"; +import history from "~/utils/history"; import isCloudHosted from "~/utils/isCloudHosted"; import Store from "./base/Store"; @@ -304,16 +305,15 @@ export default class AuthStore extends Store { } }; + /** + * Logs the user out and optionally revokes the authentication token. + * + * @param savePath Whether the current path should be saved and returned to after login. + * @param tryRevokingToken Whether the auth token should attempt to be revoked, this should be + * disabled with requests from ApiClient to prevent infinite loops. + */ @action - logout = async ( - /** Whether the current path should be saved and returned to after login */ - savePath = false, - /** - * Whether the auth token should attempt to be revoked, this should be disabled - * with requests from ApiClient to prevent infinite loops. - */ - tryRevokingToken = true - ) => { + logout = async (savePath = false, tryRevokingToken = true) => { // if this logout was forced from an authenticated route then // save the current path so we can go back there once signed in if (savePath) { @@ -348,9 +348,16 @@ export default class AuthStore extends Store { this.currentUserId = null; this.currentTeamId = null; this.collaborationToken = null; + this.rootStore.clear(); // Tell the host application we logged out, if any – allows window cleanup. - void Desktop.bridge?.onLogout?.(); - this.rootStore.clear(); + if (Desktop.isElectron()) { + void Desktop.bridge?.onLogout?.(); + } else if (env.OIDC_LOGOUT_URI) { + window.location.replace(env.OIDC_LOGOUT_URI); + return; + } + + history.replace("/"); }; } diff --git a/package.json b/package.json index bf0c2631b..1369f2be6 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "date-fns": "^2.30.0", "dd-trace": "^3.33.0", "diff": "^5.1.0", - "dotenv": "^4.0.0", + "dotenv": "^16.4.5", "email-providers": "^1.14.0", "emoji-mart": "^5.5.2", "emoji-regex": "^10.3.0", @@ -247,6 +247,7 @@ "@types/body-scroll-lock": "^3.1.0", "@types/crypto-js": "^4.2.1", "@types/diff": "^5.0.4", + "@types/dotenv": "^8.2.0", "@types/emoji-regex": "^9.2.0", "@types/express-useragent": "^1.0.2", "@types/formidable": "^2.0.6", diff --git a/plugins/azure/server/auth/azure.ts b/plugins/azure/server/auth/azure.ts index dbcee6727..5dada618c 100644 --- a/plugins/azure/server/auth/azure.ts +++ b/plugins/azure/server/auth/azure.ts @@ -6,7 +6,6 @@ import Router from "koa-router"; import { Profile } from "passport"; import { slugifyDomain } from "@shared/utils/domains"; import accountProvisioner from "@server/commands/accountProvisioner"; -import env from "@server/env"; import { MicrosoftGraphError } from "@server/errors"; import passportMiddleware from "@server/middlewares/passport"; import { User } from "@server/models"; @@ -17,6 +16,7 @@ import { getTeamFromContext, getClientFromContext, } from "@server/utils/passport"; +import env from "../env"; const router = new Router(); const providerName = "azure"; diff --git a/server/utils/azure.ts b/plugins/azure/server/azure.ts similarity index 73% rename from server/utils/azure.ts rename to plugins/azure/server/azure.ts index 07c5ce9e5..e99bfb3eb 100644 --- a/server/utils/azure.ts +++ b/plugins/azure/server/azure.ts @@ -1,6 +1,7 @@ +import invariant from "invariant"; import JWT from "jsonwebtoken"; -import env from "@server/env"; -import OAuthClient from "./oauth"; +import OAuthClient from "@server/utils/oauth"; +import env from "./env"; type AzurePayload = { /** A GUID that represents the Azure AD tenant that the user is from */ @@ -14,6 +15,13 @@ export default class AzureClient extends OAuthClient { userinfo: "https://graph.microsoft.com/v1.0/me", }; + constructor() { + invariant(env.AZURE_CLIENT_ID, "AZURE_CLIENT_ID is required"); + invariant(env.AZURE_CLIENT_SECRET, "AZURE_CLIENT_SECRET is required"); + + super(env.AZURE_CLIENT_ID, env.AZURE_CLIENT_SECRET); + } + async rotateToken( accessToken: string, refreshToken: string diff --git a/plugins/azure/server/env.ts b/plugins/azure/server/env.ts new file mode 100644 index 000000000..03cc192a5 --- /dev/null +++ b/plugins/azure/server/env.ts @@ -0,0 +1,27 @@ +import { IsOptional } from "class-validator"; +import { Environment } from "@server/env"; +import environment from "@server/utils/environment"; +import { CannotUseWithout } from "@server/utils/validators"; + +class AzurePluginEnvironment extends Environment { + /** + * Azure OAuth2 client credentials. To enable authentication with Azure. + */ + @IsOptional() + @CannotUseWithout("AZURE_CLIENT_SECRET") + public AZURE_CLIENT_ID = this.toOptionalString(environment.AZURE_CLIENT_ID); + + @IsOptional() + @CannotUseWithout("AZURE_CLIENT_ID") + public AZURE_CLIENT_SECRET = this.toOptionalString( + environment.AZURE_CLIENT_SECRET + ); + + @IsOptional() + @CannotUseWithout("AZURE_CLIENT_ID") + public AZURE_RESOURCE_APP_ID = this.toOptionalString( + environment.AZURE_RESOURCE_APP_ID + ); +} + +export default new AzurePluginEnvironment(); diff --git a/plugins/google/server/auth/google.ts b/plugins/google/server/auth/google.ts index 7bf8c2edc..0aefa3564 100644 --- a/plugins/google/server/auth/google.ts +++ b/plugins/google/server/auth/google.ts @@ -6,7 +6,6 @@ import { Profile } from "passport"; import { Strategy as GoogleStrategy } from "passport-google-oauth2"; import { slugifyDomain } from "@shared/utils/domains"; import accountProvisioner from "@server/commands/accountProvisioner"; -import env from "@server/env"; import { GmailAccountCreationError, TeamDomainRequiredError, @@ -19,6 +18,7 @@ import { getTeamFromContext, getClientFromContext, } from "@server/utils/passport"; +import env from "../env"; const router = new Router(); const providerName = "google"; diff --git a/plugins/google/server/env.ts b/plugins/google/server/env.ts new file mode 100644 index 000000000..a672a7f9d --- /dev/null +++ b/plugins/google/server/env.ts @@ -0,0 +1,21 @@ +import { IsOptional } from "class-validator"; +import { Environment } from "@server/env"; +import environment from "@server/utils/environment"; +import { CannotUseWithout } from "@server/utils/validators"; + +class GooglePluginEnvironment extends Environment { + /** + * Google OAuth2 client credentials. To enable authentication with Google. + */ + @IsOptional() + @CannotUseWithout("GOOGLE_CLIENT_SECRET") + public GOOGLE_CLIENT_ID = this.toOptionalString(environment.GOOGLE_CLIENT_ID); + + @IsOptional() + @CannotUseWithout("GOOGLE_CLIENT_ID") + public GOOGLE_CLIENT_SECRET = this.toOptionalString( + environment.GOOGLE_CLIENT_SECRET + ); +} + +export default new GooglePluginEnvironment(); diff --git a/plugins/google/server/google.ts b/plugins/google/server/google.ts new file mode 100644 index 000000000..5dec91675 --- /dev/null +++ b/plugins/google/server/google.ts @@ -0,0 +1,18 @@ +import invariant from "invariant"; +import OAuthClient from "@server/utils/oauth"; +import env from "./env"; + +export default class GoogleClient extends OAuthClient { + endpoints = { + authorize: "https://accounts.google.com/o/oauth2/auth", + token: "https://accounts.google.com/o/oauth2/token", + userinfo: "https://www.googleapis.com/oauth2/v3/userinfo", + }; + + constructor() { + invariant(env.GOOGLE_CLIENT_ID, "GOOGLE_CLIENT_ID is required"); + invariant(env.GOOGLE_CLIENT_SECRET, "GOOGLE_CLIENT_SECRET is required"); + + super(env.GOOGLE_CLIENT_ID, env.GOOGLE_CLIENT_SECRET); + } +} diff --git a/plugins/iframely/plugin.json b/plugins/iframely/plugin.json index e4a500e96..8b5767606 100644 --- a/plugins/iframely/plugin.json +++ b/plugins/iframely/plugin.json @@ -1,5 +1,5 @@ { "name": "Iframely", "description": "Integrate Iframely to enable unfurling of arbitrary urls", - "requiredEnvVars": ["IFRAMELY_URL", "IFRAMELY_API_KEY"] + "requiredEnvVars": ["IFRAMELY_API_KEY"] } diff --git a/plugins/iframely/server/env.ts b/plugins/iframely/server/env.ts new file mode 100644 index 000000000..92a623ad1 --- /dev/null +++ b/plugins/iframely/server/env.ts @@ -0,0 +1,27 @@ +import { IsOptional, IsUrl } from "class-validator"; +import { Environment } from "@server/env"; +import environment from "@server/utils/environment"; +import { CannotUseWithout } from "@server/utils/validators"; + +class IframelyPluginEnvironment extends Environment { + /** + * Iframely url + */ + @IsOptional() + @IsUrl({ + require_tld: false, + require_protocol: true, + allow_underscores: true, + protocols: ["http", "https"], + }) + public IFRAMELY_URL = environment.IFRAMELY_URL ?? "https://iframe.ly"; + + /** + * Iframely API key + */ + @IsOptional() + @CannotUseWithout("IFRAMELY_URL") + public IFRAMELY_API_KEY = this.toOptionalString(environment.IFRAMELY_API_KEY); +} + +export default new IframelyPluginEnvironment(); diff --git a/plugins/iframely/server/iframely.ts b/plugins/iframely/server/iframely.ts index f7989d973..14700b616 100644 --- a/plugins/iframely/server/iframely.ts +++ b/plugins/iframely/server/iframely.ts @@ -1,9 +1,9 @@ import { Day } from "@shared/utils/time"; -import env from "@server/env"; import { InternalError } from "@server/errors"; import Logger from "@server/logging/Logger"; import Redis from "@server/storage/redis"; import fetch from "@server/utils/fetch"; +import env from "./env"; class Iframely { private static apiUrl = `${env.IFRAMELY_URL}/api`; diff --git a/plugins/oidc/plugin.json b/plugins/oidc/plugin.json index 7d2365788..fdb7121f0 100644 --- a/plugins/oidc/plugin.json +++ b/plugins/oidc/plugin.json @@ -1,12 +1,5 @@ { "name": "OIDC", "description": "Adds an OpenID compatible authentication provider.", - "requiredEnvVars": [ - "OIDC_CLIENT_ID", - "OIDC_CLIENT_SECRET", - "OIDC_AUTH_URI", - "OIDC_TOKEN_URI", - "OIDC_USERINFO_URI", - "OIDC_DISPLAY_NAME" - ] + "requiredEnvVars": ["OIDC_CLIENT_ID", "OIDC_CLIENT_SECRET", "OIDC_AUTH_URI", "OIDC_TOKEN_URI", "OIDC_USERINFO_URI"] } diff --git a/plugins/oidc/server/auth/oidc.ts b/plugins/oidc/server/auth/oidc.ts index d9167216a..4ca362d7a 100644 --- a/plugins/oidc/server/auth/oidc.ts +++ b/plugins/oidc/server/auth/oidc.ts @@ -5,7 +5,6 @@ import get from "lodash/get"; import { Strategy } from "passport-oauth2"; import { slugifyDomain } from "@shared/utils/domains"; import accountProvisioner from "@server/commands/accountProvisioner"; -import env from "@server/env"; import { OIDCMalformedUserInfoError, AuthenticationError, @@ -19,6 +18,7 @@ import { getTeamFromContext, getClientFromContext, } from "@server/utils/passport"; +import env from "../env"; const router = new Router(); const providerName = "oidc"; diff --git a/plugins/oidc/server/env.ts b/plugins/oidc/server/env.ts new file mode 100644 index 000000000..9d1bb1a73 --- /dev/null +++ b/plugins/oidc/server/env.ts @@ -0,0 +1,79 @@ +import { IsOptional, IsUrl, MaxLength } from "class-validator"; +import { Environment } from "@server/env"; +import environment from "@server/utils/environment"; +import { CannotUseWithout } from "@server/utils/validators"; + +class OIDCPluginEnvironment extends Environment { + /** + * OIDC 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 = this.toOptionalString(environment.OIDC_CLIENT_ID); + + @IsOptional() + @CannotUseWithout("OIDC_CLIENT_ID") + public OIDC_CLIENT_SECRET = this.toOptionalString( + environment.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 = environment.OIDC_DISPLAY_NAME ?? "OpenID Connect"; + + /** + * The OIDC authorization endpoint. + */ + @IsOptional() + @IsUrl({ + require_tld: false, + allow_underscores: true, + }) + public OIDC_AUTH_URI = this.toOptionalString(environment.OIDC_AUTH_URI); + + /** + * The OIDC token endpoint. + */ + @IsOptional() + @IsUrl({ + require_tld: false, + allow_underscores: true, + }) + public OIDC_TOKEN_URI = this.toOptionalString(environment.OIDC_TOKEN_URI); + + /** + * The OIDC userinfo endpoint. + */ + @IsOptional() + @IsUrl({ + require_tld: false, + allow_underscores: true, + }) + public OIDC_USERINFO_URI = this.toOptionalString( + environment.OIDC_USERINFO_URI + ); + + /** + * The OIDC profile field to use as the username. The default value is + * "preferred_username". + */ + public OIDC_USERNAME_CLAIM = + environment.OIDC_USERNAME_CLAIM ?? "preferred_username"; + + /** + * A space separated list of OIDC scopes to request. Defaults to "openid + * profile email". + */ + public OIDC_SCOPES = environment.OIDC_SCOPES ?? "openid profile email"; +} + +export default new OIDCPluginEnvironment(); diff --git a/plugins/oidc/server/oidc.ts b/plugins/oidc/server/oidc.ts new file mode 100644 index 000000000..3eba6ea3f --- /dev/null +++ b/plugins/oidc/server/oidc.ts @@ -0,0 +1,18 @@ +import invariant from "invariant"; +import OAuthClient from "@server/utils/oauth"; +import env from "./env"; + +export default class OIDCClient extends OAuthClient { + endpoints = { + authorize: env.OIDC_AUTH_URI || "", + token: env.OIDC_TOKEN_URI || "", + userinfo: env.OIDC_USERINFO_URI || "", + }; + + constructor() { + invariant(env.OIDC_CLIENT_ID, "OIDC_CLIENT_ID is required"); + invariant(env.OIDC_CLIENT_SECRET, "OIDC_CLIENT_SECRET is required"); + + super(env.OIDC_CLIENT_ID, env.OIDC_CLIENT_SECRET); + } +} diff --git a/plugins/slack/server/api/hooks.test.ts b/plugins/slack/server/api/hooks.test.ts index ff4a4c26d..f7149d836 100644 --- a/plugins/slack/server/api/hooks.test.ts +++ b/plugins/slack/server/api/hooks.test.ts @@ -1,6 +1,5 @@ import randomstring from "randomstring"; import { IntegrationService } from "@shared/types"; -import env from "@server/env"; import { IntegrationAuthentication, SearchQuery } from "@server/models"; import { buildDocument, @@ -9,6 +8,7 @@ import { buildUser, } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; +import env from "../env"; import * as Slack from "../slack"; jest.mock("../slack", () => ({ diff --git a/plugins/slack/server/api/hooks.ts b/plugins/slack/server/api/hooks.ts index 121dee78c..917e51717 100644 --- a/plugins/slack/server/api/hooks.ts +++ b/plugins/slack/server/api/hooks.ts @@ -4,7 +4,6 @@ import escapeRegExp from "lodash/escapeRegExp"; import { Op } from "sequelize"; import { z } from "zod"; import { IntegrationService } from "@shared/types"; -import env from "@server/env"; import { AuthenticationError, InvalidRequestError, @@ -26,6 +25,7 @@ import SearchHelper from "@server/models/helpers/SearchHelper"; import { APIContext } from "@server/types"; import { safeEqual } from "@server/utils/crypto"; import { opts } from "@server/utils/i18n"; +import env from "../env"; import presentMessageAttachment from "../presenters/messageAttachment"; import * as Slack from "../slack"; import * as T from "./schema"; diff --git a/plugins/slack/server/auth/slack.ts b/plugins/slack/server/auth/slack.ts index c80e05207..947e3ec47 100644 --- a/plugins/slack/server/auth/slack.ts +++ b/plugins/slack/server/auth/slack.ts @@ -6,7 +6,6 @@ import { Strategy as SlackStrategy } from "passport-slack-oauth2"; import { IntegrationService, IntegrationType } from "@shared/types"; import { integrationSettingsPath } from "@shared/utils/routeHelpers"; import accountProvisioner from "@server/commands/accountProvisioner"; -import env from "@server/env"; import auth from "@server/middlewares/authentication"; import passportMiddleware from "@server/middlewares/passport"; import validate from "@server/middlewares/validate"; @@ -23,6 +22,7 @@ import { getTeamFromContext, StateStore, } from "@server/utils/passport"; +import env from "../env"; import * as Slack from "../slack"; import * as T from "./schema"; diff --git a/plugins/slack/server/env.ts b/plugins/slack/server/env.ts new file mode 100644 index 000000000..a19195df5 --- /dev/null +++ b/plugins/slack/server/env.ts @@ -0,0 +1,44 @@ +import { IsBoolean, IsOptional } from "class-validator"; +import { Environment } from "@server/env"; +import Deprecated from "@server/models/decorators/Deprecated"; +import environment from "@server/utils/environment"; +import { CannotUseWithout } from "@server/utils/validators"; + +class SlackPluginEnvironment extends Environment { + /** + * Slack OAuth2 client credentials. To enable authentication with Slack. + */ + @IsOptional() + @Deprecated("Use SLACK_CLIENT_SECRET instead") + public SLACK_SECRET = this.toOptionalString(environment.SLACK_SECRET); + + @IsOptional() + @Deprecated("Use SLACK_CLIENT_ID instead") + public SLACK_KEY = this.toOptionalString(environment.SLACK_KEY); + + @IsOptional() + @CannotUseWithout("SLACK_CLIENT_ID") + public SLACK_CLIENT_SECRET = this.toOptionalString( + environment.SLACK_CLIENT_SECRET ?? environment.SLACK_SECRET + ); + + /** + * Secret to verify webhook requests received from Slack. + */ + @IsOptional() + public SLACK_VERIFICATION_TOKEN = this.toOptionalString( + environment.SLACK_VERIFICATION_TOKEN + ); + + /** + * 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 = this.toBoolean( + environment.SLACK_MESSAGE_ACTIONS ?? "false" + ); +} + +export default new SlackPluginEnvironment(); diff --git a/plugins/slack/server/processors/SlackProcessor.ts b/plugins/slack/server/processors/SlackProcessor.ts index e623d329d..9b559f46a 100644 --- a/plugins/slack/server/processors/SlackProcessor.ts +++ b/plugins/slack/server/processors/SlackProcessor.ts @@ -2,7 +2,6 @@ import { differenceInMilliseconds } from "date-fns"; import { Op } from "sequelize"; import { IntegrationService, IntegrationType } from "@shared/types"; import { Minute } from "@shared/utils/time"; -import env from "@server/env"; import { Document, Integration, Collection, Team } from "@server/models"; import BaseProcessor from "@server/queues/processors/BaseProcessor"; import { @@ -12,6 +11,7 @@ import { Event, } from "@server/types"; import fetch from "@server/utils/fetch"; +import env from "../env"; import presentMessageAttachment from "../presenters/messageAttachment"; export default class SlackProcessor extends BaseProcessor { diff --git a/plugins/slack/server/slack.ts b/plugins/slack/server/slack.ts index 01165a1e8..17f70634f 100644 --- a/plugins/slack/server/slack.ts +++ b/plugins/slack/server/slack.ts @@ -1,7 +1,7 @@ import querystring from "querystring"; -import env from "@server/env"; import { InvalidRequestError } from "@server/errors"; import fetch from "@server/utils/fetch"; +import env from "./env"; const SLACK_API_URL = "https://slack.com/api"; diff --git a/plugins/storage/server/api/files.test.ts b/plugins/storage/server/api/files.test.ts index 3964221c5..e3cbbcf06 100644 --- a/plugins/storage/server/api/files.test.ts +++ b/plugins/storage/server/api/files.test.ts @@ -5,7 +5,6 @@ import FormData from "form-data"; import { ensureDirSync } from "fs-extra"; import { v4 as uuidV4 } from "uuid"; import env from "@server/env"; -import "@server/test/env"; import FileStorage from "@server/storage/files"; import { buildAttachment, buildUser } from "@server/test/factories"; import { getTestServer } from "@server/test/support"; diff --git a/server/config/database.json b/server/config/database.json index c9ad6e634..fc70a12f6 100644 --- a/server/config/database.json +++ b/server/config/database.json @@ -4,7 +4,7 @@ "dialect": "postgres" }, "test": { - "use_env_variable": "DATABASE_URL_TEST", + "use_env_variable": "DATABASE_URL", "dialect": "postgres" }, "production": { @@ -20,4 +20,4 @@ "use_env_variable": "DATABASE_URL", "dialect": "postgres" } -} \ No newline at end of file +} diff --git a/server/env.ts b/server/env.ts index a25c492b0..89576c498 100644 --- a/server/env.ts +++ b/server/env.ts @@ -1,10 +1,5 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ - -// Load the process environment variables -require("dotenv").config({ - silent: true, -}); - +// eslint-disable-next-line import/order +import environment from "./utils/environment"; import os from "os"; import { validate, @@ -16,7 +11,6 @@ import { IsIn, IsEmail, IsBoolean, - MaxLength, } from "class-validator"; import uniq from "lodash/uniq"; import { languages } from "@shared/i18n"; @@ -25,7 +19,7 @@ import Deprecated from "./models/decorators/Deprecated"; import { getArg } from "./utils/args"; export class Environment { - private validationPromise; + protected validationPromise; constructor() { this.validationPromise = validate(this); @@ -44,21 +38,23 @@ export class Environment { * The current environment name. */ @IsIn(["development", "production", "staging", "test"]) - public ENVIRONMENT = process.env.NODE_ENV ?? "production"; + public ENVIRONMENT = environment.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 ?? ""; + @IsByteLength(32, 64, { + message: `The SECRET_KEY environment variable is invalid (Use \`openssl rand -hex 32\` to generate a value).`, + }) + public SECRET_KEY = environment.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 ?? ""; + public UTILS_SECRET = environment.UTILS_SECRET ?? ""; /** * The url of the database. @@ -69,7 +65,7 @@ export class Environment { allow_underscores: true, protocols: ["postgres", "postgresql"], }) - public DATABASE_URL = process.env.DATABASE_URL ?? ""; + public DATABASE_URL = environment.DATABASE_URL ?? ""; /** * The url of the database pool. @@ -81,7 +77,7 @@ export class Environment { protocols: ["postgres", "postgresql"], }) public DATABASE_CONNECTION_POOL_URL = this.toOptionalString( - process.env.DATABASE_CONNECTION_POOL_URL + environment.DATABASE_CONNECTION_POOL_URL ); /** @@ -90,7 +86,7 @@ export class Environment { @IsNumber() @IsOptional() public DATABASE_CONNECTION_POOL_MIN = this.toOptionalNumber( - process.env.DATABASE_CONNECTION_POOL_MIN + environment.DATABASE_CONNECTION_POOL_MIN ); /** @@ -99,7 +95,7 @@ export class Environment { @IsNumber() @IsOptional() public DATABASE_CONNECTION_POOL_MAX = this.toOptionalNumber( - process.env.DATABASE_CONNECTION_POOL_MAX + environment.DATABASE_CONNECTION_POOL_MAX ); /** @@ -110,7 +106,7 @@ export class Environment { */ @IsIn(["disable", "allow", "require", "prefer", "verify-ca", "verify-full"]) @IsOptional() - public PGSSLMODE = process.env.PGSSLMODE; + public PGSSLMODE = environment.PGSSLMODE; /** * The url of redis. Note that redis does not have a database after the port. @@ -118,7 +114,7 @@ export class Environment { * base64-encoded configuration. */ @IsNotEmpty() - public REDIS_URL = process.env.REDIS_URL; + public REDIS_URL = environment.REDIS_URL; /** * The fully qualified, external facing domain name of the server. @@ -129,7 +125,7 @@ export class Environment { require_protocol: true, require_tld: false, }) - public URL = process.env.URL || ""; + public URL = environment.URL || ""; /** * If using a Cloudfront/Cloudflare distribution or similar it can be set below. @@ -143,7 +139,7 @@ export class Environment { require_protocol: true, require_tld: false, }) - public CDN_URL = this.toOptionalString(process.env.CDN_URL); + public CDN_URL = this.toOptionalString(environment.CDN_URL); /** * The fully qualified, external facing domain name of the collaboration @@ -156,7 +152,7 @@ export class Environment { }) @IsOptional() public COLLABORATION_URL = this.toOptionalString( - process.env.COLLABORATION_URL + environment.COLLABORATION_URL ); /** @@ -166,7 +162,7 @@ export class Environment { @IsOptional() @IsNumber() public COLLABORATION_MAX_CLIENTS_PER_DOCUMENT = parseInt( - process.env.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT || "100", + environment.COLLABORATION_MAX_CLIENTS_PER_DOCUMENT || "100", 10 ); @@ -175,18 +171,18 @@ export class Environment { */ @IsNumber() @IsOptional() - public PORT = this.toOptionalNumber(process.env.PORT) ?? 3000; + public PORT = this.toOptionalNumber(environment.PORT) ?? 3000; /** * Optional extra debugging. Comma separated */ - public DEBUG = process.env.DEBUG || ""; + public DEBUG = environment.DEBUG || ""; /** * Configure lowest severity level for server logs */ @IsIn(["error", "warn", "info", "http", "verbose", "debug", "silly"]) - public LOG_LEVEL = process.env.LOG_LEVEL || "info"; + public LOG_LEVEL = environment.LOG_LEVEL || "info"; /** * How many processes should be spawned. As a reasonable rule divide your @@ -194,7 +190,7 @@ export class Environment { */ @IsNumber() @IsOptional() - public WEB_CONCURRENCY = this.toOptionalNumber(process.env.WEB_CONCURRENCY); + public WEB_CONCURRENCY = this.toOptionalNumber(environment.WEB_CONCURRENCY); /** * How long a request should be processed before giving up and returning an @@ -203,28 +199,28 @@ export class Environment { @IsNumber() @IsOptional() public REQUEST_TIMEOUT = - this.toOptionalNumber(process.env.REQUEST_TIMEOUT) ?? 10 * 1000; + this.toOptionalNumber(environment.REQUEST_TIMEOUT) ?? 10 * 1000; /** - * Base64 encoded private key if Outline is to perform SSL termination. + * Base64 encoded protected key if Outline is to perform SSL termination. */ @IsOptional() @CannotUseWithout("SSL_CERT") - public SSL_KEY = this.toOptionalString(process.env.SSL_KEY); + public SSL_KEY = this.toOptionalString(environment.SSL_KEY); /** * Base64 encoded public certificate if Outline is to perform SSL termination. */ @IsOptional() @CannotUseWithout("SSL_KEY") - public SSL_CERT = this.toOptionalString(process.env.SSL_CERT); + public SSL_CERT = this.toOptionalString(environment.SSL_CERT); /** * 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"; + public DEFAULT_LANGUAGE = environment.DEFAULT_LANGUAGE ?? "en_US"; /** * A comma list of which services should be enabled on this instance – defaults to all. @@ -235,7 +231,7 @@ export class Environment { public SERVICES = uniq( ( getArg("services") ?? - process.env.SERVICES ?? + environment.SERVICES ?? "collaboration,websockets,worker,web" ) .split(",") @@ -248,7 +244,7 @@ export class Environment { * loadbalancer. */ @IsBoolean() - public FORCE_HTTPS = this.toBoolean(process.env.FORCE_HTTPS ?? "true"); + public FORCE_HTTPS = this.toBoolean(environment.FORCE_HTTPS ?? "true"); /** * Should the installation send anonymized statistics to the maintainers. @@ -256,51 +252,51 @@ export class Environment { */ @IsBoolean() public TELEMETRY = this.toBoolean( - process.env.ENABLE_UPDATES ?? process.env.TELEMETRY ?? "true" + environment.ENABLE_UPDATES ?? environment.TELEMETRY ?? "true" ); /** * An optional comma separated list of allowed domains. */ public ALLOWED_DOMAINS = - process.env.ALLOWED_DOMAINS ?? process.env.GOOGLE_ALLOWED_DOMAINS; + environment.ALLOWED_DOMAINS ?? environment.GOOGLE_ALLOWED_DOMAINS; // Third-party services /** * The host of your SMTP server for enabling emails. */ - public SMTP_HOST = process.env.SMTP_HOST; + public SMTP_HOST = environment.SMTP_HOST; /** * Optional hostname of the client, used for identifying to the server * defaults to hostname of the machine. */ - public SMTP_NAME = process.env.SMTP_NAME; + public SMTP_NAME = environment.SMTP_NAME; /** * The port of your SMTP server. */ @IsNumber() @IsOptional() - public SMTP_PORT = this.toOptionalNumber(process.env.SMTP_PORT); + public SMTP_PORT = this.toOptionalNumber(environment.SMTP_PORT); /** * The username of your SMTP server, if any. */ - public SMTP_USERNAME = process.env.SMTP_USERNAME; + public SMTP_USERNAME = environment.SMTP_USERNAME; /** * The password for the SMTP username, if any. */ - public SMTP_PASSWORD = process.env.SMTP_PASSWORD; + public SMTP_PASSWORD = environment.SMTP_PASSWORD; /** * The email address from which emails are sent. */ @IsEmail({ allow_display_name: true, allow_ip_domain: true }) @IsOptional() - public SMTP_FROM_EMAIL = this.toOptionalString(process.env.SMTP_FROM_EMAIL); + public SMTP_FROM_EMAIL = this.toOptionalString(environment.SMTP_FROM_EMAIL); /** * The reply-to address for emails sent from Outline. If unset the from @@ -308,12 +304,12 @@ export class Environment { */ @IsEmail({ allow_display_name: true, allow_ip_domain: true }) @IsOptional() - public SMTP_REPLY_EMAIL = this.toOptionalString(process.env.SMTP_REPLY_EMAIL); + public SMTP_REPLY_EMAIL = this.toOptionalString(environment.SMTP_REPLY_EMAIL); /** * Override the cipher used for SMTP SSL connections. */ - public SMTP_TLS_CIPHERS = this.toOptionalString(process.env.SMTP_TLS_CIPHERS); + public SMTP_TLS_CIPHERS = this.toOptionalString(environment.SMTP_TLS_CIPHERS); /** * If true (the default) the connection will use TLS when connecting to server. @@ -322,182 +318,56 @@ export class Environment { * Setting secure to false therefore does not mean that you would not use an * encrypted connection. */ - public SMTP_SECURE = this.toBoolean(process.env.SMTP_SECURE ?? "true"); + public SMTP_SECURE = this.toBoolean(environment.SMTP_SECURE ?? "true"); /** * Sentry DSN for capturing errors and frontend performance. */ @IsUrl() @IsOptional() - public SENTRY_DSN = this.toOptionalString(process.env.SENTRY_DSN); + public SENTRY_DSN = this.toOptionalString(environment.SENTRY_DSN); /** * Sentry tunnel URL for bypassing ad blockers */ @IsUrl() @IsOptional() - public SENTRY_TUNNEL = this.toOptionalString(process.env.SENTRY_TUNNEL); + public SENTRY_TUNNEL = this.toOptionalString(environment.SENTRY_TUNNEL); /** * A release SHA or other identifier for Sentry. */ - public RELEASE = this.toOptionalString(process.env.RELEASE); + public RELEASE = this.toOptionalString(environment.RELEASE); /** * A Google Analytics tracking ID, supports v3 or v4 properties. */ @IsOptional() public GOOGLE_ANALYTICS_ID = this.toOptionalString( - process.env.GOOGLE_ANALYTICS_ID + environment.GOOGLE_ANALYTICS_ID ); /** * A DataDog API key for tracking server metrics. */ - public DD_API_KEY = process.env.DD_API_KEY; + public DD_API_KEY = environment.DD_API_KEY; /** * The name of the service to use in DataDog. */ - public DD_SERVICE = process.env.DD_SERVICE ?? "outline"; - - /** - * Google OAuth2 client credentials. To enable authentication with Google. - */ - @IsOptional() - @CannotUseWithout("GOOGLE_CLIENT_SECRET") - public GOOGLE_CLIENT_ID = this.toOptionalString(process.env.GOOGLE_CLIENT_ID); + public DD_SERVICE = environment.DD_SERVICE ?? "outline"; @IsOptional() - @CannotUseWithout("GOOGLE_CLIENT_ID") - public GOOGLE_CLIENT_SECRET = this.toOptionalString( - process.env.GOOGLE_CLIENT_SECRET - ); - - /** - * Slack OAuth2 client credentials. To enable authentication with Slack. - */ - @IsOptional() - @Deprecated("Use SLACK_CLIENT_SECRET instead") - public SLACK_SECRET = this.toOptionalString(process.env.SLACK_SECRET); - - @IsOptional() - @Deprecated("Use SLACK_CLIENT_ID instead") - public SLACK_KEY = this.toOptionalString(process.env.SLACK_KEY); - - @IsOptional() - @CannotUseWithout("SLACK_CLIENT_SECRET") public SLACK_CLIENT_ID = this.toOptionalString( - process.env.SLACK_CLIENT_ID ?? process.env.SLACK_KEY - ); - - @IsOptional() - @CannotUseWithout("SLACK_CLIENT_ID") - public SLACK_CLIENT_SECRET = this.toOptionalString( - process.env.SLACK_CLIENT_SECRET ?? process.env.SLACK_SECRET + environment.SLACK_CLIENT_ID ?? environment.SLACK_KEY ); /** - * This is used to verify webhook requests received from Slack. - */ - @IsOptional() - public SLACK_VERIFICATION_TOKEN = this.toOptionalString( - process.env.SLACK_VERIFICATION_TOKEN - ); - - /** - * This is injected into the slack-app-id header meta tag if provided. + * Injected into the `slack-app-id` header meta tag if provided. */ @IsOptional() @CannotUseWithout("SLACK_CLIENT_ID") - public SLACK_APP_ID = this.toOptionalString(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 = this.toBoolean( - process.env.SLACK_MESSAGE_ACTIONS ?? "false" - ); - - /** - * Azure OAuth2 client credentials. To enable authentication with Azure. - */ - @IsOptional() - @CannotUseWithout("AZURE_CLIENT_SECRET") - public AZURE_CLIENT_ID = this.toOptionalString(process.env.AZURE_CLIENT_ID); - - @IsOptional() - @CannotUseWithout("AZURE_CLIENT_ID") - public AZURE_CLIENT_SECRET = this.toOptionalString( - process.env.AZURE_CLIENT_SECRET - ); - - @IsOptional() - @CannotUseWithout("AZURE_CLIENT_ID") - public AZURE_RESOURCE_APP_ID = this.toOptionalString( - process.env.AZURE_RESOURCE_APP_ID - ); - - /** - * OIDC 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 = this.toOptionalString(process.env.OIDC_CLIENT_ID); - - @IsOptional() - @CannotUseWithout("OIDC_CLIENT_ID") - public OIDC_CLIENT_SECRET = this.toOptionalString( - 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({ - require_tld: false, - allow_underscores: true, - }) - public OIDC_AUTH_URI = this.toOptionalString(process.env.OIDC_AUTH_URI); - - /** - * The OIDC token endpoint. - */ - @IsOptional() - @IsUrl({ - require_tld: false, - allow_underscores: true, - }) - public OIDC_TOKEN_URI = this.toOptionalString(process.env.OIDC_TOKEN_URI); - - /** - * The OIDC userinfo endpoint. - */ - @IsOptional() - @IsUrl({ - require_tld: false, - allow_underscores: true, - }) - public OIDC_USERINFO_URI = this.toOptionalString( - process.env.OIDC_USERINFO_URI - ); + public SLACK_APP_ID = this.toOptionalString(environment.SLACK_APP_ID); /** * Disable autoredirect to the OIDC login page if there is only one @@ -506,7 +376,7 @@ export class Environment { @IsOptional() @IsBoolean() public OIDC_DISABLE_REDIRECT = this.toOptionalBoolean( - process.env.OIDC_DISABLE_REDIRECT + environment.OIDC_DISABLE_REDIRECT ); /** @@ -517,20 +387,7 @@ export class Environment { require_tld: false, allow_underscores: true, }) - public OIDC_LOGOUT_URI = this.toOptionalString(process.env.OIDC_LOGOUT_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"; + public OIDC_LOGOUT_URI = this.toOptionalString(environment.OIDC_LOGOUT_URI); /** * A string representing the version of the software. @@ -539,7 +396,7 @@ export class Environment { * SOURCE_VERSION is used by Heroku */ public VERSION = this.toOptionalString( - process.env.SOURCE_COMMIT || process.env.SOURCE_VERSION + environment.SOURCE_COMMIT || environment.SOURCE_VERSION ); /** @@ -548,7 +405,7 @@ export class Environment { @IsOptional() @IsBoolean() public RATE_LIMITER_ENABLED = this.toBoolean( - process.env.RATE_LIMITER_ENABLED ?? "false" + environment.RATE_LIMITER_ENABLED ?? "false" ); /** @@ -559,7 +416,7 @@ export class Environment { @IsNumber() @CannotUseWithout("RATE_LIMITER_ENABLED") public RATE_LIMITER_REQUESTS = - this.toOptionalNumber(process.env.RATE_LIMITER_REQUESTS) ?? 1000; + this.toOptionalNumber(environment.RATE_LIMITER_REQUESTS) ?? 1000; /** * Set max allowed realtime connections before throttling. Defaults to 50 @@ -568,7 +425,7 @@ export class Environment { @IsOptional() @IsNumber() public RATE_LIMITER_COLLABORATION_REQUESTS = - this.toOptionalNumber(process.env.RATE_LIMITER_COLLABORATION_REQUESTS) ?? + this.toOptionalNumber(environment.RATE_LIMITER_COLLABORATION_REQUESTS) ?? 50; /** @@ -579,7 +436,7 @@ export class Environment { @IsNumber() @CannotUseWithout("RATE_LIMITER_ENABLED") public RATE_LIMITER_DURATION_WINDOW = - this.toOptionalNumber(process.env.RATE_LIMITER_DURATION_WINDOW) ?? 60; + this.toOptionalNumber(environment.RATE_LIMITER_DURATION_WINDOW) ?? 60; /** * Set max allowed upload size for file attachments. @@ -589,7 +446,7 @@ export class Environment { @IsNumber() @Deprecated("Use FILE_STORAGE_UPLOAD_MAX_SIZE instead") public AWS_S3_UPLOAD_MAX_SIZE = this.toOptionalNumber( - process.env.AWS_S3_UPLOAD_MAX_SIZE + environment.AWS_S3_UPLOAD_MAX_SIZE ); /** @@ -597,7 +454,7 @@ export class Environment { */ @IsOptional() public AWS_ACCESS_KEY_ID = this.toOptionalString( - process.env.AWS_ACCESS_KEY_ID + environment.AWS_ACCESS_KEY_ID ); /** @@ -606,35 +463,35 @@ export class Environment { @IsOptional() @CannotUseWithout("AWS_ACCESS_KEY_ID") public AWS_SECRET_ACCESS_KEY = this.toOptionalString( - process.env.AWS_SECRET_ACCESS_KEY + environment.AWS_SECRET_ACCESS_KEY ); /** * The name of the AWS S3 region to use. */ @IsOptional() - public AWS_REGION = this.toOptionalString(process.env.AWS_REGION); + public AWS_REGION = this.toOptionalString(environment.AWS_REGION); /** * Optional AWS S3 endpoint URL for file attachments. */ @IsOptional() public AWS_S3_ACCELERATE_URL = this.toOptionalString( - process.env.AWS_S3_ACCELERATE_URL + environment.AWS_S3_ACCELERATE_URL ); /** * Optional AWS S3 endpoint URL for file attachments. */ @IsOptional() - public AWS_S3_UPLOAD_BUCKET_URL = process.env.AWS_S3_UPLOAD_BUCKET_URL ?? ""; + public AWS_S3_UPLOAD_BUCKET_URL = environment.AWS_S3_UPLOAD_BUCKET_URL ?? ""; /** * The bucket name to store file attachments in. */ @IsOptional() public AWS_S3_UPLOAD_BUCKET_NAME = this.toOptionalString( - process.env.AWS_S3_UPLOAD_BUCKET_NAME + environment.AWS_S3_UPLOAD_BUCKET_NAME ); /** @@ -643,26 +500,26 @@ export class Environment { */ @IsOptional() public AWS_S3_FORCE_PATH_STYLE = this.toBoolean( - process.env.AWS_S3_FORCE_PATH_STYLE ?? "true" + environment.AWS_S3_FORCE_PATH_STYLE ?? "true" ); /** * Set default AWS S3 ACL for file attachments. */ @IsOptional() - public AWS_S3_ACL = process.env.AWS_S3_ACL ?? "private"; + public AWS_S3_ACL = environment.AWS_S3_ACL ?? "private"; /** * Which file storage system to use */ @IsIn(["local", "s3"]) - public FILE_STORAGE = this.toOptionalString(process.env.FILE_STORAGE) ?? "s3"; + public FILE_STORAGE = this.toOptionalString(environment.FILE_STORAGE) ?? "s3"; /** * Set default root dir path for local file storage */ public FILE_STORAGE_LOCAL_ROOT_DIR = - this.toOptionalString(process.env.FILE_STORAGE_LOCAL_ROOT_DIR) ?? + this.toOptionalString(environment.FILE_STORAGE_LOCAL_ROOT_DIR) ?? "/var/lib/outline/data"; /** @@ -670,8 +527,8 @@ export class Environment { */ @IsNumber() public FILE_STORAGE_UPLOAD_MAX_SIZE = - this.toOptionalNumber(process.env.FILE_STORAGE_UPLOAD_MAX_SIZE) ?? - this.toOptionalNumber(process.env.AWS_S3_UPLOAD_MAX_SIZE) ?? + this.toOptionalNumber(environment.FILE_STORAGE_UPLOAD_MAX_SIZE) ?? + this.toOptionalNumber(environment.AWS_S3_UPLOAD_MAX_SIZE) ?? 1000000; /** @@ -679,9 +536,9 @@ export class Environment { */ @IsNumber() public FILE_STORAGE_IMPORT_MAX_SIZE = - this.toOptionalNumber(process.env.FILE_STORAGE_IMPORT_MAX_SIZE) ?? - this.toOptionalNumber(process.env.MAXIMUM_IMPORT_SIZE) ?? - this.toOptionalNumber(process.env.FILE_STORAGE_UPLOAD_MAX_SIZE) ?? + this.toOptionalNumber(environment.FILE_STORAGE_IMPORT_MAX_SIZE) ?? + this.toOptionalNumber(environment.MAXIMUM_IMPORT_SIZE) ?? + this.toOptionalNumber(environment.FILE_STORAGE_UPLOAD_MAX_SIZE) ?? 1000000; /** @@ -689,9 +546,9 @@ export class Environment { */ @IsNumber() public FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE = - this.toOptionalNumber(process.env.FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE) ?? - this.toOptionalNumber(process.env.MAXIMUM_IMPORT_SIZE) ?? - this.toOptionalNumber(process.env.FILE_STORAGE_UPLOAD_MAX_SIZE) ?? + this.toOptionalNumber(environment.FILE_STORAGE_WORKSPACE_IMPORT_MAX_SIZE) ?? + this.toOptionalNumber(environment.MAXIMUM_IMPORT_SIZE) ?? + this.toOptionalNumber(environment.FILE_STORAGE_UPLOAD_MAX_SIZE) ?? 1000000; /** @@ -705,7 +562,7 @@ export class Environment { @IsNumber() @Deprecated("Use FILE_STORAGE_IMPORT_MAX_SIZE instead") public MAXIMUM_IMPORT_SIZE = this.toOptionalNumber( - process.env.MAXIMUM_IMPORT_SIZE + environment.MAXIMUM_IMPORT_SIZE ); /** @@ -714,33 +571,14 @@ export class Environment { */ @IsNumber() public MAXIMUM_EXPORT_SIZE = - this.toOptionalNumber(process.env.MAXIMUM_EXPORT_SIZE) ?? os.totalmem(); - - /** - * Iframely url - */ - @IsOptional() - @IsUrl({ - require_tld: false, - require_protocol: true, - allow_underscores: true, - protocols: ["http", "https"], - }) - public IFRAMELY_URL = process.env.IFRAMELY_URL ?? "https://iframe.ly"; - - /** - * Iframely API key - */ - @IsOptional() - @CannotUseWithout("IFRAMELY_URL") - public IFRAMELY_API_KEY = this.toOptionalString(process.env.IFRAMELY_API_KEY); + this.toOptionalNumber(environment.MAXIMUM_EXPORT_SIZE) ?? os.totalmem(); /** * Enable unsafe-inline in script-src CSP directive */ @IsBoolean() public DEVELOPMENT_UNSAFE_INLINE_CSP = this.toBoolean( - process.env.DEVELOPMENT_UNSAFE_INLINE_CSP ?? "false" + environment.DEVELOPMENT_UNSAFE_INLINE_CSP ?? "false" ); /** @@ -781,11 +619,11 @@ export class Environment { return this.ENVIRONMENT === "test"; } - private toOptionalString(value: string | undefined) { + protected toOptionalString(value: string | undefined) { return value ? value : undefined; } - private toOptionalNumber(value: string | undefined) { + protected toOptionalNumber(value: string | undefined) { return value ? parseInt(value, 10) : undefined; } @@ -801,7 +639,7 @@ export class Environment { * @param value The string to convert * @returns A boolean */ - private toBoolean(value: string) { + protected toBoolean(value: string) { try { return value ? !!JSON.parse(value) : false; } catch (err) { @@ -823,7 +661,7 @@ export class Environment { * @param value The string to convert * @returns A boolean or undefined */ - private toOptionalBoolean(value: string | undefined) { + protected toOptionalBoolean(value: string | undefined) { try { return value ? !!JSON.parse(value) : undefined; } catch (err) { @@ -832,6 +670,4 @@ export class Environment { } } -const env = new Environment(); - -export default env; +export default new Environment(); diff --git a/server/models/AuthenticationProvider.ts b/server/models/AuthenticationProvider.ts index 9e0bfda2e..78d5c0efa 100644 --- a/server/models/AuthenticationProvider.ts +++ b/server/models/AuthenticationProvider.ts @@ -16,17 +16,18 @@ import { IsUUID, PrimaryKey, } from "sequelize-typescript"; -import env from "@server/env"; import Model from "@server/models/base/Model"; -import AzureClient from "@server/utils/azure"; -import GoogleClient from "@server/utils/google"; -import OIDCClient from "@server/utils/oidc"; import { ValidationError } from "../errors"; import Team from "./Team"; import UserAuthentication from "./UserAuthentication"; import Fix from "./decorators/Fix"; import Length from "./validators/Length"; +// TODO: Avoid this hardcoding of plugins +import AzureClient from "plugins/azure/server/azure"; +import GoogleClient from "plugins/google/server/google"; +import OIDCClient from "plugins/oidc/server/oidc"; + @Table({ tableName: "authentication_providers", modelName: "authentication_provider", @@ -86,20 +87,11 @@ class AuthenticationProvider extends Model< get oauthClient() { switch (this.name) { case "google": - return new GoogleClient( - env.GOOGLE_CLIENT_ID || "", - env.GOOGLE_CLIENT_SECRET || "" - ); + return new GoogleClient(); case "azure": - return new AzureClient( - env.AZURE_CLIENT_ID || "", - env.AZURE_CLIENT_SECRET || "" - ); + return new AzureClient(); case "oidc": - return new OIDCClient( - env.OIDC_CLIENT_ID || "", - env.OIDC_CLIENT_SECRET || "" - ); + return new OIDCClient(); default: return undefined; } diff --git a/server/models/helpers/AuthenticationHelper.ts b/server/models/helpers/AuthenticationHelper.ts index 34b831e01..b6f51723e 100644 --- a/server/models/helpers/AuthenticationHelper.ts +++ b/server/models/helpers/AuthenticationHelper.ts @@ -6,6 +6,7 @@ import find from "lodash/find"; import sortBy from "lodash/sortBy"; import env from "@server/env"; import Team from "@server/models/Team"; +import environment from "@server/utils/environment"; export type AuthenticationProviderConfig = { id: string; @@ -49,7 +50,7 @@ export default class AuthenticationHelper { // Test the all required env vars are set for the auth provider const enabled = (config.requiredEnvVars ?? []).every( - (name: string) => !!env[name] + (name: string) => !!environment[name] ); if (enabled) { diff --git a/server/test/env.ts b/server/test/env.ts deleted file mode 100644 index d5fab9ce7..000000000 --- a/server/test/env.ts +++ /dev/null @@ -1,27 +0,0 @@ -import env from "../env"; - -// test environment variables -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.AZURE_CLIENT_ID = undefined; -env.AZURE_CLIENT_SECRET = undefined; -env.OIDC_CLIENT_ID = "client-id"; -env.OIDC_CLIENT_SECRET = "client-secret"; -env.OIDC_AUTH_URI = "http://localhost/authorize"; -env.OIDC_TOKEN_URI = "http://localhost/token"; -env.OIDC_USERINFO_URI = "http://localhost/userinfo"; - -env.RATE_LIMITER_ENABLED = false; - -env.FILE_STORAGE = "local"; -env.FILE_STORAGE_LOCAL_ROOT_DIR = "/tmp"; -env.IFRAMELY_API_KEY = "123"; - -if (process.env.DATABASE_URL_TEST) { - env.DATABASE_URL = process.env.DATABASE_URL_TEST; -} diff --git a/server/test/globalSetup.js b/server/test/globalSetup.js index 1e16b14f3..268e4110b 100644 --- a/server/test/globalSetup.js +++ b/server/test/globalSetup.js @@ -1,4 +1,3 @@ -import "./env"; import { sequelize } from "@server/storage/database"; module.exports = async function () { diff --git a/server/typings/index.d.ts b/server/typings/index.d.ts index b965a663e..8460e3359 100644 --- a/server/typings/index.d.ts +++ b/server/typings/index.d.ts @@ -6,8 +6,6 @@ declare module "formidable/lib/file"; declare module "oy-vey"; -declare module "dotenv"; - declare module "email-providers" { const list: string[]; export default list; diff --git a/server/utils/environment.ts b/server/utils/environment.ts new file mode 100644 index 000000000..bd46be3b6 --- /dev/null +++ b/server/utils/environment.ts @@ -0,0 +1,39 @@ +import fs from "fs"; +import path from "path"; +import dotenv from "dotenv"; + +let environment: Record = {}; + +const envPath = path.resolve(process.cwd(), `.env`); +const envDefault = fs.existsSync(envPath) + ? dotenv.parse(fs.readFileSync(envPath, "utf8")) + : {}; + +// Load environment specific variables, in reverse order of precedence +const environments = ["production", "development", "local", "test"]; + +for (const env of environments) { + const isEnv = process.env.NODE_ENV === env || envDefault.NODE_ENV === env; + const isLocalDevelopment = + env === "local" && + (process.env.NODE_ENV === "development" || + envDefault.NODE_ENV === "development"); + + if (isEnv || isLocalDevelopment) { + const resolvedPath = path.resolve(process.cwd(), `.env.${env}`); + if (fs.existsSync(resolvedPath)) { + environment = { + ...environment, + ...dotenv.parse(fs.readFileSync(resolvedPath, "utf8")), + }; + } + } +} + +process.env = { + ...envDefault, + ...environment, + ...process.env, +}; + +export default process.env; diff --git a/server/utils/google.ts b/server/utils/google.ts deleted file mode 100644 index eaa1d1f37..000000000 --- a/server/utils/google.ts +++ /dev/null @@ -1,9 +0,0 @@ -import OAuthClient from "./oauth"; - -export default class GoogleClient extends OAuthClient { - endpoints = { - authorize: "https://accounts.google.com/o/oauth2/auth", - token: "https://accounts.google.com/o/oauth2/token", - userinfo: "https://www.googleapis.com/oauth2/v3/userinfo", - }; -} diff --git a/server/utils/oidc.ts b/server/utils/oidc.ts deleted file mode 100644 index ca98e33a3..000000000 --- a/server/utils/oidc.ts +++ /dev/null @@ -1,10 +0,0 @@ -import env from "@server/env"; -import OAuthClient from "./oauth"; - -export default class OIDCClient extends OAuthClient { - endpoints = { - authorize: env.OIDC_AUTH_URI || "", - token: env.OIDC_TOKEN_URI || "", - userinfo: env.OIDC_USERINFO_URI || "", - }; -} diff --git a/server/utils/unfurl.ts b/server/utils/unfurl.ts index 706d307e0..fcf5624f2 100644 --- a/server/utils/unfurl.ts +++ b/server/utils/unfurl.ts @@ -4,6 +4,7 @@ import glob from "glob"; import env from "@server/env"; import Logger from "@server/logging/Logger"; import { UnfurlResolver } from "@server/types"; +import environment from "./environment"; const rootDir = env.ENVIRONMENT === "test" ? "" : "build"; @@ -25,7 +26,7 @@ const resolvers: Record = plugins.reduce( // Test the all required env vars are set for the resolver const enabled = (config.requiredEnvVars ?? []).every( - (name: string) => !!env[name] + (name: string) => !!environment[name] ); if (!enabled) { return resolvers; diff --git a/vite.config.ts b/vite.config.ts index 0d528bc13..4add1e8c4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,20 +2,15 @@ import fs from "fs"; import path from "path"; import react from "@vitejs/plugin-react"; import browserslistToEsbuild from "browserslist-to-esbuild"; -import dotenv from "dotenv"; import { webpackStats } from "rollup-plugin-webpack-stats"; import { CommonServerOptions, defineConfig } from "vite"; import { VitePWA } from "vite-plugin-pwa"; import { viteStaticCopy } from "vite-plugin-static-copy"; - -// Load the process environment variables -dotenv.config({ - silent: true, -}); +import environment from "./server/utils/environment"; let httpsConfig: CommonServerOptions["https"] | undefined; -if (process.env.NODE_ENV === "development") { +if (environment.NODE_ENV === "development") { try { httpsConfig = { key: fs.readFileSync("./server/config/certs/private.key"), @@ -31,13 +26,13 @@ export default () => defineConfig({ root: "./", publicDir: "./server/static", - base: (process.env.CDN_URL ?? "") + "/static/", + base: (environment.CDN_URL ?? "") + "/static/", server: { port: 3001, host: true, https: httpsConfig, fs: - process.env.NODE_ENV === "development" + environment.NODE_ENV === "development" ? { // Allow serving files from one level up to the project root allow: [".."], @@ -91,7 +86,7 @@ export default () => globPatterns: ["**/*.{js,css,ico,png,svg}"], navigateFallback: null, modifyURLPrefix: { - "": `${process.env.CDN_URL ?? ""}/static/`, + "": `${environment.CDN_URL ?? ""}/static/`, }, runtimeCaching: [ { diff --git a/yarn.lock b/yarn.lock index a55a586e6..02ce352c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2864,6 +2864,13 @@ resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.4.tgz#ba774c225ee68ce13a090fec16cf34b97a78537b" integrity "sha1-undMIl7mjOE6CQ/sFs80uXp4U3s= sha512-d7489/WO4B65k0SIqxXtviR9+MrPDipWQF6w+5D7YPrqgu6Qb87JsTdWQaNZo7itcdbViQSev3Jaz7dtKO0+Dg==" +"@types/dotenv@^8.2.0": + version "8.2.0" + resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-8.2.0.tgz#5cd64710c3c98e82d9d15844375a33bf1b45d053" + integrity sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw== + dependencies: + dotenv "*" + "@types/emoji-regex@^9.2.0": version "9.2.0" resolved "https://registry.yarnpkg.com/@types/emoji-regex/-/emoji-regex-9.2.0.tgz#2e117de04f5fa561c5dcbe43a860ecd856517525" @@ -5895,16 +5902,16 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +dotenv@*, dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + dotenv@16.3.1: version "16.3.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e" integrity "sha1-NpA03n1+WxIJcmkzUqO/ESFyzD4= sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==" -dotenv@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d" - integrity "sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0= sha512-XcaMACOr3JMVcEv0Y/iUM2XaOsATRZ3U1In41/1jjK6vJZ2PZbQ1bzCG8uvaByfaBpl9gqc9QWJovpUGBXLLYQ==" - dottie@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/dottie/-/dottie-2.0.6.tgz#34564ebfc6ec5e5772272d466424ad5b696484d4"