assets as last thing for manage assets page in boards

jschan
Thomas Lynch 3 years ago
parent 55c0ecb6bb
commit a93e42d90c
  1. 1
      CHANGELOG.md
  2. 12
      configs/template.js.example
  3. 18
      controllers/forms.js
  4. 47
      controllers/forms/addassets.js
  5. 48
      controllers/forms/deleteassets.js
  6. 6
      controllers/forms/globalsettings.js
  7. 2
      controllers/forms/index.js
  8. 10
      db/boards.js
  9. 8
      gulp/res/css/style.css
  10. 6
      helpers/filemiddlewares.js
  11. 26
      migrations/0.1.5.js
  12. 96
      models/forms/addassets.js
  13. 7
      models/forms/changeglobalsettings.js
  14. 2
      models/forms/create.js
  15. 32
      models/forms/deleteassets.js
  16. 1
      models/forms/deleteboard.js
  17. 2
      package.json
  18. 5
      views/mixins/fileform.pug
  19. 9
      views/pages/globalmanagesettings.pug
  20. 12
      views/pages/manageassets.pug

@ -33,6 +33,7 @@
- Country blocklist now can actually fit all countries
- Make "auth level" text box into "account type" dropdown in accounts page, easier to understand
- Board owners can now edit custom pages
- Board owners can now manage custom assets
- Show a little message and disable reply form on full threads (hit reply limit)
- Allow longer language names for code blocks
- User settings import and export option

@ -96,6 +96,7 @@ module.exports = {
link: 4,
detected: 4,
dice: 4,
fortune: 0,
},
},
@ -268,15 +269,22 @@ module.exports = {
max: 10, //number of banners uploadable in one request
total: 100, //max number of flags for a board
},
assetFiles: {
max: 10, //number of assets uploadable in one request
total: 50, //max number of assets for a board
},
bannerFilesSize: { //in bytes, 10MB default
max: 10485760
},
assetFilesSize: { //in bytes, 10MB default
max: 10485760
},
flagFilesSize: { //in bytes, 1MB default
max: 1048576
},
/* NOTE: postFilesSize and bannerFilesSize counts in bytes the amount of total data in form submission including
/* NOTE: xFilesSize in bytes the amount of total data in form submission including
other fields like message, name, etc. Therefore a very long message would reduce the space left for files very slightly.
To counteract this, consider increasing postFilesSize and bannerFilesSize beyond your desired max filesize by a small margin */
To counteract this, consider increasing them beyond your desired max filesize by a small margin */
fieldLength: { //max length of fields in some forms
//post form
name: 100,

@ -24,8 +24,8 @@ const express = require('express')
//controllers
, { deleteBoardController, editBansController, appealController, globalActionController,
actionController, addCustomPageController, deleteCustomPageController, addNewsController,
editNewsController, deleteNewsController, uploadBannersController, deleteBannersController,
addFlagsController, deleteFlagsController, boardSettingsController, transferController,
editNewsController, deleteNewsController, uploadBannersController, deleteBannersController, addFlagsController,
deleteFlagsController, boardSettingsController, transferController, addAssetsController, deleteAssetsController,
resignController, deleteAccountController, loginController, registerController, changePasswordController,
editAccountsController, globalSettingsController, createBoardController, makePostController,
editCustomPageController, editPostController, newCaptcha, blockBypass, logout } = require(__dirname+'/forms/index.js');
@ -49,14 +49,20 @@ router.post('/editpost', geoAndTor, torPreBypassCheck, processIp, useSession, se
//board management forms
router.post('/board/:board/transfer', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), transferController.paramConverter, transferController.controller);
router.post('/board/:board/settings', geoAndTor, torPreBypassCheck, processIp, useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), boardSettingsController.paramConverter, boardSettingsController.controller);
router.post('/board/:board/editbans', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(3), editBansController.paramConverter, editBansController.controller); //edit bans
router.post('/board/:board/deleteboard', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(config.get.deleteBoardPermLevel), deleteBoardController.controller); //delete board
//board crud banners, flags, assets, custompages
router.post('/board/:board/addbanners', useSession, sessionRefresh, fileMiddlewares.banner, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), numFiles, uploadBannersController.controller); //add banners
router.post('/board/:board/deletebanners', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), deleteBannersController.paramConverter, deleteBannersController.controller); //delete banners
///--- wip
router.post('/board/:board/addassets', useSession, sessionRefresh, fileMiddlewares.asset, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), numFiles, addAssetsController.controller); //add assets
router.post('/board/:board/deleteassets', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), deleteAssetsController.paramConverter, deleteAssetsController.controller); //delete assets
router.post('/board/:board/addflags', useSession, sessionRefresh, fileMiddlewares.flag, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), numFiles, addFlagsController.controller); //add flags
router.post('/board/:board/deleteflags', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), deleteFlagsController.paramConverter, deleteFlagsController.controller); //delete flags
router.post('/board/:board/addcustompages', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), addCustomPageController.paramConverter, addCustomPageController.controller); //add banners
router.post('/board/:board/deletecustompages', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), deleteCustomPageController.paramConverter, deleteCustomPageController.controller); //delete banners
router.post('/board/:board/editbans', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(3), editBansController.paramConverter, editBansController.controller); //edit bans
router.post('/board/:board/deleteboard', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(config.get.deleteBoardPermLevel), deleteBoardController.controller); //delete board
router.post('/board/:board/addcustompages', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), addCustomPageController.paramConverter, addCustomPageController.controller); //add custom pages
router.post('/board/:board/deletecustompages', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), deleteCustomPageController.paramConverter, deleteCustomPageController.controller); //delete custom pages
router.post('/board/:board/editcustompage', useSession, sessionRefresh, csrf, Boards.exists, calcPerms, isLoggedIn, hasPerms(2), editCustomPageController.paramConverter, editCustomPageController.controller); //edit custom page
//global management forms

@ -0,0 +1,47 @@
'use strict';
const addAssets = require(__dirname+'/../../models/forms/addassets.js')
, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
, deleteTempFiles = require(__dirname+'/../../helpers/files/deletetempfiles.js')
, config = require(__dirname+'/../../config.js')
, { checkSchema, lengthBody, numberBody, minmaxBody, numberBodyVariable,
inArrayBody, arrayInBody, existsBody } = require(__dirname+'/../../helpers/schema.js');
//almost a copy of banners code, since it can be handled the same. maybe refactor both into 1 with a "type" arg or something
//or allowing 2 types to accommodate flags too where they are named (not the object.keys & .values use in manageassets template)
module.exports = {
//paramConverter: paramConverter({}),
controller: async (req, res, next) => {
const { globalLimits } = config.get;
const errors = [];
if (res.locals.numFiles === 0) {
errors.push('Must provide a file');
} else if (res.locals.numFiles > globalLimits.assetFiles.max) {
errors.push(`Exceeded max asset uploads in one request of ${globalLimits.assetFiles.max}`);
} else if (res.locals.board.assets.length+res.locals.numFiles > globalLimits.assetFiles.total) {
errors.push(`Total number of assets would exceed global limit of ${globalLimits.assetFiles.total}`);
}
if (errors.length > 0) {
await deleteTempFiles(req).catch(e => console.error);
return dynamicResponse(req, res, 400, 'message', {
'title': 'Bad request',
'errors': errors,
'redirect': `/${req.params.board}/manage/assets.html`
})
}
try {
await addAssets(req, res, next);
} catch (err) {
await deleteTempFiles(req).catch(e => console.error);
return next(err);
}
}
}

@ -0,0 +1,48 @@
'use strict';
const deleteAssets = require(__dirname+'/../../models/forms/deleteassets.js')
, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
, paramConverter = require(__dirname+'/../../helpers/paramconverter.js')
, { checkSchema, lengthBody, numberBody, minmaxBody, numberBodyVariable,
inArrayBody, arrayInBody, existsBody } = require(__dirname+'/../../helpers/schema.js');
module.exports = {
paramConverter: paramConverter({
allowedArrays: ['checkedassets']
}),
controller: async (req, res, next) => {
const errors = await checkSchema([
{ result: lengthBody(req.body.checkedassets, 1), expected: false, error: 'Must select at least one asset to delete' },
]);
if (errors.length > 0) {
return dynamicResponse(req, res, 400, 'message', {
'title': 'Bad request',
'errors': errors,
'redirect': `/${req.params.board}/manage/assets.html`
})
}
for (let i = 0; i < req.body.checkedassets.length; i++) {
if (!res.locals.board.assets.includes(req.body.checkedassets[i])) {
return dynamicResponse(req, res, 400, 'message', {
'title': 'Bad request',
'message': 'Invalid assets selected',
'redirect': `/${req.params.board}/manage/assets.html`
})
}
}
try {
await deleteAssets(req, res, next);
} catch (err) {
console.error(err);
return next(err);
}
}
}

@ -20,7 +20,8 @@ module.exports = {
'overboard_limit', 'overboard_catalog_limit', 'lock_wait', 'prune_modlogs', 'prune_ips', 'thumb_size', 'video_thumb_percentage', 'quote_limit', 'preview_replies',
'sticky_preview_replies', 'early_404_fraction', 'early_404_replies', 'max_recent_news', 'highlight_options_threshold', 'global_limits_thread_limit_min',
'global_limits_thread_limit_max', 'global_limits_reply_limit_min', 'global_limits_reply_limit_max', 'global_limits_bump_limit_min', 'global_limits_bump_limit_max',
'global_limits_post_files_max', 'global_limits_post_files_size_max', 'global_limits_banner_files_width', 'global_limits_banner_files_height', 'global_limits_banner_files_max',
'global_limits_post_files_max', 'global_limits_post_files_size_max', 'global_limits_asset_files_total', 'global_limits_asset_files_max', 'global_limits_asset_files_size_max',
'global_limits_banner_files_width', 'global_limits_banner_files_height', 'global_limits_banner_files_max',
'global_limits_banner_files_total', 'global_limits_banner_files_size_max', 'global_limits_flag_files_max', 'global_limits_flag_files_total', 'global_limits_flag_files_size_max',
'global_limits_field_length_name', 'global_limits_field_length_email', 'global_limits_field_length_subject', 'global_limits_field_length_postpassword',
'global_limits_field_length_message', 'global_limits_field_length_report_reason', 'global_limits_field_length_ban_reason', 'global_limits_field_length_log_message',
@ -136,6 +137,9 @@ module.exports = {
{ result: numberBody(req.body.global_limits_flag_files_size_max), expected: true, error: 'Flag files size must be a number' },
{ result: numberBody(req.body.global_limits_flag_files_max), expected: true, error: 'Flag files max must be a number' },
{ result: numberBody(req.body.global_limits_flag_files_total), expected: true, error: 'Flag files total must be a number' },
{ result: numberBody(req.body.global_limits_asset_files_size_max), expected: true, error: 'Asset files size must be a number' },
{ result: numberBody(req.body.global_limits_asset_files_max), expected: true, error: 'Asset files max must be a number' },
{ result: numberBody(req.body.global_limits_asset_files_total), expected: true, error: 'Asset files total must be a number' },
{ result: numberBody(req.body.global_limits_field_length_name), expected: true, error: 'Global limit name field length must be a number' },
{ result: numberBody(req.body.global_limits_field_length_email), expected: true, error: 'Global limit email field length must be a number' },
{ result: numberBody(req.body.global_limits_field_length_subject), expected: true, error: 'Global limit subject field length must be a number' },

@ -15,6 +15,8 @@ module.exports = {
deleteNewsController: require(__dirname+'/deletenews.js'),
uploadBannersController: require(__dirname+'/uploadbanners.js'),
deleteBannersController: require(__dirname+'/deletebanners.js'),
addAssetsController: require(__dirname+'/addassets.js'),
deleteAssetsController: require(__dirname+'/deleteassets.js'),
addFlagsController: require(__dirname+'/addflags.js'),
deleteFlagsController: require(__dirname+'/deleteflags.js'),
boardSettingsController: require(__dirname+'/boardsettings.js'),

@ -138,6 +138,16 @@ module.exports = {
return module.exports.addToArray(board, 'banners', filenames)
},
removeAssets: (board, filenames) => {
cache.del(`board:${board}`);
return module.exports.removeFromArray(board, 'assets', filenames);
},
addAssets: (board, filenames) => {
cache.del(`board:${board}`);
return module.exports.addToArray(board, 'assets', filenames)
},
setFlags: (board, flags) => {
cache.del(`board:${board}`);
//could use dot notation and set flags.x for only changes? seems a bit unsafe though and couldnt have . in name

@ -899,6 +899,14 @@ input:invalid, textarea:invalid {
min-height: 100px;
}
.board-asset {
margin: 10px;
border: 1px solid var(--post-outline-color);
height: 100px;
width: 100px;
object-fit: scale-down;
}
.board-flag {
margin: 5px;
max-width: 100%;

@ -14,8 +14,7 @@ const { debugLogs } = require(__dirname+'/../configs/secrets.js')
}
, updateHandlers = () => {
const { globalLimits, filterFileNames, spaceFileNameReplacement } = require(__dirname+'/../config.js').get;
['flag', 'banner', 'post'].forEach(fileType => {
//one day this will be more easy to extend
['flag', 'banner', 'asset', 'post'].forEach(fileType => {
const fileSizeLimit = globalLimits[`${fileType}FilesSize`];
const fileNumLimit = globalLimits[`${fileType}Files`];
const fileNumLimitFunction = (req, res, next) => {
@ -50,6 +49,9 @@ addCallback('config', updateHandlers);
module.exports = {
asset: (req, res, next) => {
return fileHandlers.asset(req, res, next);
},
banner: (req, res, next) => {
return fileHandlers.banner(req, res, next);
},

@ -0,0 +1,26 @@
'use strict';
const config = require(__dirname+'/../config.js')
, fs = require('fs-extra')
, uploadDirectory = require(__dirname+'/../helpers/files/uploadDirectory.js');
module.exports = async(db, redis) => {
console.log('Adding assets');
await fs.ensureDir(`${uploadDirectory}/asset/`);
const template = require(__dirname+'/../configs/template.js.example');
await db.collection('globalsettings').updateOne({ _id: 'globalsettings' }, {
'$set': {
'globalLimits.assetFiles': template.globalLimits.assetFiles,
'globalLimits.assetFilesSize': template.globalLimits.assetFilesSize,
},
});
await db.collection('boards').updateMany({}, {
'$set': {
'assets': [],
},
});
console.log('Cleared boards cache');
await redis.deletePattern('board:*');
console.log('Cleared globalsettings cache');
await redis.deletePattern('globalsettings');
};

@ -0,0 +1,96 @@
'use strict';
const path = require('path')
, { remove, pathExists } = require('fs-extra')
, config = require(__dirname+'/../../config.js')
, uploadDirectory = require(__dirname+'/../../helpers/files/uploadDirectory.js')
, moveUpload = require(__dirname+'/../../helpers/files/moveupload.js')
, mimeTypes = require(__dirname+'/../../helpers/files/mimetypes.js')
, imageIdentify = require(__dirname+'/../../helpers/files/imageidentify.js')
, deleteTempFiles = require(__dirname+'/../../helpers/files/deletetempfiles.js')
, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
, { Boards } = require(__dirname+'/../../db/')
, buildQueue = require(__dirname+'/../../queue.js');
module.exports = async (req, res, next) => {
const { globalLimits, checkRealMimeTypes } = config.get;
const redirect = `/${req.params.board}/manage/assets.html`;
for (let i = 0; i < res.locals.numFiles; i++) {
if (!mimeTypes.allowed(req.files.file[i].mimetype, {
image: true,
animatedImage: true,
video: false,
audio: false,
other: false
})) {
await deleteTempFiles(req).catch(e => console.error);
return dynamicResponse(req, res, 400, 'message', {
'title': 'Bad request',
'message': `Invalid file type for ${req.files.file[i].name}. Mimetype ${req.files.file[i].mimetype} not allowed.`,
'redirect': redirect
});
}
// check for any mismatching supposed mimetypes from the actual file mimetype
if (checkRealMimeTypes) {
if (!(await mimeTypes.realMimeCheck(req.files.file[i]))) {
deleteTempFiles(req).catch(e => console.error);
return dynamicResponse(req, res, 400, 'message', {
'title': 'Bad request',
'message': `Mime type mismatch for file "${req.files.file[i].name}"`,
'redirect': redirect
});
}
}
}
const filenames = [];
for (let i = 0; i < res.locals.numFiles; i++) {
const file = req.files.file[i];
const filename = file.sha256 + path.extname(file.name);
file.filename = filename;
//check if already exists
const exists = await pathExists(`${uploadDirectory}/asset/${req.params.board}/${filename}`);
if (exists) {
await remove(file.tempFilePath);
continue;
}
//add to list after checking it doesnt already exist
filenames.push(filename);
//then upload it
await moveUpload(file, filename, `asset/${req.params.board}`);
//and delete the temp file
await remove(file.tempFilePath);
}
deleteTempFiles(req).catch(e => console.error);
// no new assets
if (filenames.length === 0) {
return dynamicResponse(req, res, 400, 'message', {
'title': 'Bad request',
'message': `Asset${res.locals.numFiles > 1 ? 's' : ''} already exist${res.locals.numFiles > 1 ? '' : 's'}`,
'redirect': redirect
});
}
// add assets to the db
await Boards.addAssets(req.params.board, filenames);
// add assets to board in memory
res.locals.board.assets = res.locals.board.assets.concat(filenames);
return dynamicResponse(req, res, 200, 'message', {
'title': 'Success',
'message': `Uploaded ${filenames.length} new assets.`,
'redirect': redirect
});
}

@ -196,6 +196,13 @@ module.exports = async (req, res, next) => {
flagFilesSize: {
max: numberSetting(req.body.global_limits_flag_files_size_max, oldSettings.globalLimits.flagFilesSize.max),
},
assetFiles: {
max: numberSetting(req.body.global_limits_asset_files_max, oldSettings.globalLimits.assetFiles.max),
total: numberSetting(req.body.global_limits_asset_files_total, oldSettings.globalLimits.assetFiles.total),
},
assetFilesSize: {
max: numberSetting(req.body.global_limits_asset_files_size_max, oldSettings.globalLimits.assetFilesSize.max),
},
fieldLength: {
name: numberSetting(req.body.global_limits_field_length_name, oldSettings.globalLimits.fieldLength.name),
email: numberSetting(req.body.global_limits_field_length_email, oldSettings.globalLimits.fieldLength.email),

@ -49,6 +49,7 @@ module.exports = async (req, res, next) => {
'lastPostTimestamp': null,
'webring': false,
'flags': {},
'assets': [],
'settings': {
name,
description,
@ -64,6 +65,7 @@ module.exports = async (req, res, next) => {
ensureDir(`${uploadDirectory}/json/${uri}`),
ensureDir(`${uploadDirectory}/banner/${uri}`),
ensureDir(`${uploadDirectory}/flag/${uri}`),
ensureDir(`${uploadDirectory}/asset/${uri}`),
]);
return res.redirect(`/${uri}/index.html`);

@ -0,0 +1,32 @@
'use strict';
const { remove } = require('fs-extra')
, dynamicResponse = require(__dirname+'/../../helpers/dynamic.js')
, uploadDirectory = require(__dirname+'/../../helpers/files/uploadDirectory.js')
, { Boards } = require(__dirname+'/../../db/')
, buildQueue = require(__dirname+'/../../queue.js');
module.exports = async (req, res, next) => {
const redirect = `/${req.params.board}/manage/assets.html`;
//delete file of all selected assets
await Promise.all(req.body.checkedassets.map(async filename => {
remove(`${uploadDirectory}/asset/${req.params.board}/${filename}`);
}));
//remove from db
const amount = await Boards.removeAssets(req.params.board, req.body.checkedassets);
//update res locals assets in memory
res.locals.board.assets = res.locals.board.assets.filter(asset => {
return !req.body.checkedassets.includes(asset);
});
return dynamicResponse(req, res, 200, 'message', {
'title': 'Success',
'message': `Deleted assets.`,
'redirect': redirect
});
}

@ -26,6 +26,7 @@ module.exports = async (uri, board) => {
remove(`${uploadDirectory}/json/${uri}/`), //json
remove(`${uploadDirectory}/banner/${uri}/`), //banners
remove(`${uploadDirectory}/flag/${uri}/`), //flags
remove(`${uploadDirectory}/asset/${uri}/`), //flags
]);
}

@ -1,7 +1,7 @@
{
"name": "jschan",
"version": "0.1.5",
"migrateVersion": "0.1.4",
"migrateVersion": "0.1.5",
"description": "",
"main": "server.js",
"dependencies": {

@ -1,8 +1,9 @@
include ./filelabel.pug
mixin fileform(name, max, total, addPath, deletePath, checkName, fileList, nameList, filePath, imageClass, showName)
mixin fileform(name, max, total, addPath, deletePath, checkName, fileList, nameList, filePath, imageClass, showName, showLink, formDescription)
- const capitalName = `${name.charAt(0).toUpperCase()}${name.substring(1)}`;
h4.no-m-p Add #{capitalName}s (Max #{total})
p #{formDescription}
.form-wrapper.flexleft.mt-10
form.form-post(action=addPath, enctype='multipart/form-data', method='POST')
input(type='hidden' name='_csrf' value=csrf)
@ -32,4 +33,6 @@ mixin fileform(name, max, total, addPath, deletePath, checkName, fileList, nameL
img(class=imageClass src=`${filePath}/${file}` loading='lazy')
if showName
small #{file.substring(0, file.lastIndexOf('.'))}
if showLink
a(href=`${filePath}/${file}`) Link
input(type='submit', value='delete')

@ -539,6 +539,15 @@ block content
.row
.label Total Flags Per Board
input(type='number' name='global_limits_flag_files_total' value=settings.globalLimits.flagFiles.total)
.row
.label Asset File Size Max
input(type='number' name='global_limits_asset_files_size_max' value=settings.globalLimits.assetFilesSize.max)
.row
.label Assets Per Upload Max
input(type='number' name='global_limits_asset_files_max' value=settings.globalLimits.assetFiles.max)
.row
.label Total Assets Per Board
input(type='number' name='global_limits_asset_files_total' value=settings.globalLimits.assetFiles.total)
.row
h4.mv-5 Webring

@ -14,9 +14,17 @@ block content
+fileform('banner', globalLimits.bannerFiles.max, globalLimits.bannerFiles.total,
`/forms/board/${board._id}/addbanners`, `/forms/board/${board._id}/deletebanners`,
'checkedbanners', board.banners, board.banners, `/banner/${board._id}`,
'board-banner', false)
'board-banner', false, false,
'Images randomly chosen and displayed at the top of most pages on the board')
hr(size=1)
+fileform('flag', globalLimits.flagFiles.max, globalLimits.flagFiles.total,
`/forms/board/${board._id}/addflags`, `/forms/board/${board._id}/deleteflags`,
'checkedflags', Object.values(board.flags), Object.keys(board.flags),
`/flag/${board._id}`, 'board-flag', true)
`/flag/${board._id}`, 'board-flag', true, false,
'Flags that can be applied to posts if custom flags are enabled in board settings')
hr(size=1)
+fileform('asset', globalLimits.assetFiles.max, globalLimits.assetFiles.total,
`/forms/board/${board._id}/addassets`, `/forms/board/${board._id}/deleteassets`,
'checkedassets', board.assets, board.assets,
`/asset/${board._id}`, 'board-asset', false, true,
'Arbitrary assets that can be used for other purposes e.g. custom CSS')

Loading…
Cancel
Save