aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/database/migrations/20210112011802_addSettingsTable.js25
-rw-r--r--src/api/database/seeds/initial.js34
-rw-r--r--src/api/routes/admin/fileGET.js2
-rw-r--r--src/api/routes/admin/userGET.js2
-rw-r--r--src/api/routes/albums/albumFullGET.js2
-rw-r--r--src/api/routes/albums/albumGET.js2
-rw-r--r--src/api/routes/albums/albumZipGET.js8
-rw-r--r--src/api/routes/albums/albumsGET.js2
-rw-r--r--src/api/routes/albums/link/linkPOST.js10
-rw-r--r--src/api/routes/auth/loginPOST.js3
-rw-r--r--src/api/routes/auth/registerPOST.js2
-rw-r--r--src/api/routes/files/fileGET.js2
-rw-r--r--src/api/routes/files/filesGET.js2
-rw-r--r--src/api/routes/search/searchGET.js2
-rw-r--r--src/api/routes/service/configAllGET.js17
-rw-r--r--src/api/routes/service/configGET.js21
-rw-r--r--src/api/routes/service/configPOST.js45
-rw-r--r--src/api/routes/service/configSchemaGET.js17
-rw-r--r--src/api/routes/uploads/uploadPOST.js12
-rw-r--r--src/api/scripts/databaseMigration.js (renamed from src/api/databaseMigration.js)9
-rw-r--r--src/api/scripts/overwriteConfig.js15
-rw-r--r--src/api/structures/Database.js2
-rw-r--r--src/api/structures/Route.js3
-rw-r--r--src/api/structures/Server.js22
-rw-r--r--src/api/structures/Setting.js197
-rw-r--r--src/api/utils/ThumbUtil.js11
-rw-r--r--src/api/utils/Util.js115
-rw-r--r--src/api/utils/generateThumbs.js2
-rw-r--r--src/setup.js110
-rw-r--r--src/site/components/footer/Footer.vue2
-rw-r--r--src/site/components/settings/JoiObject.vue151
-rw-r--r--src/site/components/uploader/Uploader.vue2
-rw-r--r--src/site/pages/dashboard/admin/settings.vue166
-rw-r--r--src/site/store/admin.js37
-rw-r--r--src/site/store/config.js33
-rw-r--r--src/site/store/index.js4
36 files changed, 777 insertions, 314 deletions
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 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(process.env.ADMIN_PASSWORD, 10);
+ 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
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</a>
</span><br>
- <span>v{{ version }}</span>
+ <span>{{ version }}</span>
</div>
<div class="column is-narrow bottom-up">
<a href="https://github.com/weebdev/chibisafe">GitHub</a>
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 @@
+<template>
+ <div v-if="settings">
+ <div v-for="[key, field] in Object.entries(settings)" :key="key">
+ <b-field
+ :label="field.flags.label"
+ :message="getErrorMessage(key) || field.flags.description"
+ :type="getValidationType(key)"
+ class="field"
+ horizontal>
+ <b-input
+ v-if="getDisplayType(field) === 'string'"
+ v-model="values[key]"
+ class="chibisafe-input"
+ expanded />
+ <b-input
+ v-else-if="getDisplayType(field) === 'number'"
+ v-model="values[key]"
+ type="number"
+ class="chibisafe-input"
+ :min="getMin(field)"
+ :max="getMax(field)"
+ expanded />
+ <b-switch
+ v-else-if="getDisplayType(field) === 'boolean'"
+ v-model="values[key]"
+ :rounded="false"
+ :true-value="true"
+ :false-value="false" />
+ <!-- TODO: If array and has allowed items, limit input to those items only -->
+ <b-taginput
+ v-else-if="getDisplayType(field) === 'array' || getDisplayType(field) === 'tagInput'"
+ v-model="values[key]"
+ ellipsis
+ icon="label"
+ :placeholder="field.flags.label"
+ class="taginp" />
+ <div v-else-if="getDisplayType(field) === 'checkbox'">
+ <b-checkbox v-for="item in getAllowedItems(field)" :key="item"
+ v-model="values[key]"
+ :native-value="item">
+ {{ item }}
+ </b-checkbox>
+ </div>
+ </b-field>
+ <!--
+ TODO: Add asterisk to required fields
+ -->
+ </div>
+ </div>
+</template>
+
+
+<script>
+export default {
+ name: 'JoiObject',
+ props: {
+ settings: {
+ type: Object,
+ required: true
+ },
+ errors: {
+ 'type': Object,
+ 'default': () => ({})
+ }
+ },
+ data() {
+ return {
+ values: {}
+ };
+ },
+ created() {
+ for (const [k, v] of Object.entries(this.settings)) {
+ this.$set(this.values, k, v.value);
+ }
+ },
+ methods: {
+ getMin(field) {
+ if (field.type !== 'number') return;
+
+ for (const rule of field.rules) {
+ if (rule.name === 'greater') return rule.args.limit + 1;
+ if (rule.name === 'min') return rule.args.limit;
+ }
+ },
+ getMax(field) {
+ if (field.type !== 'number') return;
+
+ for (const rule of field.rules) {
+ if (rule.name === 'less') return rule.args.limit - 1;
+ if (rule.name === 'max') return rule.args.limit;
+ }
+ },
+ getDisplayType(field) {
+ if (!field.metas) return field.type;
+
+ const foundMeta = field.metas.find(e => e.displayType);
+
+ if (foundMeta) return foundMeta.displayType;
+
+ return field.type;
+ },
+ getAllowedItems(field) {
+ if (!field.items) return [];
+
+ return field.items.reduce((acc, item) => {
+ if (!item.allow) return acc;
+
+ return [...acc, ...item.allow];
+ }, []);
+ },
+ getValidationType(fieldName) {
+ if (Array.isArray(this.errors[fieldName])) return 'is-danger';
+ return null;
+ },
+ getErrorMessage(fieldName) {
+ if (Array.isArray(this.errors[fieldName])) return this.errors[fieldName].join('\n');
+ return null;
+ },
+ getValues() {
+ return this.values;
+ }
+ }
+};
+</script>
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+
+ .field {
+ margin-bottom: 1em;
+
+ ::v-deep .help.is-danger {
+ white-space: pre-line;
+ }
+ }
+
+ .taginp {
+ ::v-deep {
+ .taginput-container {
+ border-color: #585858;
+ }
+
+ .input::placeholder {
+ color: $textColor;
+ }
+
+ .taginput-container, .control, .input {
+ background-color: transparent;
+ }
+ }
+ }
+</style>
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 @@
</h2>
<hr>
- <b-field
- label="Service name"
- message="Please enter the name which this service is gonna be identified as"
- horizontal>
- <b-input
- v-model="settings.serviceName"
- class="chibisafe-input"
- expanded />
- </b-field>
-
- <b-field
- label="Upload folder"
- message="Where to store the files relative to the working directory"
- horizontal>
- <b-input
- v-model="settings.uploadFolder"
- class="chibisafe-input"
- expanded />
- </b-field>
-
- <b-field
- label="Links per album"
- message="Maximum links allowed per album"
- horizontal>
- <b-input
- v-model="settings.linksPerAlbum"
- class="chibisafe-input"
- type="number"
- expanded />
- </b-field>
-
- <b-field
- label="Max upload size"
- message="Maximum allowed file size in MB"
- horizontal>
- <b-input
- v-model="settings.maxUploadSize"
- class="chibisafe-input"
- expanded />
- </b-field>
-
- <b-field
- label="Filename length"
- message="How many characters long should the generated filenames be"
- horizontal>
- <b-input
- v-model="settings.filenameLength"
- class="chibisafe-input"
- expanded />
- </b-field>
-
- <b-field
- label="Album link length"
- message="How many characters a link for an album should have"
- horizontal>
- <b-input
- v-model="settings.albumLinkLength"
- class="chibisafe-input"
- expanded />
- </b-field>
-
- <b-field
- label="Generate thumbnails"
- message="Generate thumbnails when uploading a file if possible"
- horizontal>
- <b-switch
- v-model="settings.generateThumbnails"
- :true-value="true"
- :false-value="false" />
- </b-field>
-
- <b-field
- label="Generate zips"
- message="Allow generating zips to download entire albums"
- horizontal>
- <b-switch
- v-model="settings.generateZips"
- :true-value="true"
- :false-value="false" />
- </b-field>
-
- <b-field
- label="Public mode"
- message="Enable anonymous uploades"
- horizontal>
- <b-switch
- v-model="settings.publicMode"
- :true-value="true"
- :false-value="false" />
- </b-field>
-
- <b-field
- label="Enable creating account"
- message="Enable creating new accounts in the platform"
- horizontal>
- <b-switch
- v-model="settings.enableAccounts"
- :true-value="true"
- :false-value="false" />
- </b-field>
+ <div v-for="[sectionName, fields] in Object.entries(sectionedSettings)" :key="sectionName" class="block">
+ <h5 class="title is-5 has-text-grey-lighter">
+ {{ sectionName }}
+ </h5>
+ <JoiObject ref="jois" :settings="fields" :errors="validationErrors" />
+ </div>
<div class="mb2 mt2 text-center">
<button
class="button is-primary"
@click="promptRestartService">
- Save and restart service
+ Save settings
</button>
</div>
</div>
@@ -128,27 +34,69 @@
<script>
import { mapState } from 'vuex';
import Sidebar from '~/components/sidebar/Sidebar.vue';
+import JoiObject from '~/components/settings/JoiObject.vue';
export default {
components: {
- Sidebar
+ Sidebar,
+ JoiObject
},
middleware: ['auth', 'admin'],
- computed: mapState({
- settings: state => state.admin.settings
- }),
+ data() {
+ return {
+ validationErrors: {}
+ };
+ },
+ computed: {
+ ...mapState({
+ settings: state => state.admin.settings,
+ settingsSchema: state => state.admin.settingsSchema
+ }),
+ sectionedSettings() {
+ return Object.entries(this.settingsSchema.keys).reduce((acc, [key, field]) => {
+ if (!field.metas) acc.Other = { ...acc.Other, [key]: field };
+
+ const sectionMeta = field.metas.find(e => e.section);
+ if (sectionMeta) {
+ acc[sectionMeta.section] = { ...acc[sectionMeta.section], [key]: field };
+ } else {
+ acc.Other = { ...acc.Other, [key]: field };
+ }
+
+ return acc;
+ }, {});
+ }
+ },
async asyncData({ app }) {
await app.store.dispatch('admin/fetchSettings');
+ await app.store.dispatch('admin/getSettingsSchema');
+ await app.store.commit('admin/populateSchemaWithValues');
},
methods: {
promptRestartService() {
this.$buefy.dialog.confirm({
- message: 'Keep in mind that restarting only works if you have PM2 or something similar set up. Continue?',
- onConfirm: () => this.restartService()
+ message: 'Certain changes need for you to manually restart your chibisafe instance, please do so from the terminal. Continue?',
+ onConfirm: () => this.saveSettings()
});
},
- restartService() {
- this.$handler.executeAction('admin/restartService');
+ async saveSettings() {
+ // handle refs
+ let settings = {};
+ for (const joiComponent of this.$refs.jois) {
+ settings = { ...settings, ...joiComponent.getValues() };
+ }
+
+ try {
+ await this.$store.dispatch('admin/saveSettings', settings);
+ this.$set(this, 'validationErrors', {});
+
+ await this.$store.dispatch('config/fetchSettings');
+ // this.$handler.executeAction('admin/restartService');
+ } catch (e) {
+ if (e.response?.data?.errors) {
+ this.$set(this, 'validationErrors', e.response.data.errors);
+ }
+ }
}
},
head() {
diff --git a/src/site/store/admin.js b/src/site/store/admin.js
index cac1cca..9b1d591 100644
--- a/src/site/store/admin.js
+++ b/src/site/store/admin.js
@@ -12,23 +12,38 @@ export const state = () => ({
},
file: {},
settings: {},
- statistics: {}
+ statistics: {},
+ settingsSchema: {
+ type: null,
+ keys: {}
+ }
});
export const actions = {
async fetchSettings({ commit }) {
- const response = await this.$axios.$get('service/config');
+ const response = await this.$axios.$get('service/config/all');
commit('setSettings', response);
return response;
},
+ async saveSettings({ commit }, settings) {
+ const response = await this.$axios.$post('service/config', { settings });
+
+ return response;
+ },
async fetchStatistics({ commit }, category) {
const url = category ? `service/statistics/${category}` : 'service/statistics';
const response = await this.$axios.$get(url);
- commit('setStatistics', { statistics: response.statistics, category: category });
+ commit('setStatistics', { statistics: response.statistics, category });
return response;
},
+ async getSettingsSchema({ commit }) {
+ // XXX: Maybe move to the config store?
+ const response = await this.$axios.$get('service/config/schema');
+
+ commit('setSettingsSchema', response);
+ },
async fetchUsers({ commit }) {
const response = await this.$axios.$get('admin/users');
commit('setUsers', response);
@@ -95,9 +110,6 @@ export const actions = {
};
export const mutations = {
- setSettings(state, { config }) {
- state.settings = config;
- },
setStatistics(state, { statistics, category }) {
if (category) {
state.statistics[category] = statistics[category];
@@ -105,6 +117,12 @@ export const mutations = {
state.statistics = statistics;
}
},
+ setSettings(state, { config }) {
+ state.settings = config;
+ },
+ setSettingsSchema(state, { schema }) {
+ state.settingsSchema = schema;
+ },
setUsers(state, { users }) {
state.users = users;
},
@@ -135,5 +153,12 @@ export const mutations = {
state.user.isAdmin = isAdmin;
}
}
+ },
+ populateSchemaWithValues({ settings, settingsSchema }) {
+ for (const [key, value] of Object.entries(settings)) {
+ if (settingsSchema.keys?.[key] !== undefined) {
+ settingsSchema.keys[key].value = value;
+ }
+ }
}
};
diff --git a/src/site/store/config.js b/src/site/store/config.js
index c17632d..124b778 100644
--- a/src/site/store/config.js
+++ b/src/site/store/config.js
@@ -1,18 +1,33 @@
export const state = () => ({
- development: true,
- version: '4.0.0',
- URL: 'http://localhost:8080',
- baseURL: 'http://localhost:8080/api',
+ development: process.env.development,
+ version: '',
+ URL: process.env.development ? 'http://localhost:5000' : '/',
+ baseURL: `${process.env.development ? 'http://localhost:5000' : ''}/api`,
serviceName: '',
- maxFileSize: 100,
- chunkSize: 90,
- maxLinksPerAlbum: 5,
+ maxUploadSize: 0,
+ chunkSize: 0,
publicMode: false,
userAccounts: false
});
+export const actions = {
+ async fetchSettings({ commit }) {
+ const response = await this.$axios.$get('service/config');
+ commit('setSettings', response);
+
+ return response;
+ }
+};
+
export const mutations = {
- set(state, config) {
- Object.assign(state, config);
+ setSettings(state, { config }) {
+ state.version = `v${config.version}`;
+ state.serviceName = config.serviceName;
+ state.maxUploadSize = config.maxUploadSize;
+ state.filenameLength = config.filenameLength;
+ state.albumLinkLength = config.albumLinkLength;
+ state.chunkSize = config.chunkSize;
+ state.publicMode = config.publicMode;
+ state.userAccounts = config.userAccounts;
}
};
diff --git a/src/site/store/index.js b/src/site/store/index.js
index b94a336..680ae84 100644
--- a/src/site/store/index.js
+++ b/src/site/store/index.js
@@ -1,8 +1,6 @@
-import config from '../../../dist/config.json';
-
export const actions = {
async nuxtServerInit({ commit, dispatch }) {
- commit('config/set', config);
+ await dispatch('config/fetchSettings');
const cookies = this.$cookies.getAll();
if (!cookies.token) return dispatch('auth/logout');