aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/routes/service/configSchemaGET.js17
-rw-r--r--src/api/structures/Setting.js197
-rw-r--r--src/site/components/settings/JoiObject.vue142
-rw-r--r--src/site/pages/dashboard/admin/settings.vue137
-rw-r--r--src/site/store/admin.js17
5 files changed, 403 insertions, 107 deletions
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/structures/Setting.js b/src/api/structures/Setting.js
new file mode 100644
index 0000000..ff98339
--- /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().hex().min(3)
+ .max(6)
+ .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/site/components/settings/JoiObject.vue b/src/site/components/settings/JoiObject.vue
new file mode 100644
index 0000000..af5b4eb
--- /dev/null
+++ b/src/site/components/settings/JoiObject.vue
@@ -0,0 +1,142 @@
+<template>
+ <div v-if="keys">
+ <div v-for="[key, field] in Object.entries(keys)" :key="key">
+ <b-field
+ :label="field.flags.label"
+ :message="field.flags.description"
+ class="field"
+ horizontal>
+ <b-input
+ v-if="getDisplayType(field) === 'string'"
+ v-model="settings.serviceName"
+ class="chibisafe-input"
+ expanded />
+ <b-input
+ v-else-if="getDisplayType(field) === 'number'"
+ v-model="settings.serviceName"
+ type="number"
+ class="chibisafe-input"
+ :min="getMin(field)"
+ :max="getMax(field)"
+ expanded />
+ <b-switch
+ v-else-if="getDisplayType(field) === 'boolean'"
+ v-model="settings.publicMode"
+ :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="settings.arr"
+ ellipsis
+ icon="label"
+ :placeholder="field.flags.label"
+ aria-close-label="Delete this tag"
+ class="taginp" />
+ <div v-else-if="getDisplayType(field) === 'checkbox'">
+ <b-checkbox v-for="item in getAllowedItems(field)" :key="item"
+ v-model="settings.ech"
+ :native-value="item">
+ {{ item }}
+ </b-checkbox>
+ </div>
+ </b-field>
+ <!--
+ TODO: Add asterisk to required fields
+ TODO: Implement showing errors returned by backend/joi
+ -->
+ </div>
+ </div>
+</template>
+
+
+<script>
+export default {
+ name: 'JoiObject',
+ props: {
+ keys: {
+ type: Object,
+ required: true
+ },
+ values: {
+ type: Object,
+ required: true
+ },
+ errors: {
+ 'type': Object,
+ 'default': () => ({})
+ }
+ },
+ data() {
+ return {
+ fields: null, // keys + values combined
+ settings: {
+ ech: []
+ }
+ };
+ },
+ mounted() {
+
+ },
+ 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];
+ }, []);
+ }
+ }
+};
+</script>
+<style lang="scss" scoped>
+ @import '~/assets/styles/_colors.scss';
+
+ .field {
+ margin-bottom: 1em;
+ }
+
+ .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/pages/dashboard/admin/settings.vue b/src/site/pages/dashboard/admin/settings.vue
index 038c495..3b2e99b 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 :keys="fields" :values="{}" />
+ </div>
<div class="mb2 mt2 text-center">
<button
class="button is-primary"
@click="promptRestartService">
- Save and restart service
+ Save settings
</button>
</div>
</div>
@@ -128,17 +34,38 @@
<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
- }),
+ 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');
+ // TODO: Implement merging fields with values from the db (no endpoint to fetch settings yet)
+ await app.store.dispatch('admin/getSettingsSchema');
},
methods: {
promptRestartService() {
diff --git a/src/site/store/admin.js b/src/site/store/admin.js
index cac1cca..4f814b5 100644
--- a/src/site/store/admin.js
+++ b/src/site/store/admin.js
@@ -12,7 +12,11 @@ export const state = () => ({
},
file: {},
settings: {},
- statistics: {}
+ statistics: {},
+ settingsSchema: {
+ type: null,
+ keys: {}
+ }
});
export const actions = {
@@ -25,10 +29,16 @@ export const actions = {
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);
@@ -105,6 +115,9 @@ export const mutations = {
state.statistics = statistics;
}
},
+ setSettingsSchema(state, { schema }) {
+ state.settingsSchema = schema;
+ },
setUsers(state, { users }) {
state.users = users;
},