diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..4b85e88 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,50 @@ +# every field in here is optional except, CORE_SECRET and CORE_DATABASE_URL. +# if CORE_SECRET is still "changethis" then zipline will exit and tell you to change it. + +# if using s3/swift make sure to comment out the other datasources + +CORE_SECURE=true +CORE_SECRET="changethis" +CORE_HOST=0.0.0.0 +CORE_PORT=3000 +CORE_DATABASE_URL="postgres://postgres:postgres@localhost/zip10" +CORE_LOGGER=false +CORE_STATS_INTERVAL=1800 + +# default +DATASOURCE_TYPE=local +DATASOURCE_LOCAL_DIRECTORY=./uploads + +# or you can choose to use s3 +DATASOURCE_TYPE=s3 +DATASOURCE_S3_ACCESS_KEY_ID=key +DATASOURCE_S3_SECRET_ACCESS_KEY=secret +DATASOURCE_S3_BUCKET=bucket +DATASOURCE_S3_ENDPOINT=s3.amazonaws.com +DATASOURCE_S3_REGION=us-west-2 +DATASOURCE_S3_FORCE_S3_PATH=false + +# or you can use swift +DATASOURCE_TYPE=swift +DATASOURCE_SWIFT_CONTAINER=container +DATASOURCE_SWIFT_AUTH_ENDPOINT="https://something/v3" +DATASOURCE_SWIFT_USERNAME=username +DATASOURCE_SWIFT_PASSWORD=password +DATASOURCE_SWIFT_PROJECT_ID=project_id +DATASOURCE_SWIFT_DOMAIN_ID=domain_id + +UPLOADER_ROUTE=/u +UPLOADER_LENGTH=6 +UPLOADER_ADMIN_LIMIT=104900000 +UPLOADER_USER_LIMIT=104900000 +UPLOADER_DISABLED_EXTENSIONS=someext + +URLS_ROUTE=/go +URLS_LENGTH=6 + +RATELIMIT_USER = 5 +RATELIMIT_ADMIN = 3 + +META_DESCRIPTION="zipline cool" +META_KEYWORDS="zipline,cool,image,uploads,sharing" +META_THEME_COLOR="#000000" \ No newline at end of file diff --git a/config.example.toml b/config.example.toml deleted file mode 100644 index 7136a6d..0000000 --- a/config.example.toml +++ /dev/null @@ -1,44 +0,0 @@ -[core] -secure = true # whether to return https or http in links -secret = 'changethis' # change this or zipline will not work -host = '0.0.0.0' -port = 3000 -database_url = 'postgres://postgres:postgres@postgres/postgres' - -[datasource] -type = 'local' # s3, local, or swift - -[datasource.local] -directory = './uploads' # directory to store uploads in - -[datasource.s3] -access_key_id = 'AKIAEXAMPLEKEY' -secret_access_key = 'somethingsomethingsomething' -bucket = 'zipline-storage' -endpoint = 's3.amazonaws.com' -region = 'us-west-2' # not required, defaults to us-east-1 if not specified -force_s3_path = false - -[datasource.swift] -container = 'default' -auth_endpoint = 'http://127.0.0.1:49155/v3' # only supports v3 swift endpoints at the moment. -username = 'swift' -password = 'fingertips' -project_id = 'Default' -domain_id = 'Default' - -[urls] -route = '/go' -length = 6 - -[uploader] -route = '/u' -embed_route = '/a' -length = 6 -user_limit = 104900000 # 100mb -admin_limit = 104900000 # 100mb -disabled_extensions = ['jpg'] - -[ratelimit] -user = 5 # 5 seconds -admin = 0 # 0 seconds, disabled \ No newline at end of file diff --git a/package.json b/package.json index 7e62ed3..88b99b8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ "argon2": "^0.28.5", "colorette": "^2.0.19", "cookie": "^0.5.0", + "dot-prop": "^7.2.0", + "dotenv": "^16.0.1", + "dotenv-expand": "^8.0.3", "fecha": "^4.2.3", "fflate": "^0.7.3", "find-my-way": "^6.3.0", diff --git a/src/lib/config/Config.ts b/src/lib/config/Config.ts index ae0b3ce..5ed4137 100644 --- a/src/lib/config/Config.ts +++ b/src/lib/config/Config.ts @@ -1,6 +1,6 @@ export interface ConfigCore { // Whether to return http or https links - secure: boolean; + https: boolean; // Used for signing of cookies and other stuff secret: string; @@ -105,10 +105,18 @@ export interface ConfigRatelimit { admin: number; } +// Metadata for the site +export interface ConfigMeta { + description: string; + theme_color: string; + keywords: string; +} + export interface Config { core: ConfigCore; uploader: ConfigUploader; urls: ConfigUrls; ratelimit: ConfigRatelimit; datasource: ConfigDatasource; + meta: ConfigMeta; } \ No newline at end of file diff --git a/src/lib/config/readConfig.ts b/src/lib/config/readConfig.ts index 9c03f5b..e6a6919 100644 --- a/src/lib/config/readConfig.ts +++ b/src/lib/config/readConfig.ts @@ -1,168 +1,117 @@ +import { parse } from 'dotenv'; +import { expand } from 'dotenv-expand'; import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import parse from '@iarna/toml/parse-string'; -import Logger from '../logger'; -import { Config } from './Config'; -const e = (val, type, fn: (c: Config, v: any) => void) => ({ val, type, fn }); +function isObject(value: any): value is Record { + return typeof value === 'object' && value !== null; +} -const envValues = [ - e('SECURE', 'boolean', (c, v) => c.core.secure = v), - e('SECRET', 'string', (c, v) => c.core.secret = v), - e('HOST', 'string', (c, v) => c.core.host = v), - e('PORT', 'number', (c, v) => c.core.port = v), - e('DATABASE_URL', 'string', (c, v) => c.core.database_url = v), - e('LOGGER', 'boolean', (c, v) => c.core.logger = v ?? true), - e('STATS_INTERVAL', 'number', (c, v) => c.core.stats_interval = v), +function set(object: Record, property: string, value: any) { + const parts = property.split('.'); - e('DATASOURCE_TYPE', 'string', (c, v) => c.datasource.type = v), - e('DATASOURCE_LOCAL_DIRECTORY', 'string', (c, v) => c.datasource.local.directory = v), - e('DATASOURCE_S3_ACCESS_KEY_ID', 'string', (c, v) => c.datasource.s3.access_key_id = v), - e('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', (c, v) => c.datasource.s3.secret_access_key = v), - e('DATASOURCE_S3_ENDPOINT', 'string', (c, v) => c.datasource.s3.endpoint = v ?? null), - e('DATASOURCE_S3_FORCE_S3_PATH', 'boolean', (c, v) => c.datasource.s3.force_s3_path = v ?? false), - e('DATASOURCE_S3_BUCKET', 'string', (c, v) => c.datasource.s3.bucket = v), - e('DATASOURCE_S3_REGION', 'string', (c, v) => c.datasource.s3.region = v ?? 'us-east-1'), + for (let i = 0; i < parts.length; ++i) { + const key = parts[i]; - e('DATASOURCE_SWIFT_CONTAINER', 'string', (c, v) => c.datasource.swift.container = v), - e('DATASOURCE_SWIFT_USERNAME', 'string', (c, v) => c.datasource.swift.username = v), - e('DATASOURCE_SWIFT_PASSWORD', 'string', (c, v) => c.datasource.swift.password = v), - e('DATASOURCE_SWIFT_AUTH_ENDPOINT', 'string', (c, v) => c.datasource.swift.auth_endpoint = v), - e('DATASOURCE_SWIFT_PROJECT_ID', 'string', (c, v) => c.datasource.swift.project_id = v), - e('DATASOURCE_SWIFT_DOMAIN_ID', 'string', (c, v) => c.datasource.swift.domain_id = v), - e('DATASOURCE_SWIFT_REGION_ID', 'string', (c, v) => c.datasource.swift.region_id = v), + if (i === parts.length - 1) { + object[key] = value; + } else if (!isObject(object[key])) { + object[key] = typeof parts[i + 1] === 'number' ? [] : {}; + } - e('UPLOADER_ROUTE', 'string', (c, v) => c.uploader.route = v), - e('UPLOADER_LENGTH', 'number', (c, v) => c.uploader.length = v), - e('UPLOADER_ADMIN_LIMIT', 'number', (c, v) => c.uploader.admin_limit = v), - e('UPLOADER_USER_LIMIT', 'number', (c, v) => c.uploader.user_limit = v), - e('UPLOADER_DISABLED_EXTS', 'array', (c, v) => v ? c.uploader.disabled_extensions = v : c.uploader.disabled_extensions = []), - - e('URLS_ROUTE', 'string', (c, v) => c.urls.route = v), - e('URLS_LENGTH', 'number', (c, v) => c.urls.length = v), - - e('RATELIMIT_USER', 'number', (c, v) => c.ratelimit.user = v ?? 0), - e('RATELIMIT_ADMIN', 'number', (c, v) => c.ratelimit.user = v ?? 0), -]; - -export default function readConfig(): Config { - if (!existsSync(join(process.cwd(), 'config.toml'))) { - if (!process.env.ZIPLINE_DOCKER_BUILD) Logger.get('config').info('reading environment'); - return tryReadEnv(); - } else { - if (process.env.ZIPLINE_DOCKER_BUILD) return; - - Logger.get('config').info('reading config file'); - const str = readFileSync(join(process.cwd(), 'config.toml'), 'utf8'); - const parsed = parse(str); - - return parsed; + object = object[key]; } -}; -function tryReadEnv(): Config { - const config = { - core: { - secure: undefined, - secret: undefined, - host: undefined, - port: undefined, - database_url: undefined, - logger: undefined, - stats_interval: undefined, - }, - datasource: { - type: undefined, - local: { - directory: undefined, - }, - s3: { - access_key_id: undefined, - secret_access_key: undefined, - endpoint: undefined, - bucket: undefined, - force_s3_path: undefined, - region: undefined, - }, - swift: { - username: undefined, - password: undefined, - auth_endpoint: undefined, - container: undefined, - project_id: undefined, - domain_id: undefined, - region_id: undefined, - }, - }, - uploader: { - route: undefined, - length: undefined, - admin_limit: undefined, - user_limit: undefined, - disabled_extensions: undefined, - }, - urls: { - route: undefined, - length: undefined, - }, - ratelimit: { - user: undefined, - admin: undefined, - }, + return object; +} + +function map(env: string, type: 'string' | 'number' | 'boolean' | 'array', path: string) { + return { + env, + type, + path, }; +} - for (let i = 0, L = envValues.length; i !== L; ++i) { - const envValue = envValues[i]; - let value: any = process.env[envValue.val]; +export default function readConfig() { + if (existsSync('.env.local')) { + const contents = readFileSync('.env.local'); - if (!value) { - envValues[i].fn(config, undefined); - } else { - envValues[i].fn(config, value); - if (envValue.type === 'number') value = parseToNumber(value); - else if (envValue.type === 'boolean') value = parseToBoolean(value); - else if (envValue.type === 'array') value = parseToArray(value); - envValues[i].fn(config, value); + expand({ + parsed: parse(contents), + }); + } + + const maps = [ + map('CORE_HTTPS', 'boolean', 'core.secure'), + map('CORE_SECRET', 'string', 'core.secret'), + map('CORE_HOST', 'string', 'core.host'), + map('CORE_PORT', 'number', 'core.port'), + map('CORE_DATABASE_URL', 'string', 'core.database_url'), + map('CORE_LOGGER', 'boolean', 'core.logger'), + map('CORE_STATS_INTERVAL', 'number', 'core.stats_interval'), + + map('DATASOURCE_TYPE', 'string', 'datasource.type'), + + map('DATASOURCE_LOCAL_DIRECTORY', 'string', 'datasource.local.directory'), + + map('DATASOURCE_S3_ACCESS_KEY_ID', 'string', 'datasource.s3.access_key_id'), + map('DATASOURCE_S3_SECRET_ACCESS_KEY', 'string', 'datasource.s3.secret_access_key'), + map('DATASOURCE_S3_ENDPOINT', 'string', 'datasource.s3.endpoint'), + map('DATASOURCE_S3_BUCKET', 'string', 'datasource.s3.bucket'), + map('DATASOURCE_S3_FORCE_S3_PATH', 'boolean', 'datasource.s3.force_s3_path'), + map('DATASOURCE_S3_REGION', 'string', 'datasource.s3.region'), + + map('DATASOURCE_SWIFT_USERNAME', 'string', 'datasource.swift.username'), + map('DATASOURCE_SWIFT_PASSWORD', 'string', 'datasource.swift.password'), + map('DATASOURCE_SWIFT_AUTH_ENDPOINT', 'string', 'datasource.swift.auth_endpoint'), + map('DATASOURCE_SWIFT_CONTAINER', 'string', 'datasource.swift.container'), + map('DATASOURCE_SWIFT_PROJECT_ID', 'string', 'datasource.swift.project_id'), + map('DATASOURCE_SWIFT_DOMAIN_ID', 'string', 'datasource.swift.domain_id'), + map('DATASOURCE_SWIFT_REGION_ID', 'string', 'datasource.swift.region_id'), + + map('UPLOADER_ROUTE', 'string', 'uploader.route'), + map('UPLOADER_LENGTH', 'number', 'uploader.length'), + map('UPLOADER_ADMIN_LIMIT', 'number', 'uploader.admin_limit'), + map('UPLOADER_USER_LIMIT', 'number', 'uploader.user_limit'), + map('UPLOADER_DISABLED_EXTENSIONS', 'array', 'uploader.disabled_extensions'), + + map('URLS_ROUTE', 'string', 'urls.route'), + map('URLS_LENGTH', 'number', 'urls.length'), + + map('RATELIMIT_USER', 'number', 'ratelimit.user'), + map('RATELIMIT_ADMIN', 'number', 'ratelimit.admin'), + + map('META_DESCRITPION', 'string', 'meta.description'), + map('META_KEYWORDS', 'string', 'meta.keywords'), + map('META_THEME_COLOR', 'string', 'meta.theme_color'), + ]; + + const config = {}; + + for (let i = 0; i !== maps.length; ++i) { + const map = maps[i]; + + const value = process.env[map.env]; + + if (value) { + let parsed: any; + switch (map.type) { + case 'array': + parsed = value.split(','); + break; + case 'number': + parsed = Number(value); + break; + case 'boolean': + parsed = value === 'true'; + break; + default: + parsed = value; + }; + + set(config, map.path, parsed); } } - - switch (config.datasource.type) { - case 's3': - config.datasource.swift = undefined; - break; - case 'swift': - config.datasource.s3 = undefined; - break; - case 'local': - config.datasource.s3 = undefined; - config.datasource.swift = undefined; - break; - default: - config.datasource.local = { - directory: null, - }; - config.datasource.s3 = undefined; - config.datasource.swift = undefined; - break; - } - return config; -} - -function parseToNumber(value) { - // infer that it is a string since env values are only strings - const number = Number(value); - if (isNaN(number)) return undefined; - return number; -} - -function parseToBoolean(value) { - // infer that it is a string since env values are only strings - if (!value || value === 'false') return false; - else return true; -} - -function parseToArray(value) { - return value.split(','); -} +} \ No newline at end of file diff --git a/src/lib/config/validateConfig.ts b/src/lib/config/validateConfig.ts index 61812bb..f34cc7d 100644 --- a/src/lib/config/validateConfig.ts +++ b/src/lib/config/validateConfig.ts @@ -3,7 +3,7 @@ import { object, bool, string, number, boolean, array } from 'yup'; const validator = object({ core: object({ - secure: bool().default(false), + https: bool().default(false), secret: string().min(8).required(), host: string().default('0.0.0.0'), port: number().default(3000), @@ -32,7 +32,7 @@ const validator = object({ project_id: string(), domain_id: string().default('default'), region_id: string().nullable(), - }).notRequired(), + }).nullable().notRequired(), }).required(), uploader: object({ route: string().default('/u'), @@ -50,6 +50,11 @@ const validator = object({ user: number().default(0), admin: number().default(0), }), + meta: object({ + description: string().nullable().notRequired(), + theme_color: string().nullable().notRequired(), + keywords: string().nullable().notRequired(), + }).notRequired(), }); export default function validate(config): Config { diff --git a/src/server/index.ts b/src/server/index.ts index 87f16d3..9e7c71a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -4,7 +4,7 @@ import { NextServer, RequestHandler } from 'next/dist/server/next'; import { Image, PrismaClient } from '@prisma/client'; import { createServer, IncomingMessage, OutgoingMessage, ServerResponse } from 'http'; import { extname } from 'path'; -import { mkdir } from 'fs/promises'; +import { mkdir, readFile } from 'fs/promises'; import { getStats, log, migrations } from './util'; import Logger from '../lib/logger'; import mimes from '../lib/mimes'; @@ -34,7 +34,8 @@ async function start() { logger.error('Running Zipline as is, without a randomized secret is not recommended and leaves your instance at risk!'); logger.error('Please change your secret in the config file or environment variables.'); logger.error('The config file is located at `config.toml`, or if using docker-compose you can change the variables in the `docker-compose.yml` file.'); - logger.error('It is recomended to use a secret that is alphanumeric and randomized. A way you can generate this is through a password manager you may have.'); + logger.error('It is recomended to use a secret that is alphanumeric and randomized.'); + logger.error('A way you can generate this is through a password manager you may have.'); process.exit(1); }; diff --git a/yarn.lock b/yarn.lock index a3d7bf6..c8a7f6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2874,7 +2874,23 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:16.0.1": +"dot-prop@npm:^7.2.0": + version: 7.2.0 + resolution: "dot-prop@npm:7.2.0" + dependencies: + type-fest: ^2.11.2 + checksum: 08e4ff14f7305ffb5fda7e4c88f3cdbeb3cd97bd27efa4f47503869a2ee7f96938b4c21b0a2abf9c37d891a1bdb3994a09d219b0dcfd6130da4eaeb44c6f067e + languageName: node + linkType: hard + +"dotenv-expand@npm:^8.0.3": + version: 8.0.3 + resolution: "dotenv-expand@npm:8.0.3" + checksum: 128ce90ac825b543de3ece0154a51b056ab0dc36bb26d97a68cd0b8707327ecd3c182fb6ac63b26a0fcdfa85064419906a1065cb634f1f9dc08ad311375f1fc0 + languageName: node + linkType: hard + +"dotenv@npm:16.0.1, dotenv@npm:^16.0.1": version: 16.0.1 resolution: "dotenv@npm:16.0.1" checksum: f459ffce07b977b7f15d8cc4ee69cdff77d4dd8c5dc8c85d2d485ee84655352c2415f9dd09d42b5b5985ced3be186130871b34e2f3e2569ebc72fbc2e8096792 @@ -7779,6 +7795,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^2.11.2": + version: 2.16.0 + resolution: "type-fest@npm:2.16.0" + checksum: 897fc5f6833de5ade5c4841d034bdfb6aaa168f24f725354ad13320b2a463b9df03a7a664b836b4c3bc7d9f92b22a25c26fe24668a35caf3b7a9ea5fcb847b8d + languageName: node + linkType: hard + "type-is@npm:^1.6.4": version: 1.6.18 resolution: "type-is@npm:1.6.18" @@ -8236,6 +8259,9 @@ __metadata: babel-plugin-import: ^1.13.5 colorette: ^2.0.19 cookie: ^0.5.0 + dot-prop: ^7.2.0 + dotenv: ^16.0.1 + dotenv-expand: ^8.0.3 esbuild: ^0.14.44 eslint: ^7.32.0 eslint-config-next: 12.1.6