diff --git a/package.json b/package.json index 9bda3ca6e..0fd29feaf 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@nestjs/common": "7.6.5", + "@nestjs/config": "^0.6.1", "@nestjs/core": "7.6.5", "@nestjs/platform-express": "7.6.5", "@nestjs/swagger": "4.7.10", @@ -34,6 +35,7 @@ "cli-color": "2.0.0", "connect-typeorm": "1.1.4", "file-type": "16.2.0", + "joi": "^17.3.0", "raw-body": "2.4.1", "reflect-metadata": "0.1.13", "rimraf": "3.0.2", diff --git a/src/api/public/media/media.controller.spec.ts b/src/api/public/media/media.controller.spec.ts index fb05c0422..5319a3bb9 100644 --- a/src/api/public/media/media.controller.spec.ts +++ b/src/api/public/media/media.controller.spec.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import appConfigMock from '../../../config/app.config.mock'; import { LoggerModule } from '../../../logger/logger.module'; import { MediaUpload } from '../../../media/media-upload.entity'; import { MediaModule } from '../../../media/media.module'; @@ -26,7 +28,15 @@ describe('Media Controller', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [MediaController], - imports: [LoggerModule, MediaModule, NotesModule], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock], + }), + LoggerModule, + MediaModule, + NotesModule, + ], }) .overrideProvider(getRepositoryToken(AuthorColor)) .useValue({}) diff --git a/src/app.module.ts b/src/app.module.ts index 3cef03730..54a22ece5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ */ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PublicApiModule } from './api/public/public-api.module'; import { AuthorsModule } from './authors/authors.module'; @@ -17,6 +18,7 @@ import { NotesModule } from './notes/notes.module'; import { PermissionsModule } from './permissions/permissions.module'; import { RevisionsModule } from './revisions/revisions.module'; import { UsersModule } from './users/users.module'; +import appConfig from './config/app.config'; @Module({ imports: [ @@ -26,6 +28,10 @@ import { UsersModule } from './users/users.module'; autoLoadEntities: true, synchronize: true, }), + ConfigModule.forRoot({ + load: [appConfig], + isGlobal: true, + }), NotesModule, UsersModule, RevisionsModule, diff --git a/src/config/app.config.mock.ts b/src/config/app.config.mock.ts new file mode 100644 index 000000000..825a0b90d --- /dev/null +++ b/src/config/app.config.mock.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { registerAs } from '@nestjs/config'; + +export default registerAs('appConfig', () => ({ + port: 3000, + media: { + backend: { + use: 'filesystem', + filesystem: { + uploadPath: 'uploads', + }, + }, + }, +})); diff --git a/src/config/app.config.ts b/src/config/app.config.ts new file mode 100644 index 000000000..bfd687325 --- /dev/null +++ b/src/config/app.config.ts @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { registerAs } from '@nestjs/config'; +import * as Joi from 'joi'; +import { Loglevel } from './loglevel.enum'; +import { appConfigHsts, HstsConfig, hstsSchema } from './hsts-config'; +import { appConfigCsp, CspConfig, cspSchema } from './csp-config'; +import { appConfigMedia, MediaConfig, mediaSchema } from './media-config'; +import { + appConfigDatabase, + DatabaseConfig, + databaseSchema, +} from './database-config'; +import { appConfigAuth, AuthConfig, authSchema } from './auth-config'; + +// import { LinkifyHeaderStyle } from './linkify-header-style'; + +export interface AppConfig { + domain: string; + port: number; + loglevel: Loglevel; + /*linkifyHeaderStyle: LinkifyHeaderStyle; + sourceURL: string; + urlPath: string; + host: string; + path: string; + urlAddPort: boolean; + cookiePolicy: string; + protocolUseSSL: boolean; + allowOrigin: string[]; + useCDN: boolean; + enableAnonymous: boolean; + enableAnonymousEdits: boolean; + enableFreeURL: boolean; + forbiddenNoteIDs: string[]; + defaultPermission: string; + sessionSecret: string; + sessionLife: number; + tooBusyLag: number; + enableGravatar: boolean;*/ + hsts: HstsConfig; + csp: CspConfig; + media: MediaConfig; + database: DatabaseConfig; + auth: AuthConfig; +} + +const schema = Joi.object({ + domain: Joi.string(), + port: Joi.number().default(3000).optional(), + loglevel: Joi.string() + .valid(...Object.values(Loglevel)) + .default(Loglevel.WARN) + .optional(), + /*linkifyHeaderStyle: Joi.string().valid(...Object.values(LinkifyHeaderStyle)).default(LinkifyHeaderStyle.GFM).optional(), + sourceURL: Joi.string(), + urlPath: Joi.string(), + host: Joi.string().default('::').optional(), + path: Joi.string(), + urlAddPort: Joi.boolean().default(false).optional(), + cookiePolicy: Joi.string(), + protocolUseSSL: Joi.boolean().default(true).optional(), + allowOrigin: Joi.array().items(Joi.string()), + useCDN: Joi.boolean().default(false).optional(), + enableAnonymous: Joi.boolean().default(true).optional(), + enableAnonymousEdits: Joi.boolean().default(false).optional(), + enableFreeURL: Joi.boolean().default(false).optional(), + forbiddenNoteIDs: Joi.array().items(Joi.string()), + defaultPermission: Joi.string(), + sessionSecret: Joi.string(), + sessionLife: Joi.number().default(14 * 24 * 60 * 60 * 1000).optional(), + tooBusyLag: Joi.number().default(70).optional(), + enableGravatar: Joi.boolean().default(true).optional(),*/ + hsts: hstsSchema, + csp: cspSchema, + media: mediaSchema, + database: databaseSchema, + auth: authSchema, +}); + +export default registerAs('appConfig', async () => { + const appConfig = schema.validate( + { + domain: process.env.HD_DOMAIN, + port: parseInt(process.env.PORT) || undefined, + loglevel: process.env.HD_LOGLEVEL, //|| Loglevel.WARN, + /*linkifyHeaderStyle: process.env.HD_LINKIFY_HEADER_STYLE, + sourceURL: process.env.HD_SOURCE_URL, + urlPath: process.env.HD_URL_PATH, + host: process.env.HD_HOST || '::', + path: process.env.HD_PATH, + urlAddPort: process.env.HD_URL_ADDPORT, + cookiePolicy: process.env.HD_COOKIE_POLICY, + protocolUseSSL: process.env.HD_PROTOCOL_USESSL || true, + allowOrigin: toArrayConfig(process.env.HD_ALLOW_ORIGIN), + useCDN: process.env.HD_USECDN, + enableAnonymous: process.env.HD_ENABLE_ANONYMOUS || true, + enableAnonymousEdits: process.env.HD_ENABLE_ANONYMOUS_EDITS, + enableFreeURL: process.env.HD_ENABLE_FREEURL, + forbiddenNoteIDs: toArrayConfig(process.env.HD_FORBIDDEN_NOTE_IDS), + defaultPermission: process.env.HD_DEFAULT_PERMISSION, + sessionSecret: process.env.HD_SESSION_SECRET, + sessionLife: parseInt(process.env.HD_SESSION_LIFE) || 14 * 24 * 60 * 60 * 1000, + tooBusyLag: parseInt(process.env.HD_TOOBUSY_LAG) || 70, + enableGravatar: process.env.HD_ENABLE_GRAVATAR || true,*/ + hsts: appConfigHsts, + csp: appConfigCsp, + media: appConfigMedia, + database: appConfigDatabase, + auth: appConfigAuth, + }, + { + abortEarly: false, + presence: 'required', + }, + ); + if (appConfig.error) { + throw new Error(appConfig.error.toString()); + } + return appConfig.value; +}); diff --git a/src/config/auth-config.ts b/src/config/auth-config.ts new file mode 100644 index 000000000..d8ab34fcd --- /dev/null +++ b/src/config/auth-config.ts @@ -0,0 +1,331 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Joi from 'joi'; +import { GitlabScope, GitlabVersion } from './gitlab.enum'; +import { toArrayConfig } from './utils'; + +export interface AuthConfig { + email: { + enableLogin: boolean; + enableRegister: boolean; + }; + facebook: { + clientID: string; + clientSecret: string; + }; + twitter: { + consumerKey: string; + consumerSecret: string; + }; + github: { + clientID: string; + clientSecret: string; + }; + dropbox: { + clientID: string; + clientSecret: string; + appKey: string; + }; + google: { + clientID: string; + clientSecret: string; + apiKey: string; + }; + gitlab: [ + { + providerName: string; + baseURL: string; + clientID: string; + clientSecret: string; + scope: GitlabScope; + version: GitlabVersion; + }, + ]; + // ToDo: tlsOptions exist in config.json.example. See https://nodejs.org/api/tls.html#tls_tls_connect_options_callback + ldap: [ + { + providerName: string; + url: string; + bindDn: string; + bindCredentials: string; + searchBase: string; + searchFilter: string; + searchAttributes: string[]; + usernameField: string; + useridField: string; + tlsCa: string[]; + }, + ]; + saml: [ + { + providerName: string; + idpSsoUrl: string; + idpCert: string; + clientCert: string; + issuer: string; + identifierFormat: string; + disableRequestedAuthnContext: string; + groupAttribute: string; + requiredGroups: string[]; + externalGroups: string; + attribute: { + id: string; + username: string; + email: string; + }; + }, + ]; + oauth2: [ + { + providerName: string; + baseURL: string; + userProfileURL: string; + userProfileIdAttr: string; + userProfileUsernameAttr: string; + userProfileDisplayNameAttr: string; + userProfileEmailAttr: string; + tokenURL: string; + authorizationURL: string; + clientID: string; + clientSecret: string; + scope: string; + rolesClaim: string; + accessRole: string; + }, + ]; +} + +export const authSchema = Joi.object({ + email: { + enableLogin: Joi.boolean().default(false).optional(), + enableRegister: Joi.boolean().default(false).optional(), + }, + facebook: { + clientID: Joi.string().optional(), + clientSecret: Joi.string().optional(), + }, + twitter: { + consumerKey: Joi.string().optional(), + consumerSecret: Joi.string().optional(), + }, + github: { + clientID: Joi.string().optional(), + clientSecret: Joi.string().optional(), + }, + dropbox: { + clientID: Joi.string().optional(), + clientSecret: Joi.string().optional(), + appKey: Joi.string().optional(), + }, + google: { + clientID: Joi.string().optional(), + clientSecret: Joi.string().optional(), + apiKey: Joi.string().optional(), + }, + gitlab: Joi.array() + .items( + Joi.object({ + providerName: Joi.string().default('Gitlab').optional(), + baseURL: Joi.string().optional(), + clientID: Joi.string().optional(), + clientSecret: Joi.string().optional(), + scope: Joi.string() + .valid(...Object.values(GitlabScope)) + .default(GitlabScope.READ_USER) + .optional(), + version: Joi.string() + .valid(...Object.values(GitlabVersion)) + .default(GitlabVersion.V4) + .optional(), + }), + ) + .optional(), + // ToDo: should searchfilter have a default? + ldap: Joi.array() + .items( + Joi.object({ + providerName: Joi.string().default('LDAP').optional(), + url: Joi.string().optional(), + bindDn: Joi.string().optional(), + bindCredentials: Joi.string().optional(), + searchBase: Joi.string().optional(), + searchFilter: Joi.string().default('(uid={{username}})').optional(), + searchAttributes: Joi.array().items(Joi.string()), + usernameField: Joi.string().default('userid').optional(), + useridField: Joi.string().optional(), + tlsCa: Joi.array().items(Joi.string()), + }), + ) + .optional(), + saml: Joi.array() + .items( + Joi.object({ + providerName: Joi.string().default('SAML').optional(), + idpSsoUrl: Joi.string().optional(), + idpCert: Joi.string().optional(), + clientCert: Joi.string().optional(), + // ToDo: (default: config.serverURL) will be build on-the-fly in the config/index.js from domain, urlAddPort and urlPath. + issuer: Joi.string().optional(), //.default().optional(), + identifierFormat: Joi.string() + .default('urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress') + .optional(), + disableRequestedAuthnContext: Joi.boolean().default(false).optional(), + groupAttribute: Joi.string().optional(), + requiredGroups: Joi.array().items(Joi.string()), + externalGroups: Joi.array().items(Joi.string()), + attribute: { + id: Joi.string().default('NameId').optional(), + username: Joi.string().default('NameId').optional(), + email: Joi.string().default('NameId').optional(), + }, + }), + ) + .optional(), + oauth2: Joi.array() + .items( + Joi.object({ + providerName: Joi.string().default('OAuth2').optional(), + baseURL: Joi.string().optional(), + userProfileURL: Joi.string().optional(), + userProfileIdAttr: Joi.string().optional(), + userProfileUsernameAttr: Joi.string().optional(), + userProfileDisplayNameAttr: Joi.string().optional(), + userProfileEmailAttr: Joi.string().optional(), + tokenURL: Joi.string().optional(), + authorizationURL: Joi.string().optional(), + clientID: Joi.string().optional(), + clientSecret: Joi.string().optional(), + scope: Joi.string().optional(), + rolesClaim: Joi.string().optional(), + accessRole: Joi.string().optional(), + }), + ) + .optional(), +}); + +// ToDo: Validate these with Joi to prevent duplicate entries? + +const gitlabNames = toArrayConfig(process.env.HD_AUTH_GITLABS, ','); +const ldapNames = toArrayConfig(process.env.HD_AUTH_LDAPS, ','); +const samlNames = toArrayConfig(process.env.HD_AUTH_SAMLS, ','); +const oauth2Names = toArrayConfig(process.env.HD_AUTH_OAUTH2S, ','); + +const gitlabs = gitlabNames.map((gitlabName) => { + return { + providerName: process.env[`HD_AUTH_GITLAB_${gitlabName}_PROVIDER_NAME`], + baseURL: process.env[`HD_AUTH_GITLAB_${gitlabName}_BASE_URL`], + clientID: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_ID`], + clientSecret: process.env[`HD_AUTH_GITLAB_${gitlabName}_CLIENT_SECRET`], + scope: process.env[`HD_AUTH_GITLAB_${gitlabName}_GITLAB_SCOPE`], + version: process.env[`HD_AUTH_GITLAB_${gitlabName}_GITLAB_VERSION`], + }; +}); + +const ldaps = ldapNames.map((ldapName) => { + return { + providerName: process.env[`HD_AUTH_LDAP_${ldapName}_PROVIDER_NAME`], + url: process.env[`HD_AUTH_LDAP_${ldapName}_URL`], + bindDn: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_DN`], + bindCredentials: process.env[`HD_AUTH_LDAP_${ldapName}_BIND_CREDENTIALS`], + searchBase: process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_BASE`], + searchFilter: process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_FILTER`], + searchAttributes: toArrayConfig( + process.env[`HD_AUTH_LDAP_${ldapName}_SEARCH_ATTRIBUTES`], + ',', + ), + usernameField: process.env[`HD_AUTH_LDAP_${ldapName}_USERNAME_FIELD`], + useridField: process.env[`HD_AUTH_LDAP_${ldapName}_USERID_FIELD`], + tlsCa: toArrayConfig(process.env[`HD_AUTH_LDAP_${ldapName}_TLS_CA`], ','), + }; +}); + +const samls = samlNames.map((samlName) => { + return { + providerName: process.env[`HD_AUTH_SAML_${samlName}_PROVIDER_NAME`], + idpSsoUrl: process.env[`HD_AUTH_SAML_${samlName}_IDPSSOURL`], + idpCert: process.env[`HD_AUTH_SAML_${samlName}_IDPCERT`], + clientCert: process.env[`HD_AUTH_SAML_${samlName}_CLIENTCERT`], + issuer: process.env[`HD_AUTH_SAML_${samlName}_ISSUER`], + identifierFormat: process.env[`HD_AUTH_SAML_${samlName}_IDENTIFIERFORMAT`], + disableRequestedAuthnContext: + process.env[`HD_AUTH_SAML_${samlName}_DISABLEREQUESTEDAUTHNCONTEXT`], + groupAttribute: process.env[`HD_AUTH_SAML_${samlName}_GROUPATTRIBUTE`], + requiredGroups: toArrayConfig( + process.env[`HD_AUTH_SAML_${samlName}_REQUIREDGROUPS`], + '|', + ), + externalGroups: toArrayConfig( + process.env[`HD_AUTH_SAML_${samlName}_EXTERNALGROUPS`], + '|', + ), + attribute: { + id: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_ID`], + username: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_USERNAME`], + email: process.env[`HD_AUTH_SAML_${samlName}_ATTRIBUTE_USERNAME`], + }, + }; +}); + +const oauth2s = oauth2Names.map((oauth2Name) => { + return { + providerName: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_PROVIDER_NAME`], + baseURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_BASEURL`], + userProfileURL: + process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_URL`], + userProfileIdAttr: + process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_ID_ATTR`], + userProfileUsernameAttr: + process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_USERNAME_ATTR`], + userProfileDisplayNameAttr: + process.env[ + `HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_DISPLAY_NAME_ATTR` + ], + userProfileEmailAttr: + process.env[`HD_AUTH_OAUTH2_${oauth2Name}_USER_PROFILE_EMAIL_ATTR`], + tokenURL: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_TOKEN_URL`], + authorizationURL: + process.env[`HD_AUTH_OAUTH2_${oauth2Name}_AUTHORIZATION_URL`], + clientID: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_CLIENT_ID`], + clientSecret: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_CLIENT_SECRET`], + scope: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_SCOPE`], + rolesClaim: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_ROLES_CLAIM`], + accessRole: process.env[`HD_AUTH_OAUTH2_${oauth2Name}_ACCESS_ROLE`], + }; +}); + +export const appConfigAuth = { + email: { + enableLogin: process.env.HD_AUTH_EMAIL_ENABLE_LOGIN, + enableRegister: process.env.HD_AUTH_EMAIL_ENABLE_REGISTER, + }, + facebook: { + clientID: process.env.HD_AUTH_FACEBOOK_CLIENT_ID, + clientSecret: process.env.HD_AUTH_FACEBOOK_CLIENT_SECRET, + }, + twitter: { + consumerKey: process.env.HD_AUTH_TWITTER_CONSUMER_KEY, + consumerSecret: process.env.HD_AUTH_TWITTER_CONSUMER_SECRET, + }, + github: { + clientID: process.env.HD_AUTH_GITHUB_CLIENT_ID, + clientSecret: process.env.HD_AUTH_GITHUB_CLIENT_SECRET, + }, + dropbox: { + clientID: process.env.HD_AUTH_DROPBOX_CLIENT_ID, + clientSecret: process.env.HD_AUTH_DROPBOX_CLIENT_SECRET, + appKey: process.env.HD_AUTH_DROPBOX_APP_KEY, + }, + google: { + clientID: process.env.HD_AUTH_GOOGLE_CLIENT_ID, + clientSecret: process.env.HD_AUTH_GOOGLE_CLIENT_SECRET, + apiKey: process.env.HD_AUTH_GOOGLE_APP_KEY, + }, + gitlab: gitlabs, + ldap: ldaps, + saml: samls, + oauth2: oauth2s, +}; diff --git a/src/config/csp-config.ts b/src/config/csp-config.ts new file mode 100644 index 000000000..ab699673a --- /dev/null +++ b/src/config/csp-config.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Joi from 'joi'; + +export interface CspConfig { + enable: boolean; + maxAgeSeconds: number; + includeSubdomains: boolean; + preload: boolean; +} + +export const cspSchema = Joi.object({ + enable: Joi.boolean().default(true).optional(), + reportURI: Joi.string().optional(), +}); + +export const appConfigCsp = { + enable: process.env.HD_CSP_ENABLE || true, + reportURI: process.env.HD_CSP_REPORTURI, +}; diff --git a/src/config/database-config.ts b/src/config/database-config.ts new file mode 100644 index 000000000..59397321e --- /dev/null +++ b/src/config/database-config.ts @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Joi from 'joi'; +import { DatabaseDialect } from './database-dialect.enum'; + +export interface DatabaseConfig { + username: string; + password: string; + database: string; + host: string; + port: number; + storage: string; + dialect: DatabaseDialect; +} + +export const databaseSchema = Joi.object({ + username: Joi.string(), + password: Joi.string(), + database: Joi.string(), + host: Joi.string(), + port: Joi.number(), + storage: Joi.when('...dialect', { + is: Joi.valid(DatabaseDialect.SQLITE), + then: Joi.string(), + otherwise: Joi.optional(), + }), + dialect: Joi.string().valid(...Object.values(DatabaseDialect)), +}); + +export const appConfigDatabase = { + username: process.env.HD_DATABASE_USER, + password: process.env.HD_DATABASE_PASS, + database: process.env.HD_DATABASE_NAME, + host: process.env.HD_DATABASE_HOST, + port: parseInt(process.env.HD_DATABASE_PORT) || undefined, + storage: process.env.HD_DATABASE_STORAGE, + dialect: process.env.HD_DATABASE_DIALECT, +}; diff --git a/src/config/database-dialect.enum.ts b/src/config/database-dialect.enum.ts new file mode 100644 index 000000000..a629edad7 --- /dev/null +++ b/src/config/database-dialect.enum.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export enum DatabaseDialect { + POSTGRES = 'postgres', + MYSQL = 'mysql', + MARIADB = 'mariadb', + SQLITE = 'sqlite', +} diff --git a/src/config/gitlab.enum.ts b/src/config/gitlab.enum.ts new file mode 100644 index 000000000..0ddb24130 --- /dev/null +++ b/src/config/gitlab.enum.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export enum GitlabScope { + READ_USER = 'read_user', + API = 'api', +} + +// ToDo: Evaluate if V3 is really necessary anymore (it's deprecated since 2017) +export enum GitlabVersion { + V3 = 'v3', + V4 = 'v4', +} diff --git a/src/config/hsts-config.ts b/src/config/hsts-config.ts new file mode 100644 index 000000000..a15ccd32b --- /dev/null +++ b/src/config/hsts-config.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Joi from 'joi'; + +export interface HstsConfig { + enable: boolean; + maxAgeSeconds: number; + includeSubdomains: boolean; + preload: boolean; +} + +export const hstsSchema = Joi.object({ + enable: Joi.boolean().default(true).optional(), + maxAgeSeconds: Joi.number() + .default(60 * 60 * 24 * 365) + .optional(), + includeSubdomains: Joi.boolean().default(true).optional(), + preload: Joi.boolean().default(true).optional(), +}); + +export const appConfigHsts = { + enable: process.env.HD_HSTS_ENABLE, + maxAgeSeconds: parseInt(process.env.HD_HSTS_MAX_AGE) || undefined, + includeSubdomains: process.env.HD_HSTS_INCLUDE_SUBDOMAINS, + preload: process.env.HD_HSTS_PRELOAD, +}; diff --git a/src/config/linkify-header-style.enum.ts b/src/config/linkify-header-style.enum.ts new file mode 100644 index 000000000..7c7ab088c --- /dev/null +++ b/src/config/linkify-header-style.enum.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export enum LinkifyHeaderStyle { + KEEP_CASE = 'keep-case', + LOWER_CASE = 'lower-case', + GFM = 'gfm', +} diff --git a/src/config/loglevel.enum.ts b/src/config/loglevel.enum.ts new file mode 100644 index 000000000..f150c6fab --- /dev/null +++ b/src/config/loglevel.enum.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export enum Loglevel { + TRACE = 'trace', + DEBUG = 'debug', + INFO = 'info', + WARN = 'warn', + ERROR = 'error', +} diff --git a/src/config/media-config.ts b/src/config/media-config.ts new file mode 100644 index 000000000..d94d86def --- /dev/null +++ b/src/config/media-config.ts @@ -0,0 +1,93 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Joi from 'joi'; +import { BackendType } from '../media/backends/backend-type.enum'; + +export interface MediaConfig { + backend: { + use: BackendType; + filesystem: { + uploadPath: string; + }; + s3: { + accessKeyId: string; + secretAccessKey: string; + region: string; + bucket: string; + endPoint: string; + }; + azure: { + connectionString: string; + container: string; + }; + imgur: { + clientID: string; + }; + }; +} + +export const mediaSchema = Joi.object({ + backend: { + use: Joi.string().valid(...Object.values(BackendType)), + filesystem: { + uploadPath: Joi.when('...use', { + is: Joi.valid(BackendType.FILESYSTEM), + then: Joi.string(), + otherwise: Joi.optional(), + }), + }, + s3: Joi.when('...use', { + is: Joi.valid(BackendType.S3), + then: Joi.object({ + accessKey: Joi.string(), + secretKey: Joi.string(), + endPoint: Joi.string(), + secure: Joi.boolean(), + port: Joi.number(), + }), + otherwise: Joi.optional(), + }), + azure: Joi.when('...use', { + is: Joi.valid(BackendType.AZURE), + then: Joi.object({ + connectionString: Joi.string(), + container: Joi.string(), + }), + otherwise: Joi.optional(), + }), + imgur: Joi.when('...use', { + is: Joi.valid(BackendType.IMGUR), + then: Joi.object({ + clientID: Joi.string(), + }), + otherwise: Joi.optional(), + }), + }, +}); + +export const appConfigMedia = { + backend: { + use: process.env.HD_MEDIA_BACKEND, + filesystem: { + uploadPath: process.env.HD_MEDIA_BACKEND_FILESYSTEM_UPLOAD_PATH, + }, + s3: { + accessKey: process.env.HD_MEDIA_BACKEND_S3_ACCESS_KEY, + secretKey: process.env.HD_MEDIA_BACKEND_S3_ACCESS_KEY, + endPoint: process.env.HD_MEDIA_BACKEND_S3_ENDPOINT, + secure: process.env.HD_MEDIA_BACKEND_S3_SECURE, + port: parseInt(process.env.HD_MEDIA_BACKEND_S3_PORT) || undefined, + }, + azure: { + connectionString: process.env.HD_MEDIA_BACKEND_AZURE_CONNECTION_STRING, + container: process.env.HD_MEDIA_BACKEND_AZURE_CONTAINER, + }, + imgur: { + clientID: process.env.HD_MEDIA_BACKEND_IMGUR_CLIENTID, + }, + }, +}; diff --git a/src/config/utils.ts b/src/config/utils.ts new file mode 100644 index 000000000..168a0e532 --- /dev/null +++ b/src/config/utils.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const toArrayConfig = (configValue: string, separator = ',') => { + if (!configValue) { + return []; + } + + if (!configValue.includes(separator)) { + return [configValue.trim()]; + } + + return configValue.split(separator).map((arrayItem) => arrayItem.trim()); +}; diff --git a/src/main.ts b/src/main.ts index d0b246742..a50657fbc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,10 +5,12 @@ */ import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; +import { AppConfig } from './config/app.config'; import { NestConsoleLoggerService } from './logger/nest-console-logger.service'; async function bootstrap() { @@ -16,6 +18,8 @@ async function bootstrap() { const logger = await app.resolve(NestConsoleLoggerService); logger.log('Switching logger', 'AppBootstrap'); app.useLogger(logger); + const configService = app.get(ConfigService); + const appConfig = configService.get('appConfig'); const swaggerOptions = new DocumentBuilder() .setTitle('HedgeDoc') @@ -31,12 +35,13 @@ async function bootstrap() { transform: true, }), ); - // TODO: Get uploads directory from config - app.useStaticAssets('uploads', { - prefix: '/uploads', - }); - await app.listen(3000); - logger.log('Listening on port 3000', 'AppBootstrap'); + if (appConfig.media.backend.use === 'filesystem') { + app.useStaticAssets('uploads', { + prefix: appConfig.media.backend.filesystem.uploadPath, + }); + } + await app.listen(appConfig.port); + logger.log(`Listening on port ${appConfig.port}`, 'AppBootstrap'); } bootstrap(); diff --git a/src/media/backends/backend-type.enum.ts b/src/media/backends/backend-type.enum.ts index 92808b4cc..90407e2c9 100644 --- a/src/media/backends/backend-type.enum.ts +++ b/src/media/backends/backend-type.enum.ts @@ -5,7 +5,7 @@ */ export enum BackendType { - FILEYSTEM = 'filesystem', + FILESYSTEM = 'filesystem', S3 = 's3', IMGUR = 'imgur', AZURE = 'azure', diff --git a/src/media/backends/filesystem-backend.ts b/src/media/backends/filesystem-backend.ts index efaa29a7a..cbad1109b 100644 --- a/src/media/backends/filesystem-backend.ts +++ b/src/media/backends/filesystem-backend.ts @@ -4,32 +4,25 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { promises as fs } from 'fs'; import { join } from 'path'; +import applicationConfig, { AppConfig } from '../../config/app.config'; import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { MediaBackend } from '../media-backend.interface'; import { BackendData } from '../media-upload.entity'; @Injectable() export class FilesystemBackend implements MediaBackend { - // TODO: Get uploads directory from config uploadDirectory = './uploads'; - constructor(private readonly logger: ConsoleLoggerService) { + constructor( + private readonly logger: ConsoleLoggerService, + @Inject(applicationConfig.KEY) + private appConfig: AppConfig, + ) { this.logger.setContext(FilesystemBackend.name); - } - - private getFilePath(fileName: string): string { - return join(this.uploadDirectory, fileName); - } - - private async ensureDirectory() { - try { - await fs.access(this.uploadDirectory); - } catch (e) { - await fs.mkdir(this.uploadDirectory); - } + this.uploadDirectory = appConfig.media.backend.filesystem.uploadPath; } async saveFile( @@ -52,4 +45,16 @@ export class FilesystemBackend implements MediaBackend { // TODO: Add server address to url return Promise.resolve('/' + filePath); } + + private getFilePath(fileName: string): string { + return join(this.uploadDirectory, fileName); + } + + private async ensureDirectory() { + try { + await fs.access(this.uploadDirectory); + } catch (e) { + await fs.mkdir(this.uploadDirectory); + } + } } diff --git a/src/media/media.module.ts b/src/media/media.module.ts index 00b70209a..d55be0843 100644 --- a/src/media/media.module.ts +++ b/src/media/media.module.ts @@ -5,6 +5,7 @@ */ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { LoggerModule } from '../logger/logger.module'; import { NotesModule } from '../notes/notes.module'; @@ -19,6 +20,7 @@ import { MediaService } from './media.service'; NotesModule, UsersModule, LoggerModule, + ConfigModule, ], providers: [MediaService, FilesystemBackend], exports: [MediaService], diff --git a/src/media/media.service.spec.ts b/src/media/media.service.spec.ts index 51522de5b..d6c804f87 100644 --- a/src/media/media.service.spec.ts +++ b/src/media/media.service.spec.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { ConfigModule } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import appConfigMock from '../config/app.config.mock'; import { LoggerModule } from '../logger/logger.module'; import { AuthorColor } from '../notes/author-color.entity'; import { Note } from '../notes/note.entity'; @@ -17,6 +19,7 @@ import { AuthToken } from '../users/auth-token.entity'; import { Identity } from '../users/identity.entity'; import { User } from '../users/user.entity'; import { UsersModule } from '../users/users.module'; +import { FilesystemBackend } from './backends/filesystem-backend'; import { MediaUpload } from './media-upload.entity'; import { MediaService } from './media.service'; @@ -31,8 +34,17 @@ describe('MediaService', () => { provide: getRepositoryToken(MediaUpload), useValue: {}, }, + FilesystemBackend, + ], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock], + }), + LoggerModule, + NotesModule, + UsersModule, ], - imports: [LoggerModule, NotesModule, UsersModule], }) .overrideProvider(getRepositoryToken(AuthorColor)) .useValue({}) diff --git a/src/media/media.service.ts b/src/media/media.service.ts index 75b345424..e3c449095 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -1,24 +1,29 @@ /* - * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * SPDX-FileCopyrightText: 2020 The HedgeDoc developers (see AUTHORS file) * * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { InjectRepository } from '@nestjs/typeorm'; import * as FileType from 'file-type'; import { Repository } from 'typeorm'; +import applicationConfig, { AppConfig } from '../config/app.config'; import { ClientError, NotInDBError, PermissionError } from '../errors/errors'; import { ConsoleLoggerService } from '../logger/console-logger.service'; import { NotesService } from '../notes/notes.service'; import { UsersService } from '../users/users.service'; import { BackendType } from './backends/backend-type.enum'; import { FilesystemBackend } from './backends/filesystem-backend'; +import { MediaBackend } from './media-backend.interface'; import { MediaUpload } from './media-upload.entity'; @Injectable() export class MediaService { + mediaBackend: MediaBackend; + mediaBackendType: BackendType; + constructor( private readonly logger: ConsoleLoggerService, @InjectRepository(MediaUpload) @@ -26,8 +31,12 @@ export class MediaService { private notesService: NotesService, private usersService: UsersService, private moduleRef: ModuleRef, + @Inject(applicationConfig.KEY) + private appConfig: AppConfig, ) { this.logger.setContext(MediaService.name); + this.mediaBackendType = this.chooseBackendType(); + this.mediaBackend = this.getBackendFromType(this.mediaBackendType); } private static isAllowedMimeType(mimeType: string): boolean { @@ -63,16 +72,14 @@ export class MediaService { if (!MediaService.isAllowedMimeType(fileTypeResult.mime)) { throw new ClientError('MIME type not allowed.'); } - //TODO: Choose backend according to config const mediaUpload = MediaUpload.create( note, user, fileTypeResult.ext, - BackendType.FILEYSTEM, + this.mediaBackendType, ); this.logger.debug(`Generated filename: '${mediaUpload.id}'`, 'saveFile'); - const backend = this.moduleRef.get(FilesystemBackend); - const [url, backendData] = await backend.saveFile( + const [url, backendData] = await this.mediaBackend.saveFile( fileBuffer, mediaUpload.id, ); @@ -96,8 +103,7 @@ export class MediaService { `File '${filename}' is not owned by '${username}'`, ); } - const backend = this.moduleRef.get(FilesystemBackend); - await backend.deleteFile(filename, mediaUpload.backendData); + await this.mediaBackend.deleteFile(filename, mediaUpload.backendData); await this.mediaUploadRepository.remove(mediaUpload); } @@ -112,4 +118,18 @@ export class MediaService { } return mediaUpload; } + + private chooseBackendType(): BackendType { + switch (this.appConfig.media.backend.use) { + case 'filesystem': + return BackendType.FILESYSTEM; + } + } + + private getBackendFromType(type: BackendType): MediaBackend { + switch (type) { + case BackendType.FILESYSTEM: + return this.moduleRef.get(FilesystemBackend); + } + } } diff --git a/test/public-api/media.e2e-spec.ts b/test/public-api/media.e2e-spec.ts index 5fe9f10b8..daa6e60de 100644 --- a/test/public-api/media.e2e-spec.ts +++ b/test/public-api/media.e2e-spec.ts @@ -4,12 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { ConfigModule, registerAs } from '@nestjs/config'; import { NestExpressApplication } from '@nestjs/platform-express'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import { promises as fs } from 'fs'; import * as request from 'supertest'; import { PublicApiModule } from '../../src/api/public/public-api.module'; +import appConfigMock from '../../src/config/app.config.mock'; import { GroupsModule } from '../../src/groups/groups.module'; import { LoggerModule } from '../../src/logger/logger.module'; import { NestConsoleLoggerService } from '../../src/logger/nest-console-logger.service'; @@ -27,6 +29,10 @@ describe('Notes', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock], + }), PublicApiModule, MediaModule, TypeOrmModule.forRoot({ diff --git a/test/public-api/notes.e2e-spec.ts b/test/public-api/notes.e2e-spec.ts index 8385a706e..1846511ee 100644 --- a/test/public-api/notes.e2e-spec.ts +++ b/test/public-api/notes.e2e-spec.ts @@ -5,10 +5,12 @@ */ import { INestApplication } from '@nestjs/common'; +import { ConfigModule, registerAs } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import * as request from 'supertest'; import { PublicApiModule } from '../../src/api/public/public-api.module'; +import appConfigMock from '../../src/config/app.config.mock'; import { NotInDBError } from '../../src/errors/errors'; import { GroupsModule } from '../../src/groups/groups.module'; import { LoggerModule } from '../../src/logger/logger.module'; @@ -23,6 +25,10 @@ describe('Notes', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock], + }), PublicApiModule, NotesModule, PermissionsModule, diff --git a/yarn.lock b/yarn.lock index a2365d24e..a44462f45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -367,6 +367,18 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@hapi/hoek@^9.0.0": + version "9.1.1" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.1.1.tgz#9daf5745156fd84b8e9889a2dc721f0c58e894aa" + integrity sha512-CAEbWH7OIur6jEOzaai83jq3FmKmv4PmX1JYfs9IrYcGEVI/lyL1EXJGCj7eFVJ0bg5QR8LMxBlEtA+xKiLpFw== + +"@hapi/topo@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7" + integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw== + dependencies: + "@hapi/hoek" "^9.0.0" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -592,6 +604,18 @@ tslib "2.0.3" uuid "8.3.2" +"@nestjs/config@^0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@nestjs/config/-/config-0.6.1.tgz#26e4bfd4b2f9d3a6b6bed6445a2a1e766abdf1c6" + integrity sha512-sSIEbHp0xV7bneG2/CePqJh60ELojsBXBPuRM40AcVQwuDRQQ4RAnLT5uaJbWB2xFQjQwik4zejN+27t1cCiBQ== + dependencies: + dotenv "8.2.0" + dotenv-expand "5.1.0" + lodash.get "4.4.2" + lodash.has "4.5.2" + lodash.set "4.3.2" + uuid "8.3.1" + "@nestjs/core@7.6.5": version "7.6.5" resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-7.6.5.tgz#93c642d1abf8d3f09f8f78c262814eaf83bc2ad6" @@ -703,6 +727,23 @@ "@angular-devkit/core" "11.0.3" "@angular-devkit/schematics" "11.0.3" +"@sideway/address@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.0.tgz#0b301ada10ac4e0e3fa525c90615e0b61a72b78d" + integrity sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" + integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@sinonjs/commons@^1.7.0": version "1.7.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2" @@ -2454,7 +2495,12 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" -dotenv@^8.2.0: +dotenv-expand@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" + integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== + +dotenv@8.2.0, dotenv@^8.2.0: version "8.2.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== @@ -4344,6 +4390,17 @@ jest@26.6.3: import-local "^3.0.2" jest-cli "^26.6.3" +joi@^17.3.0: + version "17.3.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.3.0.tgz#f1be4a6ce29bc1716665819ac361dfa139fff5d2" + integrity sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.0" + "@sideway/formula" "^3.0.0" + "@sideway/pinpoint" "^2.0.0" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -4566,11 +4623,26 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash.get@4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= + +lodash.has@4.5.2: + version "4.5.2" + resolved "https://registry.yarnpkg.com/lodash.has/-/lodash.has-4.5.2.tgz#d19f4dc1095058cccbe2b0cdf4ee0fe4aa37c862" + integrity sha1-0Z9NwQlQWMzL4rDN9O4P5Ko3yGI= + lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= +lodash.set@4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" + integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"