diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/routes/service/configSchemaGET.js | 17 | ||||
| -rw-r--r-- | src/api/structures/Setting.js | 197 | ||||
| -rw-r--r-- | src/site/components/settings/JoiObject.vue | 142 | ||||
| -rw-r--r-- | src/site/pages/dashboard/admin/settings.vue | 137 | ||||
| -rw-r--r-- | src/site/store/admin.js | 17 |
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; }, |