diff --git a/.gitignore b/.gitignore index 03a4363..1b4b1c1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ database.sqlite-journal docker/nginx/chibisafe.moe.conf docker-compose.config.yml /coverage +local/ diff --git a/docker/docker-compose.config.example.yml b/docker/docker-compose.config.example.yml index 745f8b1..092993c 100644 --- a/docker/docker-compose.config.example.yml +++ b/docker/docker-compose.config.example.yml @@ -7,18 +7,16 @@ services: SECRET: "wowfcgMHqZHwOIMLadWrKu3liyqPOOILpDLSDvuxq3YGhJmiZXJCVpnF96l11WfR" ADMIN_ACCOUNT: "admin" ADMIN_PASSWORD: "admin" + # OVERWRITE_SETTINGS: 'false' # ROUTE_PREFIX: /api # RATE_LIMIT_WINDOW: 2 # RATE_LIMIT_MAX: 5 # BLOCKED_EXTENSIONS: '.jar,.exe,.msi,.com,.bat,.cmd,.scr,.ps1,.sh' - # UPLOAD_FOLDER: uploads - # MAX_LINKS_PER_ALBUM: 5 # META_THEME_COLOR: '#20222b' # META_DESCRIPTION: 'Blazing fast file uploader and bunker written in node! 🚀' # META_KEYWORDS: 'chibisafe,upload,uploader,file,vue,images,ssr,file uploader,free' # META_TWITTER_HANDLE: '' # SERVER_PORT: 5000 - # WEBSITE_PORT: 5001 # DOMAIN: 'http://chibisafe.moe' # SERVICE_NAME: chibisafe # MAX_SIZE: 5000 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b488670..0c68f54 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -26,12 +26,12 @@ services: - "5001" restart: unless-stopped environment: + OVERWRITE_SETTINGS: "false" CHUNK_SIZE: 90 ROUTE_PREFIX: /api RATE_LIMIT_WINDOW: 2 RATE_LIMIT_MAX: 5 BLOCKED_EXTENSIONS: ".jar,.exe,.msi,.com,.bat,.cmd,.scr,.ps1,.sh" - UPLOAD_FOLDER: uploads SECRET: "" MAX_LINKS_PER_ALBUM: 5 META_THEME_COLOR: "#20222b" @@ -39,8 +39,7 @@ services: META_KEYWORDS: "chibisafe,upload,uploader,file,vue,images,ssr,file uploader,free" META_TWITTER_HANDLE: "" SERVER_PORT: 5000 - WEBSITE_PORT: 5001 - DOMAIN: "http://chibisafe.moe" + DOMAIN: "http://localhost:5000" SERVICE_NAME: chibisafe MAX_SIZE: 5000 GENERATE_THUMBNAILS: "true" diff --git a/docs/migrating.md b/docs/migrating.md index bc656e7..062b027 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -6,7 +6,7 @@ For starters we recommend cloning the new version somewhere else instead of `git - Then copy your `database/db` file from your v3 folder to the root of your v4 folder. - Make sure to install the dependencies by running `npm i` - You then need to run `npm run setup` from the v4 folder and finish the setup process. -- Once that's done you need to manually run `node src/api/databaseMigration.js` from the root folder of v4. +- Once that's done you need to manually run `node src/api/scripts/databaseMigration.js` from the root folder of v4. - This will migrate the v3 database to v4 and regenerate every single thumbnail in webp to save bandwidth. - After the migration finishes, the last step is to update your nginx config with the [newly provided script](./nginx.md). - Restart nginx with `sudo nginx -s reload`. diff --git a/nuxt.config.js b/nuxt.config.js index 278ea1a..3600f08 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -1,61 +1,46 @@ import dotenv from 'dotenv/config'; import autoprefixer from 'autoprefixer'; -import jetpack from 'fs-jetpack'; -const clientConfig = { - development: process.env.NODE_ENV !== 'production', - version: process.env.npm_package_version, - URL: process.env.DOMAIN, - baseURL: `${process.env.DOMAIN}${process.env.ROUTE_PREFIX}`, - serviceName: process.env.SERVICE_NAME, - maxFileSize: parseInt(process.env.MAX_SIZE, 10), - chunkSize: parseInt(process.env.CHUNK_SIZE, 10), - maxLinksPerAlbum: parseInt(process.env.MAX_LINKS_PER_ALBUM, 10), - publicMode: process.env.PUBLIC_MODE === 'true', - userAccounts: process.env.USER_ACCOUNTS === 'true' -}; +const Util = require('./src/api/utils/Util'); export default { ssr: true, - server: { - port: process.env.WEBSITE_PORT - }, srcDir: 'src/site/', head: { - title: process.env.SERVICE_NAME, - titleTemplate: `%s | ${process.env.SERVICE_NAME}`, + title: Util.config.serviceName, + titleTemplate: `%s | ${Util.config.serviceName}`, // TODO: Add the directory with pictures for favicon and stuff meta: [ { charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }, - { hid: 'theme-color', name: 'theme-color', content: `${process.env.META_THEME_COLOR}` }, - { hid: 'description', name: 'description', content: `${process.env.META_DESCRIPTION}` }, - { hid: 'keywords', name: 'keywords', content: `${process.env.META_KEYWORDS}` }, + { hid: 'theme-color', name: 'theme-color', content: `${Util.config.metaThemeColor}` }, + { hid: 'description', name: 'description', content: `${Util.config.metaDescription}` }, + { hid: 'keywords', name: 'keywords', content: `${Util.config.metaKeywords}` }, { hid: 'apple-mobile-web-app-title', name: 'apple-mobile-web-app-title', - content: `${process.env.SERVICE_NAME}` + content: `${Util.config.serviceName}` }, - { hid: 'application-name', name: 'application-name', content: `${process.env.SERVICE_NAME}` }, + { hid: 'application-name', name: 'application-name', content: `${Util.config.serviceName}` }, { hid: 'twitter:card', name: 'twitter:card', content: 'summary' }, - { hid: 'twitter:site', name: 'twitter:site', content: `${process.env.META_TWITTER_HANDLE}` }, - { hid: 'twitter:creator', name: 'twitter:creator', content: `${process.env.META_TWITTER_HANDLE}` }, - { hid: 'twitter:title', name: 'twitter:title', content: `${process.env.SERVICE_NAME}` }, - { hid: 'twitter:description', name: 'twitter:description', content: `${process.env.META_DESCRIPTION}` }, - { hid: 'twitter:image', name: 'twitter:image', content: `${process.env.DOMAIN}/logo.png` }, - { hid: 'og:url', property: 'og:url', content: `${process.env.DOMAIN}` }, + { hid: 'twitter:site', name: 'twitter:site', content: `${Util.config.metaTwitterHandle}` }, + { hid: 'twitter:creator', name: 'twitter:creator', content: `${Util.config.metaTwitterHandle}` }, + { hid: 'twitter:title', name: 'twitter:title', content: `${Util.config.serviceName}` }, + { hid: 'twitter:description', name: 'twitter:description', content: `${Util.config.metaDescription}` }, + { hid: 'twitter:image', name: 'twitter:image', content: `/logo.png` }, + { hid: 'og:url', property: 'og:url', content: `/` }, { hid: 'og:type', property: 'og:type', content: 'website' }, - { hid: 'og:title', property: 'og:title', content: `${process.env.SERVICE_NAME}` }, - { hid: 'og:description', property: 'og:description', content: `${process.env.META_DESCRIPTION}` }, - { hid: 'og:image', property: 'og:image', content: `${process.env.DOMAIN}/logo.png` }, - { hid: 'og:image:secure_url', property: 'og:image:secure_url', content: `${process.env.DOMAIN}/logo.png` }, - { hid: 'og:site_name', property: 'og:site_name', content: `${process.env.SERVICE_NAME}` } + { hid: 'og:title', property: 'og:title', content: `${Util.config.serviceName}` }, + { hid: 'og:description', property: 'og:description', content: `${Util.config.metaDescription}` }, + { hid: 'og:image', property: 'og:image', content: `/logo.png` }, + { hid: 'og:image:secure_url', property: 'og:image:secure_url', content: `/logo.png` }, + { hid: 'og:site_name', property: 'og:site_name', content: `${Util.config.serviceName}` } ], link: [ { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Nunito:300,400,600,700' }, // This one is a pain in the ass to make it customizable, so you should edit it manually - { type: 'application/json+oembed', href: `${process.env.DOMAIN}/oembed.json` } + { type: 'application/json+oembed', href: `/oembed.json` } ] }, plugins: [ @@ -74,8 +59,11 @@ export default { linkActiveClass: 'is-active', linkExactActiveClass: 'is-active' }, + env: { + development: process.env.NODE_ENV !== 'production' + }, axios: { - baseURL: `${process.env.DOMAIN}${process.env.ROUTE_PREFIX}` + baseURL: `${process.env.NODE_ENV === 'production' ? process.env.DOMAIN : 'http://localhost:5000'}/api` }, build: { extractCSS: process.env.NODE_ENV === 'production', @@ -84,13 +72,10 @@ export default { autoprefixer } }, - extend(config, { isClient, isDev }) { + extend(config, { isDev }) { // Extend only webpack config for client-bundle - if (isClient) { - jetpack.write('dist/config.json', clientConfig); - } if (isDev) { - config.devtool = isClient ? 'source-map' : 'inline-source-map'; + config.devtool = 'source-map'; } } } diff --git a/package-lock.json b/package-lock.json index 5ed17ae..628a9ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1230,6 +1230,19 @@ } } }, + "@hapi/hoek": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.1.1.tgz", + "integrity": "sha512-CAEbWH7OIur6jEOzaai83jq3FmKmv4PmX1JYfs9IrYcGEVI/lyL1EXJGCj7eFVJ0bg5QR8LMxBlEtA+xKiLpFw==" + }, + "@hapi/topo": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.0.0.tgz", + "integrity": "sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1893,6 +1906,117 @@ } } }, + "@mapbox/node-pre-gyp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz", + "integrity": "sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==", + "requires": { + "detect-libc": "^1.0.3", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.1", + "nopt": "^5.0.0", + "npmlog": "^4.1.2", + "rimraf": "^3.0.2", + "semver": "^7.3.4", + "tar": "^6.1.0" + }, + "dependencies": { + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + } + }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, + "debug": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", + "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", + "requires": { + "ms": "2.1.2" + } + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "requires": { + "agent-base": "6", + "debug": "4" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==" + } + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "requires": { + "abbrev": "1" + } + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, + "tar": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz", + "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^3.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } + } + }, "@mdi/font": { "version": "5.8.55", "resolved": "https://registry.npmjs.org/@mdi/font/-/font-5.8.55.tgz", @@ -3037,6 +3161,24 @@ } } }, + "@sideway/address": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.0.tgz", + "integrity": "sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA==", + "requires": { + "@hapi/hoek": "^9.0.0" + } + }, + "@sideway/formula": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.0.tgz", + "integrity": "sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg==" + }, + "@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "@sinonjs/commons": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.1.tgz", @@ -4412,18 +4554,18 @@ } }, "bcrypt": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.0.tgz", - "integrity": "sha512-jB0yCBl4W/kVHM2whjfyqnxTmOHkCX4kHEa5nYKSoGeYe8YrjTYTc87/6bwt1g8cmV0QrbhKriETg9jWtcREhg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.0.1.tgz", + "integrity": "sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw==", "requires": { - "node-addon-api": "^3.0.0", - "node-pre-gyp": "0.15.0" + "@mapbox/node-pre-gyp": "^1.0.0", + "node-addon-api": "^3.1.0" }, "dependencies": { "node-addon-api": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.1.0.tgz", - "integrity": "sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw==" + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==" } } }, @@ -11566,6 +11708,18 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-0.1.20.tgz", "integrity": "sha512-nlsuibooCG5yEjmGSVqxhjULy3rO1Gl0LDP+HpUMbzOSLcz5s1Gf5cPnjvHiei0JCG3SXX761HQArDzNIfdz4Q==" }, + "joi": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.3.0.tgz", + "integrity": "sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg==", + "requires": { + "@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-base64": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", @@ -12847,30 +13001,6 @@ "resolved": "https://registry.npmjs.org/node-object-hash/-/node-object-hash-1.4.2.tgz", "integrity": "sha512-UdS4swXs85fCGWWf6t6DMGgpN/vnlKeSGEQ7hJcrs7PBFoxoKLmibc3QRb7fwiYsjdL7PX8iI/TMSlZ90dgHhQ==" }, - "node-pre-gyp": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.15.0.tgz", - "integrity": "sha512-7QcZa8/fpaU/BKenjcaeFF9hLz2+7S9AqyXFhlH/rilsQ/hPZKK32RtR5EQHJElgu+q5RfbJ34KriI79UWaorA==", - "requires": { - "detect-libc": "^1.0.2", - "mkdirp": "^0.5.3", - "needle": "^2.5.0", - "nopt": "^4.0.1", - "npm-packlist": "^1.1.6", - "npmlog": "^4.0.2", - "rc": "^1.2.7", - "rimraf": "^2.6.1", - "semver": "^5.3.0", - "tar": "^4.4.2" - }, - "dependencies": { - "semver": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" - } - } - }, "node-releases": { "version": "1.1.66", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-1.1.66.tgz", diff --git a/package.json b/package.json index 5c9e5d2..350c1db 100644 --- a/package.json +++ b/package.json @@ -9,19 +9,19 @@ "url": "https://github.com/Pitu" }, "scripts": { - "setup": "node src/setup.js && npm run migrate && npm run seed && npm run build", - "build": "nuxt build", - "start": "npm run migrate && cross-env NODE_ENV=production node src/api/structures/Server", - "dev": "nuxt", + "setup": "node src/setup.js && npm run migrate && npm run seed", + "start": "npm run migrate && nuxt build && cross-env NODE_ENV=production node src/api/structures/Server", + "dev": "nodemon src/api/structures/Server", "migrate": "knex migrate:latest", "seed": "knex seed:run", - "api": "node src/api/structures/Server", "update": "git pull && npm install && npm run migrate && npm run build && npm run restart", - "restart": "pm2 restart lolisafe", + "restart": "pm2 restart chibisafe", + "overwrite-config": "cross-env OVERWRITE_SETTINGS=true", "test:vue": "jest --testPathPattern=src/site", "test:api": "jest --testPathPattern=src/tests/api", "test:e2e": "jest --testPathPattern=src/tests/e2e", - "tests": "npm run test:api && npm run test:vue && npm run test:e2e" + "tests": "npm run test:api && npm run test:vue && npm run test:e2e", + "sqlite": "sqlite_web -p 5001 database/database.sqlite" }, "repository": { "type": "git", @@ -37,7 +37,7 @@ "@mdi/font": "^5.8.55", "@nuxtjs/axios": "^5.12.5", "adm-zip": "^0.4.13", - "bcrypt": "^5.0.0", + "bcrypt": "^5.0.1", "blake3": "^2.1.4", "body-parser": "^1.18.3", "buefy": "^0.9.4", @@ -59,6 +59,7 @@ "fs-jetpack": "^2.2.2", "helmet": "^3.15.1", "imagesloaded": "^4.1.4", + "joi": "^17.3.0", "jsonwebtoken": "^8.5.0", "knex": "^0.21.15", "masonry-layout": "^4.2.2", @@ -103,7 +104,7 @@ "jest": "^26.6.3", "jest-serializer-vue": "^2.0.2", "node-sass": "^5.0.0", - "nodemon": "^1.19.3", + "nodemon": "^1.19.4", "postcss-css-variables": "^0.11.0", "postcss-nested": "^3.0.0", "puppeteer": "^5.5.0", @@ -131,6 +132,12 @@ } } }, + "nodemonConfig": { + "watch": [ + "src/api/*" + ], + "delay": 2500 + }, "keywords": [ "chibisafe", "lolisafe", @@ -141,5 +148,8 @@ "ssr", "file uploader", "images" - ] + ], + "volta": { + "node": "14.17.0" + } } diff --git a/src/api/database/migrations/20210112011802_addSettingsTable.js b/src/api/database/migrations/20210112011802_addSettingsTable.js new file mode 100644 index 0000000..54e1cde --- /dev/null +++ b/src/api/database/migrations/20210112011802_addSettingsTable.js @@ -0,0 +1,25 @@ +const Util = require('../../utils/Util'); + +exports.up = async knex => { + await knex.schema.createTable('settings', table => { + table.string('key'); + table.string('value'); + }); + + try { + const defaults = Util.getEnvironmentDefaults(); + const keys = Object.keys(defaults); + for (const item of keys) { + await knex('settings').insert({ + key: item, + value: JSON.stringify(defaults[item]) + }); + } + } catch (error) { + console.error(error); + } +}; + +exports.down = async knex => { + await knex.schema.dropTableIfExists('settings'); +}; diff --git a/src/api/database/seeds/initial.js b/src/api/database/seeds/initial.js index edc1949..7c04dad 100644 --- a/src/api/database/seeds/initial.js +++ b/src/api/database/seeds/initial.js @@ -1,15 +1,41 @@ /* eslint-disable no-console */ const bcrypt = require('bcrypt'); const moment = require('moment'); +const Util = require('../../utils/Util'); exports.seed = async db => { const now = moment.utc().toDate(); - const user = await db.table('users').where({ username: process.env.ADMIN_ACCOUNT }).first(); - if (user) return; + + // Save environment variables to the database try { - const hash = await bcrypt.hash(process.env.ADMIN_PASSWORD, 10); + const defaults = Util.getEnvironmentDefaults(); + const keys = Object.keys(defaults); + for (const item of keys) { + await Util.writeConfigToDb({ + key: item, + value: defaults[item] + }); + } + } catch (error) { + console.error(error); + } + + // Create admin user if it doesnt exist + const user = await db.table('users').where({ username: 'admin' }).first(); + if (user) { + console.log(); + console.log('========================================================='); + console.log('== admin account already exists, skipping. =='); + console.log('========================================================='); + console.log('== Run `pm2 start pm2.json` to start the service =='); + console.log('========================================================='); + console.log(); + return; + } + try { + const hash = await bcrypt.hash('admin', 10); await db.table('users').insert({ - username: process.env.ADMIN_ACCOUNT, + username: 'admin', password: hash, passwordEditedAt: now, createdAt: now, diff --git a/src/api/routes/admin/fileGET.js b/src/api/routes/admin/fileGET.js index 9605da4..72b96f1 100644 --- a/src/api/routes/admin/fileGET.js +++ b/src/api/routes/admin/fileGET.js @@ -15,7 +15,7 @@ class filesGET extends Route { .select('id', 'username', 'enabled', 'createdAt', 'editedAt', 'apiKeyEditedAt', 'isAdmin') .where({ id: file.userId }) .first(); - file = Util.constructFilePublicLink(file); + file = Util.constructFilePublicLink(req, file); // Additional relevant data const filesFromUser = await db.table('files').where({ userId: user.id }).select('id'); diff --git a/src/api/routes/admin/userGET.js b/src/api/routes/admin/userGET.js index 430dfd7..bf4f912 100644 --- a/src/api/routes/admin/userGET.js +++ b/src/api/routes/admin/userGET.js @@ -37,7 +37,7 @@ class usersGET extends Route { } for (let file of files) { - file = Util.constructFilePublicLink(file); + file = Util.constructFilePublicLink(req, file); } return res.json({ diff --git a/src/api/routes/albums/albumFullGET.js b/src/api/routes/albums/albumFullGET.js index d25fe15..32c7326 100644 --- a/src/api/routes/albums/albumFullGET.js +++ b/src/api/routes/albums/albumFullGET.js @@ -43,7 +43,7 @@ class albumGET extends Route { // eslint-disable-next-line no-restricted-syntax for (let file of files) { - file = Util.constructFilePublicLink(file); + file = Util.constructFilePublicLink(req, file); } return res.json({ diff --git a/src/api/routes/albums/albumGET.js b/src/api/routes/albums/albumGET.js index 4ac7089..e121a31 100644 --- a/src/api/routes/albums/albumGET.js +++ b/src/api/routes/albums/albumGET.js @@ -44,7 +44,7 @@ class albumGET extends Route { } for (let file of files) { - file = Util.constructFilePublicLink(file); + file = Util.constructFilePublicLink(req, file); } // Add 1 more view to the link diff --git a/src/api/routes/albums/albumZipGET.js b/src/api/routes/albums/albumZipGET.js index 22b0b6f..8def099 100644 --- a/src/api/routes/albums/albumZipGET.js +++ b/src/api/routes/albums/albumZipGET.js @@ -38,13 +38,13 @@ class albumGET extends Route { If the date when the album was zipped is greater than the album's last edit, we just send the zip to the user */ if (album.zippedAt > album.editedAt) { - const filePath = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`); + const filePath = path.join(__dirname, '../../../../uploads', 'zips', `${album.userId}-${album.id}.zip`); const exists = await jetpack.existsAsync(filePath); /* Make sure the file exists just in case, and if not, continue to it's generation. */ if (exists) { - const fileName = `${process.env.SERVICE_NAME}-${identifier}.zip`; + const fileName = `${Util.config.serviceName}-${identifier}.zip`; return res.download(filePath, fileName); } } @@ -77,8 +77,8 @@ class albumGET extends Route { .update('zippedAt', db.fn.now()) .wasMutated(); - const filePath = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'zips', `${album.userId}-${album.id}.zip`); - const fileName = `${process.env.SERVICE_NAME}-${identifier}.zip`; + const filePath = path.join(__dirname, '../../../../uploads', 'zips', `${album.userId}-${album.id}.zip`); + const fileName = `${Util.config.serviceName}-${identifier}.zip`; return res.download(filePath, fileName); } catch (error) { log.error(error); diff --git a/src/api/routes/albums/albumsGET.js b/src/api/routes/albums/albumsGET.js index 3c18d8f..98cc82e 100644 --- a/src/api/routes/albums/albumsGET.js +++ b/src/api/routes/albums/albumsGET.js @@ -37,7 +37,7 @@ class albumsGET extends Route { // Fetch thumbnails and stuff for (let file of files) { - file = Util.constructFilePublicLink(file); + file = Util.constructFilePublicLink(req, file); } album.fileCount = fileCount[0].count; diff --git a/src/api/routes/albums/link/linkPOST.js b/src/api/routes/albums/link/linkPOST.js index 42eac58..7bc8051 100644 --- a/src/api/routes/albums/link/linkPOST.js +++ b/src/api/routes/albums/link/linkPOST.js @@ -20,16 +20,6 @@ class linkPOST extends Route { .first(); if (!exists) return res.status(400).json({ message: 'Album doesn\t exist' }); - /* - Count the amount of links created for that album already and error out if max was reached - */ - const count = await db - .table('links') - .where('albumId', albumId) - .count({ count: 'id' }) - .first(); - if (count >= parseInt(process.env.MAX_LINKS_PER_ALBUM, 10)) return res.status(400).json({ message: 'Maximum links per album reached' }); - let { identifier } = req.body; if (identifier) { if (!user.isAdmin) return res.status(401).json({ message: 'Only administrators can create custom links' }); diff --git a/src/api/routes/auth/loginPOST.js b/src/api/routes/auth/loginPOST.js index 373252b..cc72145 100644 --- a/src/api/routes/auth/loginPOST.js +++ b/src/api/routes/auth/loginPOST.js @@ -2,6 +2,7 @@ const bcrypt = require('bcrypt'); const moment = require('moment'); const JWT = require('jsonwebtoken'); const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); class loginPOST extends Route { constructor() { @@ -37,7 +38,7 @@ class loginPOST extends Route { iss: 'chibisafe', sub: user.id, iat: moment.utc().valueOf() - }, process.env.SECRET, { expiresIn: '30d' }); + }, Util.config.secret, { expiresIn: '30d' }); return res.json({ message: 'Successfully logged in.', diff --git a/src/api/routes/auth/registerPOST.js b/src/api/routes/auth/registerPOST.js index 7b9eb3c..e740c83 100644 --- a/src/api/routes/auth/registerPOST.js +++ b/src/api/routes/auth/registerPOST.js @@ -12,7 +12,7 @@ class registerPOST extends Route { async run(req, res, db) { // Only allow admins to create new accounts if the sign up is deactivated const user = await Util.isAuthorized(req); - if ((!user || !user.isAdmin) && process.env.USER_ACCOUNTS === 'false') return res.status(401).json({ message: 'Creation of new accounts is currently disabled' }); + if ((!user || !user.isAdmin) && !Util.config.userAccounts) return res.status(401).json({ message: 'Creation of new accounts is currently disabled' }); if (!req.body) return res.status(400).json({ message: 'No body provided' }); const { username, password } = req.body; diff --git a/src/api/routes/files/fileGET.js b/src/api/routes/files/fileGET.js index 9ec6f22..2e6f0b8 100644 --- a/src/api/routes/files/fileGET.js +++ b/src/api/routes/files/fileGET.js @@ -16,7 +16,7 @@ class fileGET extends Route { let file = await db.table('files').where({ id, userId: user.id }).first(); if (!file) return res.status(400).json({ message: 'The file doesn\'t exist or doesn\'t belong to the user' }); - file = Util.constructFilePublicLink(file); + file = Util.constructFilePublicLink(req, file); /* Fetch the albums diff --git a/src/api/routes/files/filesGET.js b/src/api/routes/files/filesGET.js index 9e90633..20ccbc5 100644 --- a/src/api/routes/files/filesGET.js +++ b/src/api/routes/files/filesGET.js @@ -30,7 +30,7 @@ class filesGET extends Route { // For each file, create the public link to be able to display the file for (let file of files) { - file = Util.constructFilePublicLink(file); + file = Util.constructFilePublicLink(req, file); } return res.json({ diff --git a/src/api/routes/search/searchGET.js b/src/api/routes/search/searchGET.js index 187fcab..3cfcfef 100644 --- a/src/api/routes/search/searchGET.js +++ b/src/api/routes/search/searchGET.js @@ -53,7 +53,7 @@ class configGET extends Route { // For each file, create the public link to be able to display the file for (let file of files) { - file = Util.constructFilePublicLink(file); + file = Util.constructFilePublicLink(req, file); } return res.json({ diff --git a/src/api/routes/service/configAllGET.js b/src/api/routes/service/configAllGET.js new file mode 100644 index 0000000..fe9dae6 --- /dev/null +++ b/src/api/routes/service/configAllGET.js @@ -0,0 +1,17 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +class configGET extends Route { + constructor() { + super('/service/config/all', 'get', { adminOnly: true }); + } + + run(req, res) { + return res.json({ + message: 'Successfully retrieved config', + config: Util.config + }); + } +} + +module.exports = configGET; diff --git a/src/api/routes/service/configGET.js b/src/api/routes/service/configGET.js index bc91a7e..c8d88d3 100644 --- a/src/api/routes/service/configGET.js +++ b/src/api/routes/service/configGET.js @@ -1,24 +1,23 @@ const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); class configGET extends Route { constructor() { - super('/service/config', 'get', { adminOnly: true }); + super('/service/config', 'get', { bypassAuth: true }); } run(req, res) { return res.json({ message: 'Successfully retrieved config', config: { - serviceName: process.env.SERVICE_NAME, - uploadFolder: process.env.UPLOAD_FOLDER, - linksPerAlbum: parseInt(process.env.MAX_LINKS_PER_ALBUM, 10), - maxUploadSize: parseInt(process.env.MAX_SIZE, 10), - filenameLength: parseInt(process.env.GENERATED_FILENAME_LENGTH, 10), - albumLinkLength: parseInt(process.env.GENERATED_ALBUM_LENGTH, 10), - generateThumbnails: process.env.GENERATE_THUMBNAILS === 'true', - generateZips: process.env.GENERATE_ZIPS === 'true', - publicMode: process.env.PUBLIC_MODE === 'true', - enableAccounts: process.env.USER_ACCOUNTS === 'true' + version: process.env.npm_package_version, + serviceName: Util.config.serviceName, + maxUploadSize: Util.config.maxSize, + filenameLength: Util.config.generatedFilenameLength, + albumLinkLength: Util.config.generatedAlbumLength, + chunkSize: Util.config.chunkSize, + publicMode: Util.config.publicMode, + userAccounts: Util.config.userAccounts } }); } diff --git a/src/api/routes/service/configPOST.js b/src/api/routes/service/configPOST.js new file mode 100644 index 0000000..68af467 --- /dev/null +++ b/src/api/routes/service/configPOST.js @@ -0,0 +1,45 @@ +const Route = require('../../structures/Route'); +const Util = require('../../utils/Util'); + +const { schema } = require('../../structures/Setting'); + +const joiOptions = { + abortEarly: false, // include all errors + allowUnknown: true, // ignore unknown props + stripUnknown: true // remove unknown props +}; + +class configGET extends Route { + constructor() { + super('/service/config', 'post', { adminOnly: true }); + } + + async run(req, res) { + const { settings } = req.body; + const { error, value } = schema.validate(settings, joiOptions); + if (error) { + return res.status(400).json({ + errors: error.details.reduce((acc, v) => { + for (const p of v.path) { + acc[p] = (acc[p] || []).concat(v.message); + } + return acc; + }, {}) + }); + } + + await Util.wipeConfigDb(); + + const keys = Object.keys(value); + for await (const item of keys) { + Util.writeConfigToDb({ + key: item, + value: value[item] + }); + } + + return res.status(200).json({ value }); + } +} + +module.exports = configGET; diff --git a/src/api/routes/service/configSchemaGET.js b/src/api/routes/service/configSchemaGET.js new file mode 100644 index 0000000..90befa9 --- /dev/null +++ b/src/api/routes/service/configSchemaGET.js @@ -0,0 +1,17 @@ +const Route = require('../../structures/Route'); +const { configSchema } = require('../../structures/Setting'); + +class configGET extends Route { + constructor() { + super('/service/config/schema', 'get', { adminOnly: true }); + } + + run(req, res) { + return res.json({ + message: 'Successfully retrieved schema', + schema: configSchema + }); + } +} + +module.exports = configGET; diff --git a/src/api/routes/uploads/uploadPOST.js b/src/api/routes/uploads/uploadPOST.js index a0dba27..4e96c80 100644 --- a/src/api/routes/uploads/uploadPOST.js +++ b/src/api/routes/uploads/uploadPOST.js @@ -8,8 +8,8 @@ const multerStorage = require('../../utils/multerStorage'); const chunksData = {}; const chunkedUploadsTimeout = 1800000; -const chunksDir = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER, 'chunks'); -const uploadDir = path.join(__dirname, '../../../../', process.env.UPLOAD_FOLDER); +const chunksDir = path.join(__dirname, '../../../../uploads/chunks'); +const uploadDir = path.join(__dirname, '../../../../uploads'); const cleanUpChunks = async (uuid, onTimeout) => { @@ -72,7 +72,7 @@ const initChunks = async uuid => { const executeMulter = multer({ // Guide: https://github.com/expressjs/multer#limits limits: { - fileSize: parseInt(process.env.MAX_SIZE, 10) * (1000 * 1000), + fileSize: Util.config.maxSize * (1000 * 1000), // Maximum number of non-file fields. // Dropzone.js will add 6 extra fields for chunked uploads. // We don't use them for anything else. @@ -257,7 +257,7 @@ class uploadPOST extends Route { async run(req, res, db) { const user = await Util.isAuthorized(req); - if (!user && process.env.PUBLIC_MODE === 'false') return res.status(401).json({ message: 'Not authorized to use this resource' }); + if (!user && !Util.config.publicMode) return res.status(401).json({ message: 'Not authorized to use this resource' }); const { finishedchunks } = req.headers; const albumId = req.headers.albumid ? req.headers.albumid === 'null' ? null : req.headers.albumid : null; if (albumId && !user) return res.status(401).json({ message: 'Only registered users can upload files to an album' }); @@ -282,8 +282,8 @@ class uploadPOST extends Route { if (albumId) await Util.saveFileToAlbum(db, albumId, result.id); - result.file = Util.constructFilePublicLink(result.file); - result.deleteUrl = `${process.env.DOMAIN}/api/file/${result.id[0]}`; + result.file = Util.constructFilePublicLink(req, result.file); + result.deleteUrl = `${Util.getHost(req)}/api/file/${result.id[0]}`; return res.status(201).send({ message: 'Sucessfully uploaded the file.', diff --git a/src/api/databaseMigration.js b/src/api/scripts/databaseMigration.js similarity index 95% rename from src/api/databaseMigration.js rename to src/api/scripts/databaseMigration.js index 1e3518e..8fab0ac 100644 --- a/src/api/databaseMigration.js +++ b/src/api/scripts/databaseMigration.js @@ -3,7 +3,7 @@ require('dotenv').config(); const nodePath = require('path'); const moment = require('moment'); const jetpack = require('fs-jetpack'); -const ThumbUtil = require('./utils/ThumbUtil'); +const ThumbUtil = require('../utils/ThumbUtil'); const oldDb = require('knex')({ client: 'sqlite3', @@ -19,12 +19,7 @@ const newDb = require('knex')({ filename: nodePath.join(__dirname, '../../database/', 'database.sqlite') }, postProcessResponse: result => { - const booleanFields = [ - 'enabled', - 'enableDownload', - 'isAdmin', - 'nsfw' - ]; + const booleanFields = ['enabled', 'enableDownload', 'isAdmin', 'nsfw', 'generateZips', 'publicMode', 'userAccounts']; const processResponse = row => { Object.keys(row).forEach(key => { diff --git a/src/api/scripts/overwriteConfig.js b/src/api/scripts/overwriteConfig.js new file mode 100644 index 0000000..0355ea6 --- /dev/null +++ b/src/api/scripts/overwriteConfig.js @@ -0,0 +1,15 @@ +require('dotenv').config(); + +const Util = require('../utils/Util'); + +const start = async () => { + try { + await Util.writeConfigToDb(Util.getEnvironmentDefaults()); + console.log('Configuration overwriten, you can now start chibisafe'); + process.exit(0); + } catch (error) { + console.error(error); + } +}; + +start(); diff --git a/src/api/structures/Database.js b/src/api/structures/Database.js index 39632a1..ed30c50 100644 --- a/src/api/structures/Database.js +++ b/src/api/structures/Database.js @@ -23,7 +23,7 @@ const db = Knex({ some things like different data types for booleans need to be considered like in the implementation below where sqlite returns 1 and 0 instead of true and false. */ - const booleanFields = ['enabled', 'enableDownload', 'isAdmin', 'nsfw']; + const booleanFields = ['enabled', 'enableDownload', 'isAdmin', 'nsfw', 'generateZips', 'publicMode', 'userAccounts']; const processResponse = row => { Object.keys(row).forEach(key => { diff --git a/src/api/structures/Route.js b/src/api/structures/Route.js index 24d45b2..9496d0f 100644 --- a/src/api/structures/Route.js +++ b/src/api/structures/Route.js @@ -2,6 +2,7 @@ const JWT = require('jsonwebtoken'); const db = require('./Database'); const moment = require('moment'); const log = require('../utils/Log'); +const Util = require('../utils/Util'); class Route { constructor(path, method, options) { @@ -30,7 +31,7 @@ class Route { const token = req.headers.authorization.split(' ')[1]; if (!token) return res.status(401).json({ message: 'No authorization header provided' }); - return JWT.verify(token, process.env.SECRET, async (error, decoded) => { + return JWT.verify(token, Util.config.secret, async (error, decoded) => { if (error) { log.error(error); return res.status(401).json({ message: 'Invalid token' }); diff --git a/src/api/structures/Server.js b/src/api/structures/Server.js index 268ba68..f584fe8 100644 --- a/src/api/structures/Server.js +++ b/src/api/structures/Server.js @@ -5,6 +5,11 @@ if (!process.env.SERVER_PORT) { process.exit(0); } +if (!process.env.DOMAIN) { + console.log('You failed to provide a domain for your instance. Edit the .env file manually and fix it.'); + process.exit(0); +} + const { loadNuxt, build } = require('nuxt'); const express = require('express'); const helmet = require('helmet'); @@ -19,11 +24,10 @@ const CronJob = require('cron').CronJob; const log = require('../utils/Log'); const Util = require('../utils/Util'); - // eslint-disable-next-line no-unused-vars const rateLimiter = new RateLimit({ - windowMs: parseInt(process.env.RATE_LIMIT_WINDOW, 10), - max: parseInt(process.env.RATE_LIMIT_MAX, 10), + windowMs: parseInt(Util.config.rateLimitWindow, 10), + max: parseInt(Util.config.rateLimitMax, 10), delayMs: 0 }); @@ -72,8 +76,8 @@ class Server { for (const File of routes) { try { const route = new File(); - this.server[route.method](process.env.ROUTE_PREFIX + route.path, route.authorize.bind(route)); - log.info(`Found route ${route.method.toUpperCase()} ${process.env.ROUTE_PREFIX}${route.path}`); + this.server[route.method](Util.config.routePrefix + route.path, route.authorize.bind(route)); + log.info(`Found route ${route.method.toUpperCase()} ${Util.config.routePrefix}${route.path}`); } catch (e) { log.error(`Failed loading route from file ${routeFile} with error: ${e.message}`); } @@ -110,4 +114,10 @@ class Server { } } -new Server().start(); +const start = async () => { + const conf = await Util.config; + new Server().start(); +}; + +start(); + diff --git a/src/api/structures/Setting.js b/src/api/structures/Setting.js new file mode 100644 index 0000000..7650ccb --- /dev/null +++ b/src/api/structures/Setting.js @@ -0,0 +1,197 @@ +require('dotenv').config(); + +const Joi = require('joi'); +const { env } = process; + +const StatsGenerator = require('../utils/StatsGenerator'); + +const Sections = Object.freeze({ + SERVICE: 'Service', + FILE: 'File', + USERS: 'Users', + SOCIAL_AND_SHARING: 'Social and sharing', + INSTANCE: 'Instance', + STATISTICS: 'Statistics', + SERVER: 'Server', + OTHER: 'Other' +}); + +// use label to name them nicely +// use meta to set custom rendering (render as radio instead of dropdown for example) and custom order +// use description to add comments which will show up as a note somewhere next to the option +const schema = Joi.object({ + // Service settings + serviceName: Joi.string().default('change-me') + // .meta({ section }) + .meta({ + section: Sections.SERVICE + }) + .label('Service name') + .description('Name of the service'), + + domain: Joi.string().default(`http://localhost:${env.SERVER_PORT}`) + .meta({ + section: Sections.SERVICE + }) + .label('Domain') + .description('Full domain this instance is gonna be running on'), + + // File related settings + chunkSize: Joi.number().integer().greater(0) + .default(90) + .meta({ + section: Sections.FILE + }) + .label('Chunk size') + .description('Maximum size of a chunk (files bigger than this limit will be split into multiple chunks)'), + + maxSize: Joi.number().integer().min(0) // setting it to 0 disabled the limit + .default(5000) + .meta({ + section: Sections.FILE + }) + .label('Maximum file size') + .description('Maximum allowed upload file size in MB (0 to disable)'), + + generateZips: Joi.boolean().default(true) + .meta({ + section: Sections.FILE + }) + .label('Generate zips') + .description('Allows users to download entire albums in ZIP format'), + + generatedFileNameLength: Joi.number().integer().min(6) + .default(12) + .meta({ + section: Sections.FILE + }) + .label('Generated file name length') + .description('How long should the automatically generated file name be'), + + generatedAlbumLength: Joi.number().integer().min(6) + .default(6) + .meta({ + section: Sections.FILE + }) + .label('Generated album name length') + .description('How long should the automatically generated album identifier be'), + + maxLinksPerAlbum: Joi.number().integer().greater(0) + .default(5) + .meta({ + section: Sections.FILE + }) + .label('Maximum album links') + .description('Maximum allowed number of a distinct links for an album'), + + uploadsFolder: Joi.string().default('uploads') + .meta({ + section: Sections.FILE + }) + .label('Uploads folder') + .description('Name of the folder where the uploads will be stored'), + + blockedExtensions: Joi.array() + .items(Joi.string().pattern(/^(\.\w+)+$/, { name: 'file extension' })) + .default(['.jar', '.exe', '.msi', '.com', '.bat', '.cmd', '.scr', '.ps1', '.sh']) + .meta({ + section: Sections.FILE + }) + .label('Blocked extensions') + .description('List of extensions which will be rejected by the server'), + + // User settings + publicMode: Joi.boolean().default(true) + .meta({ + section: Sections.USERS + }) + .label('Public mode') + .description('Allows people to upload files without an account'), + + userAccount: Joi.boolean().default(true) + .meta({ + section: Sections.USERS + }) + .label('User creation') + .description('Allows people to create new accounts'), + + // Social and sharing + metaThemeColor: Joi.string().pattern(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i).min(4) + .max(7) + .default('#20222b') + .meta({ + section: Sections.SOCIAL_AND_SHARING + }) + .label('Meta theme color') + .description('Color that user agents should use to customize the display of the page/embeds'), + + metaDescription: Joi.string().default('Blazing fast file uploader and bunker written in node! 🚀') + .meta({ + section: Sections.SOCIAL_AND_SHARING + }) + .label('Meta description') + .description('Short and accurate summary of the content of the page'), + + metaKeyword: Joi.string().default('chibisafe,lolisafe,upload,uploader,file,vue,images,ssr,file uploader,free') + .meta({ + section: Sections.SOCIAL_AND_SHARING + }) + .label('Meta keywords') + .description('Words relevant to the page\'s content separated by commas'), + + metaTwitterHandle: Joi.string().pattern(/^@\w{1,15}$/, { name: 'twitter handle' }) + .meta({ + section: Sections.SOCIAL_AND_SHARING + }) + .label('Twitter handle') + .description('Your twitter handle'), + + // Instance settings + backgroundImageURL: Joi.string().uri().default(p => `${p.domain}/assets/images/background.jpg`) + .meta({ + section: Sections.INSTANCE + }) + .label('Background image link') + .description('Background image that should be used instead of the default background'), + + logoURL: Joi.string().uri().default(p => `${p.domain}/assets/images/logo.jpg`) + .meta({ + section: Sections.INSTANCE + }) + .label('Logo image link') + .description('Logo image that should be used instead of the default logo'), + + // Statistics settings + // TODO: Pattern fails for patterns like 0 1,2-7 * * * * because of the mixing of lists and ranges + statisticsCron: Joi.string().pattern(/((((\d+,)+\d+|([\d\*]+(\/|-)\d+)|\d+|\*) ?){6})/, { name: 'cron' }).default('0 0 * * * *') + .meta({ + section: Sections.STATISTICS + }) + .label('Statistics schedule') + .description('Crontab like formated value which will be used to schedule generating and saving stats to the database'), + + enabledStatistics: Joi.array().items(Joi.string().valid(...Object.keys(StatsGenerator.statGenerators)).optional()) + .meta({ section: Sections.STATISTICS, displayType: 'checkbox' }) + .label('Enabled statistics') + .description('Which statistics should be shown when opening the statistics page'), + + savedStatistics: Joi.array().items(Joi.string().valid(...Object.keys(StatsGenerator.statGenerators)).optional()) + .meta({ section: Sections.STATISTICS, displayType: 'checkbox' }) + .label('Cached statistics') + .description('Which statistics should be saved to the database (refer to Statistics schedule for scheduling).') + .note('If a statistics is enabled but not set to be saved, it will be generated every time the statistics page is opened'), + + // Server related settings + rateLimitWindow: Joi.number().integer().default(2) + .meta({ section: Sections.SERVER }) + .label('API rate limit window') + .description('Timeframe for which requests are checked/remembered'), + + rateLimitMax: Joi.number().integer().default(5) + .meta({ section: Sections.SERVER }) + .label('API maximum limit') + .description('Max number of connections during windowMs milliseconds before sending a 429 response') +}); + +module.exports.schema = schema; +module.exports.configSchema = schema.describe(); diff --git a/src/api/utils/ThumbUtil.js b/src/api/utils/ThumbUtil.js index d08ecab..fb6e47f 100644 --- a/src/api/utils/ThumbUtil.js +++ b/src/api/utils/ThumbUtil.js @@ -10,11 +10,12 @@ class ThumbUtil { static imageExtensions = ['.jpg', '.jpeg', '.gif', '.png', '.webp']; static videoExtensions = ['.webm', '.mp4', '.wmv', '.avi', '.mov']; - static thumbPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs'); - static squareThumbPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs', 'square'); - static videoPreviewPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, 'thumbs', 'preview'); + static thumbPath = path.join(__dirname, '../../../', 'uploads', 'thumbs'); + static squareThumbPath = path.join(__dirname, '../../../', 'uploads', 'thumbs', 'square'); + static videoPreviewPath = path.join(__dirname, '../../../', 'uploads', 'thumbs', 'preview'); static generateThumbnails(filename) { + if (!filename) return; const ext = path.extname(filename).toLowerCase(); const output = `${filename.slice(0, -ext.length)}.webp`; const previewOutput = `${filename.slice(0, -ext.length)}.webm`; @@ -27,7 +28,7 @@ class ThumbUtil { } static async generateThumbnailForImage(filename, output) { - const filePath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, filename); + const filePath = path.join(__dirname, '../../../', 'uploads', filename); const file = await jetpack.readAsync(filePath, 'buffer'); await sharp(file) @@ -41,7 +42,7 @@ class ThumbUtil { } static async generateThumbnailForVideo(filename, output) { - const filePath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER, filename); + const filePath = path.join(__dirname, '../../../', 'uploads', filename); ffmpeg(filePath) .thumbnail({ diff --git a/src/api/utils/Util.js b/src/api/utils/Util.js index 6feedd4..73b2b98 100644 --- a/src/api/utils/Util.js +++ b/src/api/utils/Util.js @@ -12,37 +12,102 @@ const log = require('./Log'); const ThumbUtil = require('./ThumbUtil'); const StatsGenerator = require('./StatsGenerator'); -const blockedExtensions = process.env.BLOCKED_EXTENSIONS.split(','); const preserveExtensions = ['.tar.gz', '.tar.z', '.tar.bz2', '.tar.lzma', '.tar.lzo', '.tar.xz']; -let statsLastSavedTime = null; - class Util { - static uploadPath = path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER); + static uploadPath = path.join(__dirname, '../../../', 'uploads'); + static statsLastSavedTime = null; + static _config = null; + + static get config() { + if (this._config) return this._config; + return (async () => { + if (this._config === null) { + const conf = await db('settings').select('key', 'value'); + this._config = conf.reduce((obj, item) => ( + // eslint-disable-next-line no-sequences + obj[item.key] = typeof item.value === 'string' || item.value instanceof String ? JSON.parse(item.value) : item.value, obj + ), {}); + } + return this._config; + })(); + } + + static invalidateConfigCache() { + this._config = null; + } + + static getEnvironmentDefaults() { + return { + domain: process.env.DOMAIN, + routePrefix: process.env.ROUTE_PREFIX || '/api', + rateLimitWindow: process.env.RATE_LIMIT_WINDOW || 2, + rateLimitMax: process.env.RATE_LIMIT_MAX || 5, + secret: process.env.SECRET || randomstring.generate(64), + serviceName: process.env.SERVICE_NAME || 'change-me', + chunkSize: process.env.CHUNK_SIZE || 90, + maxSize: process.env.MAX_SIZE || 5000, + // eslint-disable-next-line eqeqeq + generateZips: process.env.GENERATE_ZIPS == undefined ? true : false, + generatedFilenameLength: process.env.GENERATED_FILENAME_LENGTH || 12, + generatedAlbumLength: process.env.GENERATED_ALBUM_LENGTH || 6, + blockedExtensions: process.env.BLOCKED_EXTENSIONS ? process.env.BLOCKED_EXTENSIONS.split(',') : ['.jar', '.exe', '.msi', '.com', '.bat', '.cmd', '.scr', '.ps1', '.sh'], + // eslint-disable-next-line eqeqeq + publicMode: process.env.PUBLIC_MODE == undefined ? true : false, + // eslint-disable-next-line eqeqeq + userAccounts: process.env.USER_ACCOUNTS == undefined ? true : false, + metaThemeColor: process.env.META_THEME_COLOR || '#20222b', + metaDescription: process.env.META_DESCRIPTION || 'Blazing fast file uploader and bunker written in node! 🚀', + metaKeywords: process.env.META_KEYWORDS || 'chibisafe,lolisafe,upload,uploader,file,vue,images,ssr,file uploader,free', + metaTwitterHandle: process.env.META_TWITTER_HANDLE || '@your-handle' + }; + } + + static async wipeConfigDb() { + try { + await db.table('settings').del(); + } catch (error) { + console.error(error); + } + } + + static async writeConfigToDb(config, wipe = false) { + // TODO: Check that the config passes the joi schema validation + if (!config || !config.key) return; + try { + config.value = JSON.stringify(config.value); + await db.table('settings').insert(config); + } catch (error) { + console.error(error); + } finally { + this.invalidateConfigCache(); + } + } static uuid() { return uuidv4(); } static isExtensionBlocked(extension) { - return blockedExtensions.includes(extension); + return this.config.blockedExtensions.includes(extension); } static getMimeFromType(fileTypeMimeObj) { return fileTypeMimeObj ? fileTypeMimeObj.mime : undefined; } - static constructFilePublicLink(file) { + static constructFilePublicLink(req, file) { /* TODO: This wont work without a reverse proxy serving both the site and the API under the same domain. Pls fix. */ - file.url = `${process.env.DOMAIN}/${file.name}`; + const host = this.getHost(req); + file.url = `${host}/${file.name}`; const { thumb, preview } = ThumbUtil.getFileThumbnail(file.name) || {}; if (thumb) { - file.thumb = `${process.env.DOMAIN}/thumbs/${thumb}`; - file.thumbSquare = `${process.env.DOMAIN}/thumbs/square/${thumb}`; - file.preview = preview && `${process.env.DOMAIN}/thumbs/preview/${preview}`; + file.thumb = `${host}/thumbs/${thumb}`; + file.thumbSquare = `${host}/thumbs/square/${thumb}`; + file.preview = preview && `${host}/thumbs/preview/${preview}`; } return file; } @@ -50,7 +115,7 @@ class Util { static getUniqueFilename(extension) { const retry = (i = 0) => { const filename = randomstring.generate({ - length: parseInt(process.env.GENERATED_FILENAME_LENGTH, 10), + length: this.config.generatedFilenameLength, capitalization: 'lowercase' }) + extension; @@ -67,7 +132,7 @@ class Util { static getUniqueAlbumIdentifier() { const retry = async (i = 0) => { const identifier = randomstring.generate({ - length: parseInt(process.env.GENERATED_ALBUM_LENGTH, 10), + length: this.config.generatedAlbumLength, capitalization: 'lowercase' }); const exists = await db @@ -164,7 +229,7 @@ class Util { const token = req.headers.authorization.split(' ')[1]; if (!token) return false; - return JWT.verify(token, process.env.SECRET, async (error, decoded) => { + return JWT.verify(token, this.config.secret, async (error, decoded) => { if (error) { log.error(error); return false; @@ -190,13 +255,7 @@ class Util { zip.addLocalFile(path.join(Util.uploadPath, file)); } zip.writeZip( - path.join( - __dirname, - '../../../', - process.env.UPLOAD_FOLDER, - 'zips', - `${album.userId}-${album.id}.zip` - ) + path.join(__dirname, '../../../', 'uploads', 'zips', `${album.userId}-${album.id}.zip`) ); } catch (error) { log.error(error); @@ -205,8 +264,8 @@ class Util { static generateThumbnails = ThumbUtil.generateThumbnails; - static async fileExists(res, exists, filename) { - exists = Util.constructFilePublicLink(exists); + static async fileExists(req, res, exists, filename) { + exists = Util.constructFilePublicLink(req, exists); res.json({ message: 'Successfully uploaded the file.', name: exists.name, @@ -214,7 +273,7 @@ class Util { size: exists.size, url: exists.url, thumb: exists.thumb, - deleteUrl: `${process.env.DOMAIN}/api/file/${exists.id}`, + deleteUrl: `${this.getHost(req)}/api/file/${exists.id}`, repeated: true }); @@ -238,7 +297,7 @@ class Util { .first(); if (dbFile) { - await this.fileExists(res, dbFile, file.data.filename); + await this.fileExists(req, res, dbFile, file.data.filename); return; } @@ -320,7 +379,7 @@ class Util { // skip generating and saving new stats. if (!force && (!db.userParams.lastMutationTime || - (statsLastSavedTime && statsLastSavedTime > db.userParams.lastMutationTime) + (Util.statsLastSavedTime && Util.statsLastSavedTime > db.userParams.lastMutationTime) ) ) { return; @@ -341,11 +400,15 @@ class Util { await db.table('statistics').insert({ type, data: JSON.stringify(data), createdAt: now, batchId }); } - statsLastSavedTime = now.getTime(); + Util.statsLastSavedTime = now.getTime(); } catch (error) { console.error(error); } } + + static getHost(req) { + return `${req.protocol}://${req.headers.host}`; + } } module.exports = Util; diff --git a/src/api/utils/generateThumbs.js b/src/api/utils/generateThumbs.js index d2cd91b..a22fcb6 100644 --- a/src/api/utils/generateThumbs.js +++ b/src/api/utils/generateThumbs.js @@ -6,7 +6,7 @@ const path = require('path'); const ThumbUtil = require('./ThumbUtil'); const start = async () => { - const files = fs.readdirSync(path.join(__dirname, '../../../', process.env.UPLOAD_FOLDER)); + const files = fs.readdirSync(path.join(__dirname, '../../../uploads')); for (const fileName of files) { console.log(`Generating thumb for '${fileName}`); // eslint-disable-next-line no-await-in-loop diff --git a/src/setup.js b/src/setup.js index 7171c19..997a251 100644 --- a/src/setup.js +++ b/src/setup.js @@ -1,5 +1,4 @@ /* eslint-disable no-console */ -const randomstring = require('randomstring'); const jetpack = require('fs-jetpack'); const qoa = require('qoa'); @@ -16,53 +15,17 @@ async function start() { const wizard = [ { type: 'input', - query: 'Port to run chibisafe in: (5000)', - handle: 'SERVER_PORT' - }, - { - type: 'input', - query: 'Full domain this instance is gonna be running on (Ex: https://chibisafe.moe):', + query: 'Full domain this instance is gonna be running on (Ex: https://my-super-chibisafe.xyz):', handle: 'DOMAIN' }, { type: 'input', - query: 'Name of the service? (Ex: chibisafe):', - handle: 'SERVICE_NAME' - }, - { - type: 'input', - query: 'Maximum allowed upload file size in MB (Ex: 100):', - handle: 'MAX_SIZE' - }, - { - type: 'confirm', - query: 'Allow users to download entire albums in ZIP format? (true)', - handle: 'GENERATE_ZIPS', - accept: 'y', - deny: 'n' - }, - { - type: 'confirm', - query: 'Allow people to upload files without an account? (true)', - handle: 'PUBLIC_MODE', - accept: 'y', - deny: 'n' - }, - { - type: 'confirm', - query: 'Allow people to create new accounts? (true)', - handle: 'USER_ACCOUNTS', - accept: 'y', - deny: 'n' - }, - { - type: 'input', - query: 'Name of the admin account? (admin)', - handle: 'ADMIN_ACCOUNT' + query: 'Port to run chibisafe in? (default: 5000)', + handle: 'SERVER_PORT' }, { type: 'interactive', - query: 'Which predefined database do you want to use?', + query: 'Which database do you want to use? (select sqlite3 if not sure)', handle: 'DB_CLIENT', symbol: '>', menu: [ @@ -73,22 +36,22 @@ async function start() { }, { type: 'input', - query: 'Database host (Ignore if you selected sqlite3):', + query: 'Database host (Leave blank if you selected sqlite3):', handle: 'DB_HOST' }, { type: 'input', - query: 'Database user (Ignore if you selected sqlite3):', + query: 'Database user (Leave blank if you selected sqlite3):', handle: 'DB_USER' }, { type: 'input', - query: 'Database password (Ignore if you selected sqlite3):', + query: 'Database password (Leave blank if you selected sqlite3):', handle: 'DB_PASSWORD' }, { type: 'input', - query: 'Database name (Ignore if you selected sqlite3):', + query: 'Database name (Leave blank if you selected sqlite3):', handle: 'DB_DATABASE' } ]; @@ -97,69 +60,30 @@ async function start() { let envfile = ''; const defaultSettings = { - _1: '# Server settings', + DOMAIN: response.DOMAIN, SERVER_PORT: response.SERVER_PORT || 5000, - WEBSITE_PORT: 5001, - ROUTE_PREFIX: '/api', - RATE_LIMIT_WINDOW: 2, - RATE_LIMIT_MAX: 5, - SECRET: randomstring.generate(64), - - _2: '# Service settings', - SERVICE_NAME: response.SERVICE_NAME || 'change-me', - DOMAIN: response.DOMAIN || `http://localhost:${response.SERVER_PORT}`, - - _3: '# File related settings', - CHUNK_SIZE: 90, - MAX_SIZE: response.MAX_SIZE || 5000, - GENERATE_ZIPS: response.GENERATE_ZIPS == undefined ? true : false, - GENERATED_FILENAME_LENGTH: 12, - GENERATED_ALBUM_LENGTH: 6, - MAX_LINKS_PER_ALBUM: 5, - UPLOAD_FOLDER: 'uploads', - BLOCKED_EXTENSIONS: ['.jar', '.exe', '.msi', '.com', '.bat', '.cmd', '.scr', '.ps1', '.sh'], - - _4: '# User settings', - PUBLIC_MODE: response.PUBLIC_MODE == undefined ? true : false, - USER_ACCOUNTS: response.USER_ACCOUNTS == undefined ? true : false, - ADMIN_ACCOUNT: response.ADMIN_ACCOUNT || 'admin', - ADMIN_PASSWORD: randomstring.generate(16), - - _5: '# Database connection settings', DB_CLIENT: response.DB_CLIENT, DB_HOST: response.DB_HOST || null, DB_USER: response.DB_USER || null, DB_PASSWORD: response.DB_PASSWORD || null, - DB_DATABASE: response.DB_DATABASE || null, - - _6: '# Social and sharing settings', - META_THEME_COLOR: '#20222b', - META_DESCRIPTION: 'Blazing fast file uploader and bunker written in node! 🚀', - META_KEYWORDS: 'chibisafe,lolisafe,upload,uploader,file,vue,images,ssr,file uploader,free', - META_TWITTER_HANDLE: '@its_pitu' + DB_DATABASE: response.DB_DATABASE || null }; const keys = Object.keys(defaultSettings); for (const item of keys) { - let prefix = `${item}=`; - if (item.startsWith('_1')) { - prefix = ''; - } else if (item.startsWith('_')) { - prefix = '\n'; - } - envfile += `${prefix}${defaultSettings[item]}\n`; + envfile += `${item}=${defaultSettings[item]}\n`; } jetpack.write('.env', envfile); jetpack.dir('database'); console.log(); - console.log('===================================================='); - console.log('== .env file generated successfully. =='); - console.log('===================================================='); - console.log(`== Your admin password is: ${defaultSettings.ADMIN_PASSWORD} ==`); - console.log('== MAKE SURE TO CHANGE IT AFTER YOUR FIRST LOGIN! =='); - console.log('===================================================='); + console.log('====================================================='); + console.log('== .env file generated successfully. =='); + console.log('====================================================='); + console.log(`== Both your initial user and password are 'admin' ==`); + console.log('== MAKE SURE TO CHANGE IT AFTER YOUR FIRST LOGIN =='); + console.log('====================================================='); console.log(); setTimeout(() => {}, 1000); } diff --git a/src/site/components/footer/Footer.vue b/src/site/components/footer/Footer.vue index 0c77603..0e3cceb 100644 --- a/src/site/components/footer/Footer.vue +++ b/src/site/components/footer/Footer.vue @@ -9,7 +9,7 @@ href="https://github.com/pitu" class="no-block">Pitu
- v{{ version }} + {{ version }}
GitHub diff --git a/src/site/components/settings/JoiObject.vue b/src/site/components/settings/JoiObject.vue new file mode 100644 index 0000000..8d3a803 --- /dev/null +++ b/src/site/components/settings/JoiObject.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/site/components/uploader/Uploader.vue b/src/site/components/uploader/Uploader.vue index abe2128..f2d213a 100644 --- a/src/site/components/uploader/Uploader.vue +++ b/src/site/components/uploader/Uploader.vue @@ -124,7 +124,7 @@ export default { parallelChunkUploads: false, chunkSize: this.config.chunkSize * 1000000, chunksUploaded: this.dropzoneChunksUploaded, - maxFilesize: this.config.maxFileSize, + maxFilesize: this.config.maxUploadSize, previewTemplate: this.$refs.template.innerHTML, dictDefaultMessage: 'Drag & Drop your files or click to browse', headers: { Accept: 'application/vnd.chibisafe.json' } diff --git a/src/site/pages/dashboard/admin/settings.vue b/src/site/pages/dashboard/admin/settings.vue index 038c495..3fce282 100644 --- a/src/site/pages/dashboard/admin/settings.vue +++ b/src/site/pages/dashboard/admin/settings.vue @@ -11,112 +11,18 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
+
+ {{ sectionName }} +
+ +
@@ -128,27 +34,69 @@