diff --git a/.eslintignore b/.eslintignore index b7a6ad79cd..ed7af0301b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -18,3 +18,12 @@ logs/ .eslintrc test/files *.min.js + +/public/src/app.js +/public/src/client.js +/public/src/admin/admin.js +/public/src/modules/translator.common.js +/public/src/modules/pictureCropper.js +/public/src/modules/ace-editor.js +/public/src/client/account/header.js +/public/src/client/test.js \ No newline at end of file diff --git a/Gruntfile.js b/Gruntfile.js index d95d14f7fd..7c866dbe91 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -105,9 +105,9 @@ module.exports = function (grunt) { 'app.js', 'install/*.js', 'src/**/*.js', - 'public/src/modules/translator.js', - 'public/src/modules/helpers.js', - 'public/src/utils.js', + 'public/src/modules/translator.common.js', + 'public/src/modules/helpers.common.js', + 'public/src/utils.common.js', serverUpdated, '!src/upgrades/**', ], @@ -137,9 +137,10 @@ module.exports = function (grunt) { }); const build = require('./src/meta/build'); if (!grunt.option('skip')) { - await build.build(true); + await build.build(true, { webpack: false }); } run(); + await build.webpack({ watch: true }); done(); }); @@ -183,7 +184,7 @@ module.exports = function (grunt) { return run(); } - require('./src/meta/build').build([compiling], (err) => { + require('./src/meta/build').build([compiling], { webpack: false }, (err) => { if (err) { winston.error(err.stack); } diff --git a/install/package.json b/install/package.json index ed96c0a71d..c555e847a2 100644 --- a/install/package.json +++ b/install/package.json @@ -2,7 +2,7 @@ "name": "nodebb", "license": "GPL-3.0", "description": "NodeBB Forum", - "version": "1.19.7", + "version": "2.0.0-beta.1", "homepage": "http://www.nodebb.org", "repository": { "type": "git", @@ -60,6 +60,7 @@ "express": "4.18.0", "express-session": "1.17.2", "express-useragent": "1.0.15", + "file-loader": "6.2.0", "graceful-fs": "4.2.10", "helmet": "5.0.2", "html-to-text": "8.2.0", @@ -85,20 +86,20 @@ "multiparty": "4.2.3", "@nodebb/bootswatch": "3.4.2", "nconf": "0.12.0", - "nodebb-plugin-2factor": "3.0.7", - "nodebb-plugin-composer-default": "7.0.22", + "nodebb-plugin-2factor": "4.0.0", + "nodebb-plugin-composer-default": "8.0.0", "nodebb-plugin-dbsearch": "5.1.4", - "nodebb-plugin-emoji": "3.5.17", - "nodebb-plugin-emoji-android": "2.0.5", - "nodebb-plugin-markdown": "9.0.10", + "nodebb-plugin-emoji": "4.0.2", + "nodebb-plugin-emoji-android": "3.0.0", + "nodebb-plugin-markdown": "10.0.0", "nodebb-plugin-mentions": "3.0.8", - "nodebb-plugin-spam-be-gone": "0.8.1", + "nodebb-plugin-spam-be-gone": "1.0.0", "nodebb-rewards-essentials": "0.2.1", - "nodebb-theme-lavender": "5.3.2", - "nodebb-theme-persona": "11.4.4", - "nodebb-theme-slick": "1.4.23", + "nodebb-theme-lavender": "6.0.0", + "nodebb-theme-persona": "12.0.0", + "nodebb-theme-slick": "2.0.0", "nodebb-theme-vanilla": "12.1.17", - "nodebb-widget-essentials": "5.0.11", + "nodebb-widget-essentials": "6.0.0", "nodemailer": "6.7.4", "nprogress": "0.2.0", "passport": "0.5.2", @@ -112,7 +113,6 @@ "ioredis": "5.0.4", "request": "2.88.2", "request-promise-native": "1.0.9", - "requirejs": "2.3.6", "rimraf": "3.0.2", "rss": "1.2.2", "sanitize-html": "2.7.0", @@ -136,6 +136,8 @@ "uglify-es": "3.3.9", "validator": "13.7.0", "visibilityjs": "2.0.2", + "webpack": "^5.68.0", + "webpack-merge": "5.8.0", "winston": "3.7.2", "xml": "1.0.1", "xregexp": "5.1.0", diff --git a/install/web.js b/install/web.js index 53d7f732ee..d1609c45d3 100644 --- a/install/web.js +++ b/install/web.js @@ -8,7 +8,7 @@ const path = require('path'); const childProcess = require('child_process'); const less = require('less'); -const uglify = require('uglify-es'); +const webpack = require('webpack'); const nconf = require('nconf'); const Benchpress = require('benchpressjs'); @@ -46,16 +46,6 @@ winston.configure({ }); const web = module.exports; - -const scripts = [ - 'node_modules/jquery/dist/jquery.js', - 'node_modules/xregexp/xregexp-all.js', - 'public/src/modules/slugify.js', - 'public/src/utils.js', - 'public/src/installer/install.js', - 'node_modules/zxcvbn/dist/zxcvbn.js', -]; - let installing = false; let success = false; let error = false; @@ -68,6 +58,8 @@ web.install = async function (port) { winston.info(`Launching web installer on port ${port}`); app.use(express.static('public', {})); + app.use('/assets', express.static(path.join(__dirname, '../build/webpack'), {})); + app.engine('tpl', (filepath, options, callback) => { filepath = filepath.replace(/\.tpl$/, '.js'); @@ -82,7 +74,7 @@ web.install = async function (port) { await Promise.all([ compileTemplate(), compileLess(), - compileJS(), + runWebpack(), copyCSS(), loadDefaults(), ]); @@ -93,6 +85,13 @@ web.install = async function (port) { } }; +async function runWebpack() { + const util = require('util'); + const webpackCfg = require('../webpack.installer'); + const compiler = webpack(webpackCfg); + const webpackRun = util.promisify(compiler.run).bind(compiler); + await webpackRun(); +} function launchExpress(port) { server = app.listen(port, () => { @@ -255,23 +254,6 @@ async function compileLess() { } } -async function compileJS() { - let code = ''; - - for (const srcPath of scripts) { - // eslint-disable-next-line no-await-in-loop - const buffer = await fs.promises.readFile(path.join(__dirname, '..', srcPath)); - code += buffer.toString(); - } - const minified = uglify.minify(code, { - compress: false, - }); - if (!minified.code) { - throw new Error('[[error:failed-to-minify]]'); - } - await fs.promises.writeFile(path.join(__dirname, '../public/installer.min.js'), minified.code); -} - async function copyCSS() { const src = await fs.promises.readFile( path.join(__dirname, '../node_modules/bootstrap/dist/css/bootstrap.min.css'), 'utf8' diff --git a/public/language/en-GB/error.json b/public/language/en-GB/error.json index 957049d43b..67a40dcb20 100644 --- a/public/language/en-GB/error.json +++ b/public/language/en-GB/error.json @@ -69,6 +69,7 @@ "no-group": "Group does not exist", "no-user": "User does not exist", "no-teaser": "Teaser does not exist", + "no-flag": "Flag does not exist", "no-privileges": "You do not have enough privileges for this action.", "category-disabled": "Category disabled", diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index d526bd6e25..8156f1b1fd 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -38,6 +38,7 @@ "delete-post": "Delete Post", "purge-post": "Purge Post", "restore-post": "Restore Post", + "delete": "Delete Flag", "user-view": "View Profile", "user-edit": "Edit Profile", @@ -46,8 +47,10 @@ "add-note": "Add Note", "no-notes": "No shared notes.", "delete-note-confirm": "Are you sure you want to delete this flag note?", + "delete-flag-confirm": "Are you sure you want to delete this flag?", "note-added": "Note Added", "note-deleted": "Note Deleted", + "flag-deleted": "Flag Deleted", "history": "Account & Flag History", "no-history": "No flag history.", diff --git a/public/openapi/read/admin/manage/privileges/cid.yaml b/public/openapi/read/admin/manage/privileges/cid.yaml index 2625f3abef..2ef7c8d891 100644 --- a/public/openapi/read/admin/manage/privileges/cid.yaml +++ b/public/openapi/read/admin/manage/privileges/cid.yaml @@ -27,17 +27,13 @@ get: users: type: array items: - type: object - properties: - name: - type: string + type: string + description: Language key of the privilege name's user-friendly name groups: type: array items: - type: object - properties: - name: - type: string + type: string + description: Language key of the privilege name's user-friendly name keys: type: object properties: diff --git a/public/openapi/write/categories/cid/privileges.yaml b/public/openapi/write/categories/cid/privileges.yaml index 9cf239cbac..209b0ad051 100644 --- a/public/openapi/write/categories/cid/privileges.yaml +++ b/public/openapi/write/categories/cid/privileges.yaml @@ -30,19 +30,13 @@ get: users: type: array items: - type: object - properties: - name: - type: string - description: Language key of the privilege name's user-friendly name + type: string + description: Language key of the privilege name's user-friendly name groups: type: array items: - type: object - properties: - name: - type: string - description: Language key of the privilege name's user-friendly name + type: string + description: Language key of the privilege name's user-friendly name users: type: array items: diff --git a/public/openapi/write/categories/cid/privileges/privilege.yaml b/public/openapi/write/categories/cid/privileges/privilege.yaml index 9b00cc8573..1303640b19 100644 --- a/public/openapi/write/categories/cid/privileges/privilege.yaml +++ b/public/openapi/write/categories/cid/privileges/privilege.yaml @@ -47,19 +47,13 @@ put: users: type: array items: - type: object - properties: - name: - type: string - description: Language key of the privilege name's user-friendly name + type: string + description: Language key of the privilege name's user-friendly name groups: type: array items: - type: object - properties: - name: - type: string - description: Language key of the privilege name's user-friendly name + type: string + description: Language key of the privilege name's user-friendly name users: type: array items: @@ -164,19 +158,13 @@ delete: users: type: array items: - type: object - properties: - name: - type: string - description: Language key of the privilege name's user-friendly name + type: string + description: Language key of the privilege name's user-friendly name groups: type: array items: - type: object - properties: - name: - type: string - description: Language key of the privilege name's user-friendly name + type: string + description: Language key of the privilege name's user-friendly name users: type: array items: diff --git a/public/openapi/write/flags/flagId.yaml b/public/openapi/write/flags/flagId.yaml index d8194d71ea..7f30e92835 100644 --- a/public/openapi/write/flags/flagId.yaml +++ b/public/openapi/write/flags/flagId.yaml @@ -64,4 +64,32 @@ put: status: $ref: ../../components/schemas/Status.yaml#/Status response: - $ref: ../../components/schemas/FlagObject.yaml#/FlagHistoryObject \ No newline at end of file + $ref: ../../components/schemas/FlagObject.yaml#/FlagHistoryObject +delete: + tags: + - flags + summary: delete a flag + description: | + This operation deletes a flag. Unlike posts and topics, flags are not marked as deleted. + This deletion endpoint will purge the flag and all of its associated content from the database. + parameters: + - in: path + name: flagId + schema: + type: number + required: true + description: a valid flag id + example: 1 + responses: + '200': + description: Flag successfully deleted + content: + application/json: + schema: + type: object + properties: + status: + $ref: ../../components/schemas/Status.yaml#/Status + response: + type: object + properties: {} \ No newline at end of file diff --git a/public/openapi/write/flags/flagId/notes.yaml b/public/openapi/write/flags/flagId/notes.yaml index 46b95cf02d..1a52881d71 100644 --- a/public/openapi/write/flags/flagId/notes.yaml +++ b/public/openapi/write/flags/flagId/notes.yaml @@ -10,7 +10,7 @@ post: type: number required: true description: a valid flag id - example: 1 + example: 2 requestBody: required: true content: diff --git a/public/openapi/write/flags/flagId/notes/datetime.yaml b/public/openapi/write/flags/flagId/notes/datetime.yaml index 58bd67c8c1..bfd296b69b 100644 --- a/public/openapi/write/flags/flagId/notes/datetime.yaml +++ b/public/openapi/write/flags/flagId/notes/datetime.yaml @@ -10,7 +10,7 @@ delete: type: number required: true description: a valid flag id - example: 1 + example: 2 - in: path name: datetime schema: diff --git a/public/src/admin/admin.js b/public/src/admin/admin.js index a91572c009..ce35d5860a 100644 --- a/public/src/admin/admin.js +++ b/public/src/admin/admin.js @@ -1,5 +1,12 @@ 'use strict'; +require('../app'); + +// scripts-admin.js contains javascript files +// from plugins that add files to "acpScripts" block in plugin.json +// eslint-disable-next-line import/no-unresolved +require('../../scripts-admin'); + (function () { let logoutTimer = 0; let logoutMessage; @@ -36,6 +43,12 @@ hooks.on('action:ajaxify.end', () => { showCorrectNavTab(); startLogoutTimer(); + if ($('.settings').length) { + require(['admin/settings'], function (Settings) { + Settings.prepare(); + Settings.populateTOC(); + }); + } }); }); @@ -67,7 +80,7 @@ $(window).on('action:ajaxify.contentLoaded', function (ev, data) { selectMenuItem(data.url); setupRestartLinks(); - + require('material-design-lite'); componentHandler.upgradeDom(); }); @@ -227,10 +240,4 @@ }); }); } - - // tell ace to use the right paths when requiring modules - require(['ace/ace'], function (ace) { - ace.config.set('packaged', true); - ace.config.set('basePath', config.relative_path + '/assets/src/modules/ace/'); - }); }()); diff --git a/public/src/admin/appearance/customise.js b/public/src/admin/appearance/customise.js index d0a95050fd..5e9a943e79 100644 --- a/public/src/admin/appearance/customise.js +++ b/public/src/admin/appearance/customise.js @@ -9,36 +9,9 @@ define('admin/appearance/customise', ['admin/settings', 'ace/ace'], function (Se $('#customJS').text($('#customJS-holder').val()); $('#customHTML').text($('#customHTML-holder').val()); - const customCSS = ace.edit('customCSS'); - const customJS = ace.edit('customJS'); - const customHTML = ace.edit('customHTML'); - - customCSS.setTheme('ace/theme/twilight'); - customCSS.getSession().setMode('ace/mode/less'); - - customCSS.on('change', function () { - app.flags = app.flags || {}; - app.flags._unsaved = true; - $('#customCSS-holder').val(customCSS.getValue()); - }); - - customJS.setTheme('ace/theme/twilight'); - customJS.getSession().setMode('ace/mode/javascript'); - - customJS.on('change', function () { - app.flags = app.flags || {}; - app.flags._unsaved = true; - $('#customJS-holder').val(customJS.getValue()); - }); - - customHTML.setTheme('ace/theme/twilight'); - customHTML.getSession().setMode('ace/mode/html'); - - customHTML.on('change', function () { - app.flags = app.flags || {}; - app.flags._unsaved = true; - $('#customHTML-holder').val(customHTML.getValue()); - }); + initACE('customCSS', 'less', '#customCSS-holder'); + initACE('customJS', 'javascript', '#customJS-holder'); + initACE('customHTML', 'html', '#customHTML-holder'); $('#save').on('click', function () { if ($('#enableLiveReload').is(':checked')) { @@ -48,5 +21,20 @@ define('admin/appearance/customise', ['admin/settings', 'ace/ace'], function (Se }); }; + function initACE(aceElementId, mode, holder) { + const editorEl = ace.edit(aceElementId, { + mode: 'ace/mode/' + mode, + theme: 'ace/theme/twilight', + maxLines: 30, + minLines: 30, + fontSize: 14, + }); + editorEl.on('change', function () { + app.flags = app.flags || {}; + app.flags._unsaved = true; + $(holder).val(editorEl.getValue()); + }); + } + return Customise; }); diff --git a/public/src/admin/manage/categories.js b/public/src/admin/manage/categories.js index 6c2664a09a..3125f796bb 100644 --- a/public/src/admin/manage/categories.js +++ b/public/src/admin/manage/categories.js @@ -9,6 +9,7 @@ define('admin/manage/categories', [ 'bootbox', 'alerts', ], function (translator, Benchpress, categorySelector, api, Sortable, bootbox, alerts) { + Sortable = Sortable.default; const Categories = {}; let newCategoryId = -1; let sortables; diff --git a/public/src/ajaxify.js b/public/src/ajaxify.js index 3ca6c91f68..cb030b2558 100644 --- a/public/src/ajaxify.js +++ b/public/src/ajaxify.js @@ -1,8 +1,10 @@ 'use strict'; +const hooks = require('./modules/hooks'); +const { render } = require('./widgets'); -ajaxify = window.ajaxify || {}; - +window.ajaxify = window.ajaxify || {}; +ajaxify.widgets = { render: render }; (function () { let apiXHR = null; let ajaxifyTimer; @@ -13,11 +15,6 @@ ajaxify = window.ajaxify || {}; ajaxify.count = 0; ajaxify.currentPage = null; - let hooks; - require(['hooks'], function (_hooks) { - hooks = _hooks; - }); - ajaxify.go = function (url, callback, quiet) { // Automatically reconnect to socket and re-ajaxify on success if (!socket.connected) { @@ -358,18 +355,14 @@ ajaxify = window.ajaxify || {}; }; } if (typeof script === 'string') { - return function (next) { - require([script], function (module) { - // Hint: useful if you want to override a loaded library (e.g. replace core client-side logic), - // or call a method other than .init() - hooks.fire('static:script.init', { tpl_url, name: script, module }).then(() => { - if (module && module.init) { - module.init(); - } - next(); - }); - }, function () { - // ignore 404 error + return async function (next) { + const module = await app.require(script); + // Hint: useful if you want to override a loaded library (e.g. replace core client-side logic), + // or call a method other than .init() + hooks.fire('static:script.init', { tpl_url, name: script, module }).then(() => { + if (module && module.init) { + module.init(); + } next(); }); }; @@ -440,9 +433,20 @@ ajaxify = window.ajaxify || {}; }; ajaxify.loadTemplate = function (template, callback) { - require([config.asset_base_url + '/templates/' + template + '.js'], callback, function (err) { + $.ajax({ + url: `${config.asset_base_url}/templates/${template}.js`, + cache: false, + dataType: 'text', + success: function (script) { + // eslint-disable-next-line no-new-func + const renderFunction = new Function('module', script); + const moduleObj = { exports: {} }; + renderFunction(moduleObj); + callback(moduleObj.exports); + }, + }).fail(function () { console.error('Unable to load template: ' + template); - throw err; + callback(new Error('[[error:unable-to-load-template]]')); }); }; @@ -452,15 +456,11 @@ ajaxify = window.ajaxify || {}; translator.translate(`[[global:reconnecting-message, ${config.siteTitle}]]`); Benchpress.registerLoader(ajaxify.loadTemplate); Benchpress.setGlobal('config', config); + Benchpress.render('500', {}); // loads and caches the 500.tpl }); }()); $(document).ready(function () { - let hooks; - require(['hooks'], function (_hooks) { - hooks = _hooks; - }); - $(window).on('popstate', function (ev) { ev = ev.originalEvent; diff --git a/public/src/app.js b/public/src/app.js index 3542a4cb43..0d428fe578 100644 --- a/public/src/app.js +++ b/public/src/app.js @@ -1,5 +1,21 @@ 'use strict'; +window.$ = require('jquery'); + +window.jQuery = window.$; +require('bootstrap'); +window.bootbox = require('bootbox'); +require('jquery-form'); +window.utils = require('./utils'); +require('timeago'); + +const Visibility = require('visibilityjs'); +const Benchpress = require('benchpressjs'); +Benchpress.setGlobal('config', config); + +require('./sockets'); +require('./overrides'); +require('./ajaxify'); app = window.app || {}; @@ -8,24 +24,25 @@ app.currentRoom = null; app.widgets = {}; app.flags = {}; +document.addEventListener('DOMContentLoaded', async function () { + if (app.user.timeagoCode && app.user.timeagoCode !== 'en') { + await import(/* webpackChunkName: "timeago/[request]" */ 'timeago/locales/jquery.timeago.' + app.user.timeagoCode); + } + ajaxify.parseData(); + app.load(); +}); + (function () { let appLoaded = false; const isTouchDevice = utils.isTouchDevice(); app.cacheBuster = config['cache-buster']; - $(document).ready(function () { - ajaxify.parseData(); - app.load(); - }); - app.coldLoad = function () { if (appLoaded) { ajaxify.coldLoad(); } else { - $(window).on('action:app.load', function () { - ajaxify.coldLoad(); - }); + $(window).one('action:app.load', ajaxify.coldLoad); } }; @@ -82,46 +99,50 @@ app.flags = {}; 'taskbar', 'helpers', 'forum/pagination', - 'translator', 'messages', 'search', 'forum/header', 'hooks', - 'timeago/jquery.timeago', - ], function (taskbar, helpers, pagination, translator, messages, search, header, hooks) { + ], function (taskbar, helpers, pagination, messages, search, header, hooks) { header.prepareDOM(); - translator.prepareDOM(); taskbar.init(); helpers.register(); pagination.init(); search.init(); - - function finishLoad() { - hooks.fire('action:app.load'); - messages.show(); - appLoaded = true; - } overrides.overrideTimeago(); - if (app.user.timeagoCode && app.user.timeagoCode !== 'en') { - require(['timeago/locales/jquery.timeago.' + app.user.timeagoCode], finishLoad); - } else { - finishLoad(); - } + hooks.fire('action:app.load'); + messages.show(); + appLoaded = true; }); }; - app.require = async (modules) => { // allows you to await require.js modules + app.require = async function (modules) { const single = !Array.isArray(modules); if (single) { modules = [modules]; } - - return new Promise((resolve, reject) => { - require(modules, (...exports) => { - resolve(single ? exports.pop() : exports); - }, reject); - }); - }; + async function requireModule(moduleName) { + let _module; + try { + switch (moduleName) { + case 'bootbox': return require('bootbox'); + case 'benchpressjs': return require('benchpressjs'); + } + if (moduleName.startsWith('admin')) { + _module = await import(/* webpackChunkName: "admin/[request]" */ 'admin/' + moduleName.replace(/^admin\//, '')); + } else if (moduleName.startsWith('forum')) { + _module = await import(/* webpackChunkName: "forum/[request]" */ 'forum/' + moduleName.replace(/^forum\//, '')); + } else { + _module = await import(/* webpackChunkName: "modules/[request]" */ 'modules/' + moduleName); + } + } catch (err) { + console.warn(`error loading ${moduleName}\n${err.stack}`); + } + return _module && _module.default; + } + const result = await Promise.all(modules.map(requireModule)); + return single ? result.pop() : result; + } app.logout = function (redirect) { console.warn('[deprecated] app.logout is deprecated, please use logout module directly'); diff --git a/public/src/client.js b/public/src/client.js new file mode 100644 index 0000000000..74720c2f3f --- /dev/null +++ b/public/src/client.js @@ -0,0 +1,8 @@ +'use strict'; + +require('./app'); + +// scripts-client.js contains javascript files +// from plugins that add files to "scripts" block in plugin.json +// eslint-disable-next-line import/no-unresolved +require('../scripts-client'); diff --git a/public/src/client/account/edit/password.js b/public/src/client/account/edit/password.js index 408b1f72d0..85a44e2a36 100644 --- a/public/src/client/account/edit/password.js +++ b/public/src/client/account/edit/password.js @@ -21,23 +21,21 @@ define('forum/account/edit/password', [ let passwordsmatch = false; function onPasswordChanged() { - const passwordStrength = zxcvbn(password.val()); passwordvalid = false; - if (password.val().length < ajaxify.data.minimumPasswordLength) { - showError(password_notify, '[[reset_password:password_too_short]]'); - } else if (password.val().length > 512) { - showError(password_notify, '[[error:password-too-long]]'); - } else if (!utils.isPasswordValid(password.val())) { - showError(password_notify, '[[user:change_password_error]]'); - } else if (password.val() === ajaxify.data.username) { - showError(password_notify, '[[user:password_same_as_username]]'); - } else if (password.val() === ajaxify.data.email) { - showError(password_notify, '[[user:password_same_as_email]]'); - } else if (passwordStrength.score < ajaxify.data.minimumPasswordStrength) { - showError(password_notify, '[[user:weak_password]]'); - } else { + + try { + utils.assertPasswordValidity(password.val()); + + if (password.val() === ajaxify.data.username) { + throw new Error('[[user:password_same_as_username]]'); + } else if (password.val() === ajaxify.data.email) { + throw new Error('[[user:password_same_as_email]]'); + } + showSuccess(password_notify); passwordvalid = true; + } catch (err) { + showError(password_notify, err.message); } } diff --git a/public/src/client/flags/detail.js b/public/src/client/flags/detail.js index 644b7a1c35..7965614255 100644 --- a/public/src/client/flags/detail.js +++ b/public/src/client/flags/detail.js @@ -34,7 +34,6 @@ define('forum/flags/detail', [ } case 'appendNote': - // socket.emit('flags.appendNote', { api.post(`/flags/${ajaxify.data.flagId}/notes`, { note: noteEl.value, datetime: parseInt(noteEl.getAttribute('data-datetime'), 10), @@ -111,6 +110,18 @@ define('forum/flags/detail', [ textareaEl.focus(); break; } + + case 'delete-flag': { + bootbox.confirm('[[flags:delete-flag-confirm]]', function (ok) { + if (ok) { + api.delete(`/flags/${ajaxify.data.flagId}`, {}).then(() => { + alerts.success('[[flags:flag-deleted]]'); + ajaxify.go('flags'); + }).catch(alerts.error); + } + }); + break; + } } }); }; diff --git a/public/src/client/register.js b/public/src/client/register.js index 758f402feb..69bd6362f2 100644 --- a/public/src/client/register.js +++ b/public/src/client/register.js @@ -2,8 +2,8 @@ define('forum/register', [ - 'translator', 'zxcvbn', 'slugify', 'api', 'bootbox', 'forum/login', 'jquery-form', -], function (translator, zxcvbn, slugify, api, bootbox, Login) { + 'translator', 'slugify', 'api', 'bootbox', 'forum/login', 'jquery-form', +], function (translator, slugify, api, bootbox, Login) { const Register = {}; let validationError = false; const successIcon = ''; @@ -141,20 +141,17 @@ define('forum/register', [ function validatePassword(password, password_confirm) { const password_notify = $('#password-notify'); const password_confirm_notify = $('#password-confirm-notify'); - const passwordStrength = zxcvbn(password); - - if (password.length < ajaxify.data.minimumPasswordLength) { - showError(password_notify, '[[reset_password:password_too_short]]'); - } else if (password.length > 512) { - showError(password_notify, '[[error:password-too-long]]'); - } else if (!utils.isPasswordValid(password)) { - showError(password_notify, '[[user:change_password_error]]'); - } else if (password === $('#username').val()) { - showError(password_notify, '[[user:password_same_as_username]]'); - } else if (passwordStrength.score < ajaxify.data.minimumPasswordStrength) { - showError(password_notify, '[[user:weak_password]]'); - } else { + + try { + utils.assertPasswordValidity(password); + + if (password === $('#username').val()) { + throw new Error('[[user:password_same_as_username]]'); + } + showSuccess(password_notify, successIcon); + } catch (err) { + showError(password_notify, err.message); } if (password !== password_confirm && password_confirm !== '') { diff --git a/public/src/client/reset_code.js b/public/src/client/reset_code.js index 886b098846..a6e5fd0a55 100644 --- a/public/src/client/reset_code.js +++ b/public/src/client/reset_code.js @@ -1,7 +1,7 @@ 'use strict'; -define('forum/reset_code', ['zxcvbn', 'alerts'], function (zxcvbn, alerts) { +define('forum/reset_code', ['alerts'], function (alerts) { const ResetCode = {}; ResetCode.init = function () { @@ -12,20 +12,13 @@ define('forum/reset_code', ['zxcvbn', 'alerts'], function (zxcvbn, alerts) { const repeat = $('#repeat'); resetEl.on('click', function () { - const strength = zxcvbn(password.val()); - if (password.val().length < ajaxify.data.minimumPasswordLength) { - $('#notice').removeClass('hidden'); - $('#notice strong').translateText('[[reset_password:password_too_short]]'); - } else if (password.val().length > 512) { - $('#notice').removeClass('hidden'); - $('#notice strong').translateText('[[error:password-too-long]]'); - } else if (password.val() !== repeat.val()) { - $('#notice').removeClass('hidden'); - $('#notice strong').translateText('[[reset_password:passwords_do_not_match]]'); - } else if (strength.score < ajaxify.data.minimumPasswordStrength) { - $('#notice').removeClass('hidden'); - $('#notice strong').translateText('[[user:weak_password]]'); - } else { + try { + utils.assertPasswordValidity(password.val()); + + if (password.val() !== repeat.val()) { + throw new Error('[[reset_password:passwords_do_not_match]]'); + } + resetEl.prop('disabled', true).translateHtml(' [[reset_password:changing_password]]'); socket.emit('user.reset.commit', { code: reset_code, @@ -38,7 +31,11 @@ define('forum/reset_code', ['zxcvbn', 'alerts'], function (zxcvbn, alerts) { window.location.href = config.relative_path + '/login'; }); + } catch (err) { + $('#notice').removeClass('hidden'); + $('#notice strong').translateText(err.message); } + return false; }); }; diff --git a/public/src/client/topic.js b/public/src/client/topic.js index 636a9d7593..0c111bcf7f 100644 --- a/public/src/client/topic.js +++ b/public/src/client/topic.js @@ -155,14 +155,27 @@ define('forum/topic', [ }); } + Topic.applyDropup = function () { + const containerRect = this.getBoundingClientRect(); + const dropdownEl = this.querySelector('.dropdown-menu'); + const dropdownStyle = window.getComputedStyle(dropdownEl); + const dropdownHeight = dropdownStyle.getPropertyValue('height').slice(0, -2); + const offset = document.documentElement.style.getPropertyValue('--panel-offset').slice(0, -2); + + // Toggler position (including its height, since the menu spawns above it), + // minus the dropdown's height and navbar offset + const dropUp = (containerRect.top + containerRect.height - dropdownHeight - offset) > 0; + this.classList.toggle('dropup', dropUp); + }; + function addDropupHandler() { // Locate all dropdowns const target = $('#content .dropdown-menu').parent(); - - // Toggle dropup if past 50% of screen - $(target).on('show.bs.dropdown', function () { - const dropUp = this.getBoundingClientRect().top > ($(window).height() / 2); - $(this).toggleClass('dropup', dropUp); + $(target).on('shown.bs.dropdown', function () { + const dropdownEl = this.querySelector('.dropdown-menu'); + if (dropdownEl.innerHTML) { + Topic.applyDropup.call(this); + } }); } diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js index 51146641ca..8b46eb3485 100644 --- a/public/src/client/topic/postTools.js +++ b/public/src/client/topic/postTools.js @@ -32,6 +32,7 @@ define('forum/topic/postTools', [ function renderMenu() { $('[component="topic"]').on('show.bs.dropdown', '.moderator-tools', function () { + const self = this; const $this = $(this); const dropdownMenu = $this.find('.dropdown-menu'); if (dropdownMenu.html()) { @@ -41,19 +42,23 @@ define('forum/topic/postTools', [ const pid = postEl.attr('data-pid'); const index = parseInt(postEl.attr('data-index'), 10); - socket.emit('posts.loadPostTools', { pid: pid, cid: ajaxify.data.cid }, function (err, data) { + socket.emit('posts.loadPostTools', { pid: pid, cid: ajaxify.data.cid }, async (err, data) => { if (err) { return alerts.error(err); } data.posts.display_move_tools = data.posts.display_move_tools && index !== 0; - app.parseAndTranslate('partials/topic/post-menu-list', data, function (html) { - dropdownMenu.html(html); - require(['clipboard'], function (clipboard) { - new clipboard('[data-clipboard-text]'); - }); - hooks.fire('action:post.tools.load'); - }); + const html = await app.parseAndTranslate('partials/topic/post-menu-list', data); + const clipboard = require('clipboard'); + // eslint-disable-next-line import/no-unresolved + const topic = require('forum/topic'); + + dropdownMenu.html(html); + dropdownMenu.get(0).classList.toggle('hidden', false); + topic.applyDropup.call(self); + new clipboard('[data-clipboard-text]'); + + hooks.fire('action:post.tools.load'); }); }); } diff --git a/public/src/client/users.js b/public/src/client/users.js index b7f077fdfb..e0d70748e4 100644 --- a/public/src/client/users.js +++ b/public/src/client/users.js @@ -11,7 +11,7 @@ define('forum/users', [ Users.init = function () { app.enterRoom('user_list'); - const section = utils.params().section ? ('?section=' + utils.params().section) : ''; + const section = utils.param('section') ? ('?section=' + utils.param('section')) : ''; $('.nav-pills li').removeClass('active').find('a[href="' + window.location.pathname + section + '"]').parent() .addClass('active'); @@ -115,7 +115,7 @@ define('forum/users', [ } function getActiveSection() { - return utils.params().section || ''; + return utils.param('section') || ''; } return Users; diff --git a/public/src/installer/install.js b/public/src/installer/install.js index 48b1d1eba7..8dcf8f211d 100644 --- a/public/src/installer/install.js +++ b/public/src/installer/install.js @@ -1,6 +1,11 @@ +/* eslint-disable no-redeclare */ + 'use strict'; -/* global zxcvbn, slugify */ +const $ = require('jquery'); +const zxcvbn = require('zxcvbn'); +const utils = require('../utils'); +const slugify = require('../modules/slugify'); $('document').ready(function () { setupInputs(); diff --git a/public/src/modules/ace-editor.js b/public/src/modules/ace-editor.js new file mode 100644 index 0000000000..7c88771a9f --- /dev/null +++ b/public/src/modules/ace-editor.js @@ -0,0 +1,20 @@ +export * from 'ace-builds'; + +// only import the modes and theme we use +import 'ace-builds/src-noconflict/mode-javascript'; +import 'ace-builds/src-noconflict/mode-less'; +import 'ace-builds/src-noconflict/mode-html'; +import 'ace-builds/src-noconflict/ext-searchbox'; +import 'ace-builds/src-noconflict/theme-twilight'; + +/* eslint-disable import/no-webpack-loader-syntax */ +/* eslint-disable import/no-unresolved */ +import htmlWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-html'; +import javascriptWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-javascript'; +import cssWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-css'; + +ace.config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl); +ace.config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl); +ace.config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl); + + diff --git a/public/src/modules/autocomplete.js b/public/src/modules/autocomplete.js index e28e59bf16..a8f1e832e4 100644 --- a/public/src/modules/autocomplete.js +++ b/public/src/modules/autocomplete.js @@ -1,121 +1,120 @@ - 'use strict'; - define('autocomplete', ['api', 'alerts'], function (api, alerts) { const module = {}; + const _default = { + delay: 200, + }; + + module.init = (params) => { + const { input, source, onSelect, delay } = { ..._default, ...params }; - module.user = function (input, params, onselect) { - if (typeof params === 'function') { - onselect = params; - params = {}; - } - params = params || {}; app.loadJQueryUI(function () { input.autocomplete({ - delay: 200, + delay, open: function () { $(this).autocomplete('widget').css('z-index', 100005); }, select: function (event, ui) { - handleOnSelect(input, onselect, event, ui); + handleOnSelect(input, onSelect, event, ui); }, - source: function (request, response) { - params.query = request.term; + source, + }); + }); + }; - api.get('/api/users', params, function (err, result) { - if (err) { - return alerts.error(err); - } + module.user = function (input, params, onSelect) { + if (typeof params === 'function') { + onSelect = params; + params = {}; + } + params = params || {}; - if (result && result.users) { - const names = result.users.map(function (user) { - const username = $('
').html(user.username).text(); - return user && { - label: username, - value: username, - user: { - uid: user.uid, - name: user.username, - slug: user.userslug, - username: user.username, - userslug: user.userslug, - picture: user.picture, - banned: user.banned, - 'icon:text': user['icon:text'], - 'icon:bgColor': user['icon:bgColor'], - }, - }; - }); - response(names); - } + module.init({ + input, + onSelect, + source: (request, response) => { + params.query = request.term; - $('.ui-autocomplete a').attr('data-ajaxify', 'false'); - }); - }, - }); + api.get('/api/users', params, function (err, result) { + if (err) { + return alerts.error(err); + } + + if (result && result.users) { + const names = result.users.map(function (user) { + const username = $('').html(user.username).text(); + return user && { + label: username, + value: username, + user: { + uid: user.uid, + name: user.username, + slug: user.userslug, + username: user.username, + userslug: user.userslug, + picture: user.picture, + banned: user.banned, + 'icon:text': user['icon:text'], + 'icon:bgColor': user['icon:bgColor'], + }, + }; + }); + response(names); + } + + $('.ui-autocomplete a').attr('data-ajaxify', 'false'); + }); + }, }); }; - module.group = function (input, onselect) { - app.loadJQueryUI(function () { - input.autocomplete({ - delay: 200, - open: function () { - $(this).autocomplete('widget').css('z-index', 100005); - }, - select: function (event, ui) { - handleOnSelect(input, onselect, event, ui); - }, - source: function (request, response) { - socket.emit('groups.search', { - query: request.term, - }, function (err, results) { - if (err) { - return alerts.error(err); - } - if (results && results.length) { - const names = results.map(function (group) { - return group && { - label: group.name, - value: group.name, - group: group, - }; - }); - response(names); - } - $('.ui-autocomplete a').attr('data-ajaxify', 'false'); - }); - }, - }); + module.group = function (input, onSelect) { + module.init({ + input, + onSelect, + source: (request, response) => { + socket.emit('groups.search', { + query: request.term, + }, function (err, results) { + if (err) { + return alerts.error(err); + } + if (results && results.length) { + const names = results.map(function (group) { + return group && { + label: group.name, + value: group.name, + group: group, + }; + }); + response(names); + } + $('.ui-autocomplete a').attr('data-ajaxify', 'false'); + }); + }, }); }; - module.tag = function (input, onselect) { - app.loadJQueryUI(function () { - input.autocomplete({ - delay: 100, - open: function () { - $(this).autocomplete('widget').css('z-index', 20000); - }, - select: function (event, ui) { - handleOnSelect(input, onselect, event, ui); - }, - source: function (request, response) { - socket.emit('topics.autocompleteTags', { - query: request.term, - cid: ajaxify.data.cid || 0, - }, function (err, tags) { - if (err) { - return alerts.error(err); - } - if (tags) { - response(tags); - } - $('.ui-autocomplete a').attr('data-ajaxify', 'false'); - }); - }, - }); + module.tag = function (input, onSelect) { + module.init({ + input, + onSelect, + delay: 100, + source: (request, response) => { + socket.emit('topics.autocompleteTags', { + query: request.term, + cid: ajaxify.data.cid || 0, + }, function (err, tags) { + if (err) { + return alerts.error(err); + } + if (tags) { + response(tags); + } + $('.ui-autocomplete a').attr('data-ajaxify', 'false'); + }); + }, }); }; diff --git a/public/src/modules/helpers.common.js b/public/src/modules/helpers.common.js new file mode 100644 index 0000000000..706c44c6fa --- /dev/null +++ b/public/src/modules/helpers.common.js @@ -0,0 +1,347 @@ +'use strict'; + +module.exports = function (utils, Benchpress, relative_path) { + Benchpress.setGlobal('true', true); + Benchpress.setGlobal('false', false); + + const helpers = { + displayMenuItem, + buildMetaTag, + buildLinkTag, + stringify, + escape, + stripTags, + generateCategoryBackground, + generateChildrenCategories, + generateTopicClass, + membershipBtn, + spawnPrivilegeStates, + localeToHTML, + renderTopicImage, + renderTopicEvents, + renderEvents, + renderDigestAvatar, + userAgentIcons, + buildAvatar, + register, + __escape: identity, + }; + + function identity(str) { + return str; + } + + function displayMenuItem(data, index) { + const item = data.navigation[index]; + if (!item) { + return false; + } + + if (item.route.match('/users') && data.user && !data.user.privileges['view:users']) { + return false; + } + + if (item.route.match('/tags') && data.user && !data.user.privileges['view:tags']) { + return false; + } + + if (item.route.match('/groups') && data.user && !data.user.privileges['view:groups']) { + return false; + } + + return true; + } + + function buildMetaTag(tag) { + const name = tag.name ? 'name="' + tag.name + '" ' : ''; + const property = tag.property ? 'property="' + tag.property + '" ' : ''; + const content = tag.content ? 'content="' + tag.content.replace(/\n/g, ' ') + '" ' : ''; + + return '\n\t'; + } + + function buildLinkTag(tag) { + const attributes = ['link', 'rel', 'as', 'type', 'href', 'sizes', 'title', 'crossorigin']; + const [link, rel, as, type, href, sizes, title, crossorigin] = attributes.map(attr => (tag[attr] ? `${attr}="${tag[attr]}" ` : '')); + + return '\n\t'; + } + + function stringify(obj) { + // Turns the incoming object into a JSON string + return JSON.stringify(obj).replace(/&/gm, '&').replace(//gm, '>') + .replace(/"/g, '"'); + } + + function escape(str) { + return utils.escapeHTML(str); + } + + function stripTags(str) { + return utils.stripHTMLTags(str); + } + + function generateCategoryBackground(category) { + if (!category) { + return ''; + } + const style = []; + + if (category.bgColor) { + style.push('background-color: ' + category.bgColor); + } + + if (category.color) { + style.push('color: ' + category.color); + } + + if (category.backgroundImage) { + style.push('background-image: url(' + category.backgroundImage + ')'); + if (category.imageClass) { + style.push('background-size: ' + category.imageClass); + } + } + + return style.join('; ') + ';'; + } + + function generateChildrenCategories(category) { + let html = ''; + if (!category || !category.children || !category.children.length) { + return html; + } + category.children.forEach(function (child) { + if (child && !child.isSection) { + const link = child.link ? child.link : (relative_path + '/category/' + child.slug); + html += '' + + '