diff --git a/config/compatibility.yml b/config/compatibility.yml index 92b30e29a..7401c1ced 100644 --- a/config/compatibility.yml +++ b/config/compatibility.yml @@ -22,6 +22,13 @@ store: # secretKey is the same as STORE_SECRET_KEY; must be specified if env doesn't secretKey: YOUR_SECRET_KEY # forcePathStyle is the same as STORE_FORCE_PATH_STYLE; defaults to false + forcePathStyle: true + # proxyAttachments is the same as DIRECT_RESPONSE_ATTACHMENT; defaults to false + proxyAttachments: true -# baseUrl is the same as BASE_URL; not required -baseUrl: https://example.com +# Server configuration +server: + # useSecureConfig is the same as COOKIE_SECURE; if in production (which is for end-users), it will be true, else it'll be false + useSecureConfig: true + # baseUrl is the same as BASE_URL; not required + baseUrl: https://example.com \ No newline at end of file diff --git a/libs/server/config.ts b/libs/server/config.ts index 571d3647a..5d4fcaea5 100644 --- a/libs/server/config.ts +++ b/libs/server/config.ts @@ -1,5 +1,5 @@ import yaml from 'js-yaml'; -import { getEnv } from 'libs/shared/env'; +import * as env from 'libs/shared/env'; import { existsSync, readFileSync } from 'fs'; export type BasicUser = { username: string; password: string }; @@ -26,21 +26,27 @@ export interface S3StoreConfiguration { region: string; forcePathStyle: boolean; prefix: string; + proxyAttachments: boolean; } export type StoreConfiguration = S3StoreConfiguration; +export interface ServerConfiguration { + useSecureCookies: boolean; + baseUrl?: string; +} + export interface Configuration { auth: AuthConfiguration; store: StoreConfiguration; - baseUrl?: string; + server: ServerConfiguration; } // eslint-disable-next-line @typescript-eslint/no-unused-vars let loaded: Configuration | undefined = undefined; export function loadConfig() { - const configFile = String(getEnv('CONFIG_FILE', './notea.yml')); + const configFile = env.getEnvRaw('CONFIG_FILE', false) ?? './notea.yml'; let baseConfig: Configuration = {} as Configuration; if (existsSync(configFile)) { @@ -48,11 +54,11 @@ export function loadConfig() { baseConfig = yaml.load(data) as Configuration; } - const disablePassword = getEnv('DISABLE_PASSWORD', undefined); + const disablePassword = env.parseBool(env.getEnvRaw('DISABLE_PASSWORD', false), false); let auth: AuthConfiguration; - if (disablePassword === undefined || !disablePassword) { - const envPassword = getEnv('PASSWORD', undefined, false); + if (!disablePassword) { + const envPassword = env.getEnvRaw('PASSWORD', false); if (baseConfig.auth === undefined) { if (envPassword === undefined) { throw new Error('Authentication undefined'); @@ -98,47 +104,52 @@ export function loadConfig() { // for now, this works { store.detectCredentials ??= true; - store.accessKey = getEnv( + store.accessKey = env.getEnvRaw( 'STORE_ACCESS_KEY', - store.accessKey, - !store.detectCredentials - )?.toString(); - store.secretKey = getEnv( + !store.detectCredentials || !store.accessKey + ) ?? store.accessKey; + store.secretKey = env.getEnvRaw( 'STORE_SECRET_KEY', - store.secretKey, - !store.detectCredentials - )?.toString(); - store.bucket = getEnv( + !store.detectCredentials || !store.secretKey + ) ?? store.secretKey; + store.bucket = env.getEnvRaw( 'STORE_BUCKET', - store.bucket ?? 'notea', false - ).toString(); - store.forcePathStyle = getEnv( + ) ?? 'notea'; + store.forcePathStyle = env.parseBool(env.getEnvRaw( 'STORE_FORCE_PATH_STYLE', - store.forcePathStyle ?? false, !store.forcePathStyle - ); - store.endpoint = getEnv( + )) ?? store.forcePathStyle; + store.endpoint = env.getEnvRaw( 'STORE_END_POINT', - store.endpoint, - !store.endpoint - ); - store.region = getEnv( + store.endpoint == null + ) ?? store.endpoint; + store.region = env.getEnvRaw( 'STORE_REGION', - store.region ?? 'us-east-1', false - ).toString(); - store.prefix = getEnv( + ) ?? store.region ?? 'us-east-1'; + store.prefix = env.getEnvRaw( 'STORE_PREFIX', - store.prefix ?? '', - false - ); + false, + ) ?? store.prefix ?? ''; + store.proxyAttachments = env.parseBool(env.getEnvRaw('DIRECT_RESPONSE_ATTACHMENT', false), store.proxyAttachments ?? false); + } + + let server: ServerConfiguration; + if (!baseConfig.server) { + server = {} as ServerConfiguration; + } else { + server = baseConfig.server; + } + { + server.useSecureCookies = env.parseBool(env.getEnvRaw('COOKIE_SECURE', false), process.env.NODE_ENV === 'production'); + server.baseUrl = env.getEnvRaw('BASE_URL', false) ?? baseConfig.server.baseUrl; } loaded = { auth, store, - baseUrl: getEnv('BASE_URL')?.toString() ?? baseConfig.baseUrl, + server }; } diff --git a/libs/server/middlewares/csrf.ts b/libs/server/middlewares/csrf.ts index 9ee8f8e3c..99a27e53a 100644 --- a/libs/server/middlewares/csrf.ts +++ b/libs/server/middlewares/csrf.ts @@ -1,13 +1,13 @@ import Tokens from 'csrf'; import { CSRF_HEADER_KEY } from 'libs/shared/const'; -import { getEnv } from 'libs/shared/env'; import md5 from 'md5'; import { ApiNext, ApiRequest, ApiResponse, SSRMiddleware } from '../connect'; +import { BasicAuthConfiguration, config } from 'libs/server/config'; const tokens = new Tokens(); // generate CSRF secret -const csrfSecret = md5('CSRF' + getEnv('PASSWORD')); +const csrfSecret = md5('CSRF' + (config().auth as BasicAuthConfiguration).password); export const getCsrfToken = () => tokens.create(csrfSecret); diff --git a/libs/server/middlewares/note.ts b/libs/server/middlewares/note.ts index db5d77aa5..6e5ea6026 100644 --- a/libs/server/middlewares/note.ts +++ b/libs/server/middlewares/note.ts @@ -32,7 +32,7 @@ export const applyNote: (id: string) => SSRMiddleware = req.props = { ...req.props, ...props, - baseURL: config()?.baseUrl || 'http://' + req.headers.host, + baseURL: config().server.baseUrl ?? 'http://' + req.headers.host, }; next(); diff --git a/libs/server/middlewares/session.ts b/libs/server/middlewares/session.ts index fe406a46f..eabfef4d8 100644 --- a/libs/server/middlewares/session.ts +++ b/libs/server/middlewares/session.ts @@ -1,16 +1,13 @@ import { ironSession } from 'next-iron-session'; import md5 from 'md5'; -import { getEnv } from 'libs/shared/env'; +import { BasicAuthConfiguration, config } from 'libs/server/config'; const sessionOptions = { cookieName: 'notea-auth', - password: md5('notea' + getEnv('PASSWORD')), + password: md5('notea' + (config().auth as BasicAuthConfiguration).password), // NOTE(tecc): in the future, if this field becomes null, it will be an issue // if your localhost is served on http:// then disable the secure flag cookieOptions: { - secure: getEnv( - 'COOKIE_SECURE', - process.env.NODE_ENV === 'production' - ), + secure: config().server.useSecureCookies, }, }; diff --git a/libs/shared/env.ts b/libs/shared/env.ts index 5564321b3..8c42e6c2a 100644 --- a/libs/shared/env.ts +++ b/libs/shared/env.ts @@ -14,6 +14,9 @@ type AllowedEnvs = | 'STORE_PREFIX' | 'CONFIG_FILE'; +/** + * @deprecated This function should not be used. Prefer the `config()` system. + */ export function getEnv( env: AllowedEnvs, defaultValue?: any, @@ -44,3 +47,45 @@ export function getEnv( return result as unknown as T; } + +export function getEnvRaw(env: AllowedEnvs, required: true): string; +export function getEnvRaw(env: AllowedEnvs, required?: false): string | undefined; +export function getEnvRaw(env: AllowedEnvs, required?: boolean): string | undefined; +export function getEnvRaw(env: AllowedEnvs, required: boolean = false): string | undefined { + const value = process.env[env]; + + if (value == null) { + if (required) { + throw new Error(`[Notea] ${env} is undefined`); + } else { + return undefined; + } + } + + return String(value).toLocaleLowerCase(); +} + +export function parseBool(str: string, invalid?: boolean): boolean; +export function parseBool(str: null | undefined): undefined; +export function parseBool(str: string | null | undefined, invalid: boolean): boolean; +export function parseBool(str: string | null | undefined, invalid?: boolean): boolean | undefined; +export function parseBool(str: string | null | undefined, invalid?: boolean): boolean | undefined { + if (str == null) { + return invalid ?? undefined; + } + switch (str.toLowerCase()) { + case "true": + case "1": + case "yes": + case "on": + return true; + case "false": + case "0": + case "no": + case "off": + return false; + default: + if (invalid == null) throw new Error("Invalid boolean: " + str); + else return invalid; + } +} \ No newline at end of file diff --git a/pages/api/file/[...file].ts b/pages/api/file/[...file].ts index a5c892fd1..bba52883c 100644 --- a/pages/api/file/[...file].ts +++ b/pages/api/file/[...file].ts @@ -2,7 +2,7 @@ import { api } from 'libs/server/connect'; import { useReferrer } from 'libs/server/middlewares/referrer'; import { useStore } from 'libs/server/middlewares/store'; import { getPathFileByName } from 'libs/server/note-path'; -import { getEnv } from 'libs/shared/env'; +import { config } from 'libs/server/config'; // On aliyun `X-Amz-Expires` must be less than 604800 seconds const expires = 86400; @@ -24,9 +24,7 @@ export default api() `public, max-age=${expires}, s-maxage=${expires}, stale-while-revalidate=${expires}` ); - const directed = getEnv('DIRECT_RESPONSE_ATTACHMENT', false); - - if (directed) { + if (config().store.proxyAttachments) { const { buffer, contentType } = await req.state.store.getObjectAndMeta(objectPath); @@ -35,15 +33,14 @@ export default api() } res.send(buffer); - return; - } + } else { + const signUrl = await req.state.store.getSignUrl(objectPath, expires); - const signUrl = await req.state.store.getSignUrl(objectPath, expires); + if (signUrl) { + res.redirect(signUrl); + return; + } - if (signUrl) { - res.redirect(signUrl); - return; + res.redirect('/404'); } - - res.redirect('/404'); });