From d20b07cfeabc7354f48f4e96f86278cff5939141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Fri, 29 Apr 2022 21:39:33 -0400 Subject: [PATCH] Webpack5 (#10311) * feat: webpack 5 part 1 * fix: gruntfile fixes * fix: fix taskbar warning add app.importScript copy public/src/modules to build folder * refactor: remove commented old code * feat: reenable admin * fix: acp settings pages, fix sortable on manage categories embedded require in html not allowed * fix: bundle serialize/deserizeli so plugins dont break * test: fixe util tests * test: fix require path * test: more test fixes * test: require correct utils module * test: require correct utils * test: log stack * test: fix db require blowing up tests * test: move and disable bundle test * refactor: add aliases * test: disable testing route * fix: move webpack modules necessary for build, into `dependencies` * test: fix one more test remove 500-embed.tpl * fix: restore use of assets/nodebb.min.js, at least for now * fix: remove unnecessary line break * fix: point to proper ACP bundle * test: maybe fix build test * test: composer * refactor: dont need dist * refactor: more cleanup use everything from build/public folder * get rid of conditional import in app.js * fix: ace * refactor: cropper alias * test: lint and test fixes * lint: fix * refactor: rename function to app.require * refactor: go back to using app.require * chore: use github branch * chore: use webpack branch * feat: webpack webinstaller * feat: add chunkFile name with contenthash * refactor: move hooks to top * refactor: get rid of template500Function * fix(deps): use webpack5 branch of 2factor plugin * chore: tagging v2.0.0-beta.0 pre-release version :boom: :shipit: :tada: :rocket: * refactor: disable cache on templates loadTemplate is called once by benchpress and the result is cache internally * refactor: add server side helpers.js * feat: deprecate /plugins shorthand route, closes #10343 * refactor: use build/public for webpack * test: fix filename * fix: more specific selector * lint: ignore * refactor: fix comments * test: add debug for random failing test * refactor: cleanup remove test page, remove dupe functions in utils.common * lint: use relative path for now * chore: bump prerelease version * feat: add translateKeys * fix: optional params * fix: get rid of extra timeago files * refactor: cleanup, require timeago locale earlier remove translator.prepareDOM, it is in header.tpl html tag * refactor: privileges system to use a Map in the backend instead of separate objects for keys and labels (#10378) * refactor: privileges system to use a Map in the backend instead of separate objects for keys and labels - Existing hooks are preserved (to be deprecated at a later date, possibly) - New init hooks are called on NodeBB start, and provide a one-stop shop to add new privileges, instead of having to add to four different hooks * docs: fix typo in comment * test: spec changes * refactor: privileges system to use a Map in the backend instead of separate objects for keys and labels (#10378) * refactor: privileges system to use a Map in the backend instead of separate objects for keys and labels - Existing hooks are preserved (to be deprecated at a later date, possibly) - New init hooks are called on NodeBB start, and provide a one-stop shop to add new privileges, instead of having to add to four different hooks * docs: fix typo in comment * test: spec changes * feat: allow app.require('bootbox'/'benchpressjs') * refactor: require server side utils * test: jquery ready * change istaller to use build/public * test: use document.addEventListener * refactor: closes #10301 * refactor: generateTopicClass * fix: column counts for other privileges * fix: #10443, regression where sorted-list items did not render into the DOM in the predicted order [breaking] * fix: typo in hook name * refactor: introduce a generic autocomplete.init() method that can be called to add nodebb-style autocompletion but using different data sources (e.g. not user/groups/tags) * fix: crash if `delay` not passed in (as it cannot be destructured) * refactor: replace substr * feat: set --panel-offset style in html element based on stored value in localStorage * refactor: addDropupHandler() logic to be less naive - Take into account height of the menu - Don't apply dropUp logic if there's nothing in the dropdown - Remove 'hidden' class (added by default in Persona for post tools) when menu items are added closes #10423 * refactor: simplify utils.params [breaking] Retrospective analysis of the usage of this method suggests that the options passed in are superfluous, and that only `url` is required. Using a browser built-in makes more sense to accomplish what this method sets out to do. * feat: add support for returning full URLSearchParams for utils.params * fix: utils.params() fallback handling * fix: default empty obj for params() * fix: remove \'loggedin\' and \'register\' qs parameters once they have been used, delay invocation of messages until ajaxify.end * fix: utils.params() not allowing relative paths to be passed in * refactor(DRY): new assertPasswordValidity utils method * fix: incorrect error message returned on insufficient privilege on flag edit * fix: read/update/delete access to flags API should be limited for moderators to only post flags in categories they moderate - added failing tests and patched up middleware.assert.flags to fix * refactor: flag api v3 tests to create new post and flags on every round * fix: missing error:no-flag language key * refactor: flags.canView to check flag existence, simplify middleware.assert.flag * feat: flag deletion API endpoint, #10426 * feat: UI for flag deletion, closes #10426 * chore: update plugin versions * chore: up emoji * chore: update markdown * chore: up emoji-android * fix: regression caused by utils.params() refactor, supports arrays and pipes all values through utils.toType, adjusts tests to type check Co-authored-by: Julian Lam --- .eslintignore | 9 + Gruntfile.js | 11 +- install/package.json | 26 +- install/web.js | 40 +- public/language/en-GB/error.json | 1 + public/language/en-GB/flags.json | 3 + .../read/admin/manage/privileges/cid.yaml | 12 +- .../write/categories/cid/privileges.yaml | 14 +- .../categories/cid/privileges/privilege.yaml | 28 +- public/openapi/write/flags/flagId.yaml | 30 +- public/openapi/write/flags/flagId/notes.yaml | 2 +- .../write/flags/flagId/notes/datetime.yaml | 2 +- public/src/admin/admin.js | 21 +- public/src/admin/appearance/customise.js | 48 +- public/src/admin/manage/categories.js | 1 + public/src/ajaxify.js | 52 +- public/src/app.js | 83 +- public/src/client.js | 8 + public/src/client/account/edit/password.js | 26 +- public/src/client/flags/detail.js | 13 +- public/src/client/register.js | 27 +- public/src/client/reset_code.js | 27 +- public/src/client/topic.js | 23 +- public/src/client/topic/postTools.js | 21 +- public/src/client/users.js | 4 +- public/src/installer/install.js | 7 +- public/src/modules/ace-editor.js | 20 + public/src/modules/autocomplete.js | 193 ++-- public/src/modules/helpers.common.js | 347 ++++++++ public/src/modules/helpers.js | 357 +------- public/src/modules/messages.js | 23 +- public/src/modules/notifications.js | 2 +- public/src/modules/pictureCropper.js | 10 +- public/src/modules/settings.js | 22 +- public/src/modules/taskbar.js | 39 +- public/src/modules/translator.common.js | 633 +++++++++++++ public/src/modules/translator.js | 684 +------------- public/src/overrides.js | 151 ++-- public/src/require-config.js | 13 - public/src/sockets.js | 7 +- public/src/utils.common.js | 759 ++++++++++++++++ public/src/utils.js | 839 ++---------------- public/src/widgets.js | 83 +- src/api/flags.js | 2 +- src/cli/index.js | 1 + src/controllers/admin/settings.js | 2 +- src/controllers/write/flags.js | 5 + src/flags.js | 23 +- src/helpers.js | 7 + src/meta/build.js | 53 +- src/meta/js.js | 263 +----- src/middleware/admin.js | 2 +- src/middleware/assert.js | 5 +- src/plugins/data.js | 3 +- src/privileges/admin.js | 68 +- src/privileges/categories.js | 91 +- src/privileges/global.js | 86 +- src/privileges/index.js | 6 + src/routes/index.js | 6 +- src/routes/write/flags.js | 1 + src/topics/posts.js | 2 +- src/translator.js | 11 +- src/user/info.js | 2 +- src/utils.js | 16 +- src/views/500-embed.tpl | 14 - src/views/admin/advanced/cache.tpl | 7 +- src/views/admin/development/logger.tpl | 20 +- src/views/admin/header.tpl | 2 +- .../admin/partials/privileges/category.tpl | 12 +- .../admin/partials/privileges/global.tpl | 12 +- src/views/admin/partials/settings/footer.tpl | 7 - src/views/admin/settings/homepage.tpl | 11 +- src/views/install/index.tpl | 2 +- src/views/partials/footer/js.tpl | 2 + src/webserver.js | 4 +- test/api.js | 3 +- test/build.js | 29 +- test/controllers.js | 76 +- test/flags.js | 164 ++++ test/helpers/index.js | 2 +- test/messaging.js | 2 +- test/mocks/databasemock.js | 1 + test/posts.js | 3 +- test/posts/uploads.js | 2 +- test/template-helpers.js | 2 +- test/translator.js | 2 +- test/user/uploads.js | 2 +- test/utils.js | 73 +- webpack.common.js | 61 ++ webpack.dev.js | 9 + webpack.installer.js | 24 + webpack.prod.js | 8 + 92 files changed, 3009 insertions(+), 2923 deletions(-) create mode 100644 public/src/client.js create mode 100644 public/src/modules/ace-editor.js create mode 100644 public/src/modules/helpers.common.js create mode 100644 public/src/modules/translator.common.js delete mode 100644 public/src/require-config.js create mode 100644 public/src/utils.common.js create mode 100644 src/helpers.js delete mode 100644 src/views/500-embed.tpl create mode 100644 webpack.common.js create mode 100644 webpack.dev.js create mode 100644 webpack.installer.js create mode 100644 webpack.prod.js 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 += '' + + '' + + '' + child.name + ''; + } + }); + html = html ? ('' + html + '') : html; + return html; + } + + function generateTopicClass(topic) { + const fields = ['locked', 'pinned', 'deleted', 'unread', 'scheduled']; + return fields.filter(field => !!topic[field]).join(' '); + } + + // Groups helpers + function membershipBtn(groupObj) { + if (groupObj.isMember && groupObj.name !== 'administrators') { + return ''; + } + + if (groupObj.isPending && groupObj.name !== 'administrators') { + return ''; + } else if (groupObj.isInvited) { + return ''; + } else if (!groupObj.disableJoinRequests && groupObj.name !== 'administrators') { + return ''; + } + return ''; + } + + function spawnPrivilegeStates(member, privileges) { + const states = []; + for (const priv in privileges) { + if (privileges.hasOwnProperty(priv)) { + states.push({ + name: priv, + state: privileges[priv], + }); + } + } + return states.map(function (priv) { + const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create']; + const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups']; + const globalModDisabled = ['groups:moderate']; + const disabled = + (member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) || + (member === 'spiders' && !spidersEnabled.includes(priv.name)) || + (member === 'Global Moderators' && globalModDisabled.includes(priv.name)); + + return ''; + }).join(''); + } + + function localeToHTML(locale, fallback) { + locale = locale || fallback || 'en-GB'; + return locale.replace('_', '-'); + } + + function renderTopicImage(topicObj) { + if (topicObj.thumb) { + return ''; + } + return ''; + } + + function renderTopicEvents(index, sort) { + if (sort === 'most_votes') { + return ''; + } + const start = this.posts[index].eventStart; + const end = this.posts[index].eventEnd; + const events = this.events.filter(event => event.timestamp >= start && event.timestamp < end); + if (!events.length) { + return ''; + } + + return renderEvents.call(this, events); + } + + function renderEvents(events) { + return events.reduce((html, event) => { + html += `
  • +
    + +
    + + ${event.href ? `${event.text}` : event.text}  + + `; + + if (event.user) { + if (!event.user.system) { + html += `${buildAvatar(event.user, 'xs', true)} ${event.user.username} `; + } else { + html += `[[global:system-user]] `; + } + } + + html += ``; + + if (this.privileges.isAdminOrMod) { + html += ` `; + } + + return html; + }, ''); + } + + function renderDigestAvatar(block) { + if (block.teaser) { + if (block.teaser.user.picture) { + return ''; + } + return '
    ' + block.teaser.user['icon:text'] + '
    '; + } + if (block.user.picture) { + return ''; + } + return '
    ' + block.user['icon:text'] + '
    '; + } + + function userAgentIcons(data) { + let icons = ''; + + switch (data.platform) { + case 'Linux': + icons += ''; + break; + case 'Microsoft Windows': + icons += ''; + break; + case 'Apple Mac': + icons += ''; + break; + case 'Android': + icons += ''; + break; + case 'iPad': + icons += ''; + break; + case 'iPod': // intentional fall-through + case 'iPhone': + icons += ''; + break; + default: + icons += ''; + break; + } + + switch (data.browser) { + case 'Chrome': + icons += ''; + break; + case 'Firefox': + icons += ''; + break; + case 'Safari': + icons += ''; + break; + case 'IE': + icons += ''; + break; + case 'Edge': + icons += ''; + break; + default: + icons += ''; + break; + } + + return icons; + } + + function buildAvatar(userObj, size, rounded, classNames, component) { + /** + * userObj requires: + * - uid, picture, icon:bgColor, icon:text (getUserField w/ "picture" should return all 4), username + * size: one of "xs", "sm", "md", "lg", or "xl" (required), or an integer + * rounded: true or false (optional, default false) + * classNames: additional class names to prepend (optional, default none) + * component: overrides the default component (optional, default none) + */ + + // Try to use root context if passed-in userObj is undefined + if (!userObj) { + userObj = this; + } + + const attributes = [ + 'alt="' + userObj.username + '"', + 'title="' + userObj.username + '"', + 'data-uid="' + userObj.uid + '"', + 'loading="lazy"', + ]; + const styles = []; + classNames = classNames || ''; + + // Validate sizes, handle integers, otherwise fall back to `avatar-sm` + if (['xs', 'sm', 'sm2x', 'md', 'lg', 'xl'].includes(size)) { + classNames += ' avatar-' + size; + } else if (!isNaN(parseInt(size, 10))) { + styles.push('width: ' + size + 'px;', 'height: ' + size + 'px;', 'line-height: ' + size + 'px;', 'font-size: ' + (parseInt(size, 10) / 16) + 'rem;'); + } else { + classNames += ' avatar-sm'; + } + attributes.unshift('class="avatar ' + classNames + (rounded ? ' avatar-rounded' : '') + '"'); + + // Component override + if (component) { + attributes.push('component="' + component + '"'); + } else { + attributes.push('component="avatar/' + (userObj.picture ? 'picture' : 'icon') + '"'); + } + + if (userObj.picture) { + return ''; + } + + styles.push('background-color: ' + userObj['icon:bgColor'] + ';'); + return '' + userObj['icon:text'] + ''; + } + + function register() { + Object.keys(helpers).forEach(function (helperName) { + Benchpress.registerHelper(helperName, helpers[helperName]); + }); + } + + return helpers; +}; diff --git a/public/src/modules/helpers.js b/public/src/modules/helpers.js index b53377c88f..f471d869b6 100644 --- a/public/src/modules/helpers.js +++ b/public/src/modules/helpers.js @@ -1,356 +1,7 @@ 'use strict'; -(function (factory) { - if (typeof module === 'object' && module.exports) { - const relative_path = require('nconf').get('relative_path'); - module.exports = factory(require('../utils'), require('benchpressjs'), relative_path); - } else if (typeof define === 'function' && define.amd) { - define('helpers', ['benchpress'], function (Benchpress) { - return factory(utils, Benchpress, config.relative_path); - }); - } -}(function (utils, Benchpress, relative_path) { - Benchpress.setGlobal('true', true); - Benchpress.setGlobal('false', false); +const factory = require('./helpers.common'); - 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 += '' + - '' + - '' + child.name + ''; - } - }); - html = html ? ('' + html + '') : html; - return html; - } - - function generateTopicClass(topic) { - const fields = ['locked', 'pinned', 'deleted', 'unread', 'scheduled']; - return fields.filter(field => !!topic[field]).join(' '); - } - - // Groups helpers - function membershipBtn(groupObj) { - if (groupObj.isMember && groupObj.name !== 'administrators') { - return ''; - } - - if (groupObj.isPending && groupObj.name !== 'administrators') { - return ''; - } else if (groupObj.isInvited) { - return ''; - } else if (!groupObj.disableJoinRequests && groupObj.name !== 'administrators') { - return ''; - } - return ''; - } - - function spawnPrivilegeStates(member, privileges) { - const states = []; - for (const priv in privileges) { - if (privileges.hasOwnProperty(priv)) { - states.push({ - name: priv, - state: privileges[priv], - }); - } - } - return states.map(function (priv) { - const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create']; - const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups']; - const globalModDisabled = ['groups:moderate']; - const disabled = - (member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) || - (member === 'spiders' && !spidersEnabled.includes(priv.name)) || - (member === 'Global Moderators' && globalModDisabled.includes(priv.name)); - - return ''; - }).join(''); - } - - function localeToHTML(locale, fallback) { - locale = locale || fallback || 'en-GB'; - return locale.replace('_', '-'); - } - - function renderTopicImage(topicObj) { - if (topicObj.thumb) { - return ''; - } - return ''; - } - - function renderTopicEvents(index, sort) { - if (sort === 'most_votes') { - return ''; - } - const start = this.posts[index].eventStart; - const end = this.posts[index].eventEnd; - const events = this.events.filter(event => event.timestamp >= start && event.timestamp < end); - if (!events.length) { - return ''; - } - - return renderEvents.call(this, events); - } - - function renderEvents(events) { - return events.reduce((html, event) => { - html += `
  • -
    - -
    - - ${event.href ? `${event.text}` : event.text}  - - `; - - if (event.user) { - if (!event.user.system) { - html += `${buildAvatar(event.user, 'xs', true)} ${event.user.username} `; - } else { - html += `[[global:system-user]] `; - } - } - - html += ``; - - if (this.privileges.isAdminOrMod) { - html += ` `; - } - - return html; - }, ''); - } - - function renderDigestAvatar(block) { - if (block.teaser) { - if (block.teaser.user.picture) { - return ''; - } - return '
    ' + block.teaser.user['icon:text'] + '
    '; - } - if (block.user.picture) { - return ''; - } - return '
    ' + block.user['icon:text'] + '
    '; - } - - function userAgentIcons(data) { - let icons = ''; - - switch (data.platform) { - case 'Linux': - icons += ''; - break; - case 'Microsoft Windows': - icons += ''; - break; - case 'Apple Mac': - icons += ''; - break; - case 'Android': - icons += ''; - break; - case 'iPad': - icons += ''; - break; - case 'iPod': // intentional fall-through - case 'iPhone': - icons += ''; - break; - default: - icons += ''; - break; - } - - switch (data.browser) { - case 'Chrome': - icons += ''; - break; - case 'Firefox': - icons += ''; - break; - case 'Safari': - icons += ''; - break; - case 'IE': - icons += ''; - break; - case 'Edge': - icons += ''; - break; - default: - icons += ''; - break; - } - - return icons; - } - - function buildAvatar(userObj, size, rounded, classNames, component) { - /** - * userObj requires: - * - uid, picture, icon:bgColor, icon:text (getUserField w/ "picture" should return all 4), username - * size: one of "xs", "sm", "md", "lg", or "xl" (required), or an integer - * rounded: true or false (optional, default false) - * classNames: additional class names to prepend (optional, default none) - * component: overrides the default component (optional, default none) - */ - - // Try to use root context if passed-in userObj is undefined - if (!userObj) { - userObj = this; - } - - const attributes = [ - 'alt="' + userObj.username + '"', - 'title="' + userObj.username + '"', - 'data-uid="' + userObj.uid + '"', - 'loading="lazy"', - ]; - const styles = []; - classNames = classNames || ''; - - // Validate sizes, handle integers, otherwise fall back to `avatar-sm` - if (['xs', 'sm', 'sm2x', 'md', 'lg', 'xl'].includes(size)) { - classNames += ' avatar-' + size; - } else if (!isNaN(parseInt(size, 10))) { - styles.push('width: ' + size + 'px;', 'height: ' + size + 'px;', 'line-height: ' + size + 'px;', 'font-size: ' + (parseInt(size, 10) / 16) + 'rem;'); - } else { - classNames += ' avatar-sm'; - } - attributes.unshift('class="avatar ' + classNames + (rounded ? ' avatar-rounded' : '') + '"'); - - // Component override - if (component) { - attributes.push('component="' + component + '"'); - } else { - attributes.push('component="avatar/' + (userObj.picture ? 'picture' : 'icon') + '"'); - } - - if (userObj.picture) { - return ''; - } - - styles.push('background-color: ' + userObj['icon:bgColor'] + ';'); - return '' + userObj['icon:text'] + ''; - } - - function register() { - Object.keys(helpers).forEach(function (helperName) { - Benchpress.registerHelper(helperName, helpers[helperName]); - }); - } - - return helpers; -})); +define('helpers', ['utils', 'benchpressjs'], function (utils, Benchpressjs) { + return factory(utils, Benchpressjs, config.relative_path); +}); diff --git a/public/src/modules/messages.js b/public/src/modules/messages.js index af028e1609..724d6886f1 100644 --- a/public/src/modules/messages.js +++ b/public/src/modules/messages.js @@ -1,15 +1,17 @@ 'use strict'; -define('messages', ['bootbox', 'translator', 'storage', 'alerts'], function (bootbox, translator, storage, alerts) { +define('messages', ['bootbox', 'translator', 'storage', 'alerts', 'hooks'], function (bootbox, translator, storage, alerts, hooks) { const messages = {}; let showWelcomeMessage; let registerMessage; messages.show = function () { - showQueryStringMessages(); - showCookieWarning(); - messages.showEmailConfirmWarning(); + hooks.one('action:ajaxify.end', () => { + showQueryStringMessages(); + showCookieWarning(); + messages.showEmailConfirmWarning(); + }); }; messages.showEmailConfirmWarning = function (message) { @@ -76,9 +78,9 @@ define('messages', ['bootbox', 'translator', 'storage', 'alerts'], function (boo } function showQueryStringMessages() { - const params = utils.params(); - showWelcomeMessage = !!params.loggedin; - registerMessage = params.register; + const params = utils.params({ full: true }); + showWelcomeMessage = params.has('loggedin'); + registerMessage = params.get('register'); if (showWelcomeMessage) { alerts.alert({ @@ -87,13 +89,20 @@ define('messages', ['bootbox', 'translator', 'storage', 'alerts'], function (boo message: '[[global:you_have_successfully_logged_in]]', timeout: 5000, }); + + params.delete('loggedin'); } if (registerMessage) { bootbox.alert({ message: utils.escapeHTML(decodeURIComponent(registerMessage)), }); + + params.delete('register'); } + + const qs = params.toString(); + ajaxify.updateHistory(ajaxify.currentPage + (qs ? `?${qs}` : '') + document.location.hash, true); } messages.showInvalidSession = function () { diff --git a/public/src/modules/notifications.js b/public/src/modules/notifications.js index 0e9a05e11e..314502914b 100644 --- a/public/src/modules/notifications.js +++ b/public/src/modules/notifications.js @@ -56,7 +56,7 @@ define('notifications', [ components.get('notifications').on('click', '.mark-all-read', Notifications.markAllRead); notifList.on('click', '.mark-read', function () { - const liEl = $(this).parent(); + const liEl = $(this).parents('li'); const unread = liEl.hasClass('unread'); const nid = liEl.attr('data-nid'); markNotification(nid, unread, function () { diff --git a/public/src/modules/pictureCropper.js b/public/src/modules/pictureCropper.js index f095ebd760..cb1201ba29 100644 --- a/public/src/modules/pictureCropper.js +++ b/public/src/modules/pictureCropper.js @@ -1,6 +1,6 @@ 'use strict'; -define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) { +define('pictureCropper', ['alerts'], function (alerts) { const module = {}; module.show = function (data, callback) { @@ -36,7 +36,7 @@ define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) { $('#crop-picture-modal').remove(); app.parseAndTranslate('modals/crop_picture', { url: utils.escapeHTML(data.url), - }, function (cropperModal) { + }, async function (cropperModal) { cropperModal.modal({ backdrop: 'static', }).modal('show'); @@ -45,6 +45,7 @@ define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) { const cropBoxHeight = parseInt($(window).height() / 2, 10); const img = document.getElementById('cropped-image'); $(img).css('max-height', cropBoxHeight); + const Cropper = (await import(/* webpackChunkName: "cropperjs" */ 'cropperjs')).default; let cropperTool = new Cropper(img, { aspectRatio: data.aspectRatio, @@ -126,10 +127,11 @@ define('pictureCropper', ['cropper', 'alerts'], function (Cropper, alerts) { }); }); - cropperModal.find('.upload-btn').on('click', function () { + + cropperModal.find('.upload-btn').on('click', async function () { $(this).addClass('disabled'); cropperTool.destroy(); - + const Cropper = (await import(/* webpackChunkName: "cropperjs" */ 'cropperjs')).default; cropperTool = new Cropper(img, { viewMode: 1, autoCropArea: 1, diff --git a/public/src/modules/settings.js b/public/src/modules/settings.js index a3f570dc7a..57dfa2abee 100644 --- a/public/src/modules/settings.js +++ b/public/src/modules/settings.js @@ -2,17 +2,6 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) { - const DEFAULT_PLUGINS = [ - 'settings/checkbox', - 'settings/number', - 'settings/textarea', - 'settings/select', - 'settings/array', - 'settings/key', - 'settings/object', - 'settings/sorted-list', - ]; - // eslint-disable-next-line prefer-const let Settings; let onReady = []; @@ -574,7 +563,16 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) { helper.registerReadyJobs(1); - require(DEFAULT_PLUGINS, function () { + require([ + 'settings/checkbox', + 'settings/number', + 'settings/textarea', + 'settings/select', + 'settings/array', + 'settings/key', + 'settings/object', + 'settings/sorted-list', + ], function () { for (let i = 0; i < arguments.length; i += 1) { Settings.registerPlugin(arguments[i]); } diff --git a/public/src/modules/taskbar.js b/public/src/modules/taskbar.js index 87fc2ad1c6..67b36d8156 100644 --- a/public/src/modules/taskbar.js +++ b/public/src/modules/taskbar.js @@ -12,24 +12,22 @@ define('taskbar', ['benchpress', 'translator', 'hooks'], function (Benchpress, t self.tasklist = self.taskbar.find('ul'); $(document.body).append(self.taskbar); - self.taskbar.on('click', 'li', function () { + self.taskbar.on('click', 'li', async function () { const $btn = $(this); - const module = $btn.attr('data-module'); + const moduleName = $btn.attr('data-module'); const uuid = $btn.attr('data-uuid'); - require([module], function (module) { - if (!$btn.hasClass('active')) { - minimizeAll(); - module.load(uuid); - taskbar.toggleNew(uuid, false); - - taskbar.tasklist.removeClass('active'); - $btn.addClass('active'); - } else { - module.minimize(uuid); - } - }); + const module = await app.require(moduleName); + if (!$btn.hasClass('active')) { + minimizeAll(); + module.load(uuid); + taskbar.toggleNew(uuid, false); + taskbar.tasklist.removeClass('active'); + $btn.addClass('active'); + } else { + module.minimize(uuid); + } return false; }); }); @@ -39,22 +37,21 @@ define('taskbar', ['benchpress', 'translator', 'hooks'], function (Benchpress, t }); }; - taskbar.close = function (module, uuid) { + taskbar.close = async function (moduleName, uuid) { // Sends signal to the appropriate module's .close() fn (if present) const btnEl = taskbar.tasklist.find('[data-module="' + module + '"][data-uuid="' + uuid + '"]'); let fnName = 'close'; // TODO: Refactor chat module to not take uuid in close instead of by jQuery element - if (module === 'chat') { + if (moduleName === 'chat') { fnName = 'closeByUUID'; } if (btnEl.length) { - require([module], function (module) { - if (typeof module[fnName] === 'function') { - module[fnName](uuid); - } - }); + const module = await app.require(moduleName); + if (module && typeof module[fnName] === 'function') { + module[fnName](uuid); + } } }; diff --git a/public/src/modules/translator.common.js b/public/src/modules/translator.common.js new file mode 100644 index 0000000000..49a3b59c60 --- /dev/null +++ b/public/src/modules/translator.common.js @@ -0,0 +1,633 @@ +'use strict'; + +module.exports = function (utils, load, warn) { + const assign = Object.assign || jQuery.extend; + + function escapeHTML(str) { + return utils.escapeHTML(utils.decodeHTMLEntities( + String(str) + .replace(/[\s\xa0]+/g, ' ') + .replace(/^\s+|\s+$/g, '') + )); + } + + const Translator = (function () { + /** + * Construct a new Translator object + * @param {string} language - Language code for this translator instance + * @exports translator.Translator + */ + function Translator(language) { + const self = this; + + if (!language) { + throw new TypeError('Parameter `language` must be a language string. Received ' + language + (language === '' ? '(empty string)' : '')); + } + + self.modules = Object.keys(Translator.moduleFactories).map(function (namespace) { + const factory = Translator.moduleFactories[namespace]; + return [namespace, factory(language)]; + }).reduce(function (prev, elem) { + const namespace = elem[0]; + const module = elem[1]; + prev[namespace] = module; + + return prev; + }, {}); + + self.lang = language; + self.translations = {}; + } + + Translator.prototype.load = load; + + /** + * Parse the translation instructions into the language of the Translator instance + * @param {string} str - Source string + * @returns {Promise} + */ + Translator.prototype.translate = function translate(str) { + // regex for valid text in namespace / key + const validText = 'a-zA-Z0-9\\-_.\\/'; + const validTextRegex = new RegExp('[' + validText + ']'); + const invalidTextRegex = new RegExp('[^' + validText + '\\]]'); + + // current cursor position + let cursor = 0; + // last break of the input string + let lastBreak = 0; + // length of the input string + const len = str.length; + // array to hold the promises for the translations + // and the strings of untranslated text in between + const toTranslate = []; + + // to store the state of if we're currently in a top-level token for later + let inToken = false; + + // split a translator string into an array of tokens + // but don't split by commas inside other translator strings + function split(text) { + const len = text.length; + const arr = []; + let i = 0; + let brk = 0; + let level = 0; + + while (i + 2 <= len) { + if (text[i] === '[' && text[i + 1] === '[') { + level += 1; + i += 1; + } else if (text[i] === ']' && text[i + 1] === ']') { + level -= 1; + i += 1; + } else if (level === 0 && text[i] === ',' && text[i - 1] !== '\\') { + arr.push(text.slice(brk, i).trim()); + i += 1; + brk = i; + } + i += 1; + } + arr.push(text.slice(brk, i + 1).trim()); + return arr; + } + + // move to the first [[ + cursor = str.indexOf('[[', cursor); + + // the loooop, we'll go to where the cursor + // is equal to the length of the string since + // slice doesn't include the ending index + while (cursor + 2 <= len && cursor !== -1) { + // split the string from the last break + // to the character before the cursor + // add that to the result array + toTranslate.push(str.slice(lastBreak, cursor)); + // set the cursor position past the beginning + // brackets of the translation string + cursor += 2; + // set the last break to our current + // spot since we just broke the string + lastBreak = cursor; + // we're in a token now + inToken = true; + + // the current level of nesting of the translation strings + let level = 0; + let char0; + let char1; + // validating the current string is actually a translation + let textBeforeColonFound = false; + let colonFound = false; + let textAfterColonFound = false; + let commaAfterNameFound = false; + + while (cursor + 2 <= len) { + char0 = str[cursor]; + char1 = str[cursor + 1]; + // found some text after the double bracket, + // so this is probably a translation string + if (!textBeforeColonFound && validTextRegex.test(char0)) { + textBeforeColonFound = true; + cursor += 1; + // found a colon, so this is probably a translation string + } else if (textBeforeColonFound && !colonFound && char0 === ':') { + colonFound = true; + cursor += 1; + // found some text after the colon, + // so this is probably a translation string + } else if (colonFound && !textAfterColonFound && validTextRegex.test(char0)) { + textAfterColonFound = true; + cursor += 1; + } else if (textAfterColonFound && !commaAfterNameFound && char0 === ',') { + commaAfterNameFound = true; + cursor += 1; + // a space or comma was found before the name + // this isn't a translation string, so back out + } else if (!(textBeforeColonFound && colonFound && textAfterColonFound && commaAfterNameFound) && + invalidTextRegex.test(char0)) { + cursor += 1; + lastBreak -= 2; + // no longer in a token + inToken = false; + if (level > 0) { + level -= 1; + } else { + break; + } + // if we're at the beginning of another translation string, + // we're nested, so add to our level + } else if (char0 === '[' && char1 === '[') { + level += 1; + cursor += 2; + // if we're at the end of a translation string + } else if (char0 === ']' && char1 === ']') { + // if we're at the base level, then this is the end + if (level === 0) { + // so grab the name and args + const currentSlice = str.slice(lastBreak, cursor); + const result = split(currentSlice); + const name = result[0]; + const args = result.slice(1); + + // make a backup based on the raw string of the token + // if there are arguments to the token + let backup = ''; + if (args && args.length) { + backup = this.translate(currentSlice); + } + // add the translation promise to the array + toTranslate.push(this.translateKey(name, args, backup)); + // skip past the ending brackets + cursor += 2; + // set this as our last break + lastBreak = cursor; + // and we're no longer in a translation string, + // so continue with the main loop + inToken = false; + break; + } + // otherwise we lower the level + level -= 1; + // and skip past the ending brackets + cursor += 2; + } else { + // otherwise just move to the next character + cursor += 1; + } + } + + // skip to the next [[ + cursor = str.indexOf('[[', cursor); + } + + // ending string of source + let last = str.slice(lastBreak); + + // if we were mid-token, treat it as invalid + if (inToken) { + last = this.translate(last); + } + + // add the remaining text after the last translation string + toTranslate.push(last); + + // and return a promise for the concatenated translated string + return Promise.all(toTranslate).then(function (translated) { + return translated.join(''); + }); + }; + + /** + * Translates a specific key and array of arguments + * @param {string} name - Translation key (ex. 'global:home') + * @param {string[]} args - Arguments for `%1`, `%2`, etc + * @param {string|Promise} backup - Text to use in case the key can't be found + * @returns {Promise} + */ + Translator.prototype.translateKey = function translateKey(name, args, backup) { + const self = this; + + const result = name.split(':', 2); + const namespace = result[0]; + const key = result[1]; + + if (self.modules[namespace]) { + return Promise.resolve(self.modules[namespace](key, args)); + } + + if (namespace && result.length === 1) { + return Promise.resolve('[[' + namespace + ']]'); + } + + if (namespace && !key) { + warn('Missing key in translation token "' + name + '" for language "' + self.lang + '"'); + return Promise.resolve('[[' + namespace + ']]'); + } + + const translation = this.getTranslation(namespace, key); + return translation.then(function (translated) { + // check if the translation is missing first + if (!translated) { + warn('Missing translation "' + name + '" for language "' + self.lang + '"'); + return backup || key; + } + + const argsToTranslate = args.map(function (arg) { + return self.translate(escapeHTML(arg)); + }); + + return Promise.all(argsToTranslate).then(function (translatedArgs) { + let out = translated; + translatedArgs.forEach(function (arg, i) { + let escaped = arg.replace(/%(?=\d)/g, '%').replace(/\\,/g, ','); + // fix double escaped translation keys, see https://github.com/NodeBB/NodeBB/issues/9206 + escaped = escaped.replace(/&lsqb;/g, '[') + .replace(/&rsqb;/g, ']'); + out = out.replace(new RegExp('%' + (i + 1), 'g'), escaped); + }); + return out; + }); + }); + }; + + /** + * Load translation file (or use a cached version), and optionally return the translation of a certain key + * @param {string} namespace - The file name of the translation namespace + * @param {string} [key] - The key of the specific translation to getJSON + * @returns {Promise<{ [key: string]: string } | string>} + */ + Translator.prototype.getTranslation = function getTranslation(namespace, key) { + let translation; + if (!namespace) { + warn('[translator] Parameter `namespace` is ' + namespace + (namespace === '' ? '(empty string)' : '')); + translation = Promise.resolve({}); + } else { + this.translations[namespace] = this.translations[namespace] || + this.load(this.lang, namespace).catch(function () { return {}; }); + translation = this.translations[namespace]; + } + + if (key) { + return translation.then(function (x) { + if (typeof x[key] === 'string') return x[key]; + const keyParts = key.split('.'); + for (let i = 0; i <= keyParts.length; i++) { + if (i === keyParts.length) { + // default to trying to find key with the same name as parent or equal to empty string + return x[keyParts[i - 1]] !== undefined ? x[keyParts[i - 1]] : x['']; + } + switch (typeof x[keyParts[i]]) { + case 'object': + x = x[keyParts[i]]; + break; + case 'string': + if (i === keyParts.length - 1) { + return x[keyParts[i]]; + } + + return false; + + default: + return false; + } + } + }); + } + return translation; + }; + + /** + * @param {Node} node + * @returns {Node[]} + */ + function descendantTextNodes(node) { + const textNodes = []; + + function helper(node) { + if (node.nodeType === 3) { + textNodes.push(node); + } else { + for (let i = 0, c = node.childNodes, l = c.length; i < l; i += 1) { + helper(c[i]); + } + } + } + + helper(node); + return textNodes; + } + + /** + * Recursively translate a DOM element in place + * @param {Element} element - Root element to translate + * @param {string[]} [attributes] - Array of node attributes to translate + * @returns {Promise} + */ + Translator.prototype.translateInPlace = function translateInPlace(element, attributes) { + attributes = attributes || ['placeholder', 'title']; + + const nodes = descendantTextNodes(element); + const text = nodes.map(function (node) { + return utils.escapeHTML(node.nodeValue); + }).join(' || '); + + const attrNodes = attributes.reduce(function (prev, attr) { + const tuples = Array.prototype.map.call(element.querySelectorAll('[' + attr + '*="[["]'), function (el) { + return [attr, el]; + }); + return prev.concat(tuples); + }, []); + const attrText = attrNodes.map(function (node) { + return node[1].getAttribute(node[0]); + }).join(' || '); + + return Promise.all([ + this.translate(text), + this.translate(attrText), + ]).then(function (ref) { + const translated = ref[0]; + const translatedAttrs = ref[1]; + if (translated) { + translated.split(' || ').forEach(function (html, i) { + $(nodes[i]).replaceWith(html); + }); + } + if (translatedAttrs) { + translatedAttrs.split(' || ').forEach(function (text, i) { + attrNodes[i][1].setAttribute(attrNodes[i][0], text); + }); + } + }); + }; + + /** + * Get the language of the current environment, falling back to defaults + * @returns {string} + */ + Translator.getLanguage = function getLanguage() { + return utils.getLanguage(); + }; + + /** + * Create and cache a new Translator instance, or return a cached one + * @param {string} [language] - ('en-GB') Language string + * @returns {Translator} + */ + Translator.create = function create(language) { + if (!language) { + language = Translator.getLanguage(); + } + + Translator.cache[language] = Translator.cache[language] || new Translator(language); + + return Translator.cache[language]; + }; + + Translator.cache = {}; + + /** + * Register a custom module to handle translations + * @param {string} namespace - Namespace to handle translation for + * @param {Function} factory - Function to return the translation function for this namespace + */ + Translator.registerModule = function registerModule(namespace, factory) { + Translator.moduleFactories[namespace] = factory; + + Object.keys(Translator.cache).forEach(function (key) { + const translator = Translator.cache[key]; + translator.modules[namespace] = factory(translator.lang); + }); + }; + + Translator.moduleFactories = {}; + + /** + * Remove the translator patterns from text + * @param {string} text + * @returns {string} + */ + Translator.removePatterns = function removePatterns(text) { + const len = text.length; + let cursor = 0; + let lastBreak = 0; + let level = 0; + let out = ''; + let sub; + + while (cursor < len) { + sub = text.slice(cursor, cursor + 2); + if (sub === '[[') { + if (level === 0) { + out += text.slice(lastBreak, cursor); + } + level += 1; + cursor += 2; + } else if (sub === ']]') { + level -= 1; + cursor += 2; + if (level === 0) { + lastBreak = cursor; + } + } else { + cursor += 1; + } + } + out += text.slice(lastBreak, cursor); + return out; + }; + + /** + * Escape translator patterns in text + * @param {string} text + * @returns {string} + */ + Translator.escape = function escape(text) { + return typeof text === 'string' ? text.replace(/\[\[/g, '[[').replace(/\]\]/g, ']]') : text; + }; + + /** + * Unescape escaped translator patterns in text + * @param {string} text + * @returns {string} + */ + Translator.unescape = function unescape(text) { + return typeof text === 'string' ? + text.replace(/[/g, '[').replace(/\\\[/g, '[') + .replace(/]/g, ']').replace(/\\\]/g, ']') : + text; + }; + + /** + * Construct a translator pattern + * @param {string} name - Translation name + * @param {...string} arg - Optional argument for the pattern + */ + Translator.compile = function compile() { + const args = Array.prototype.slice.call(arguments, 0).map(function (text) { + // escape commas and percent signs in arguments + return String(text).replace(/%/g, '%').replace(/,/g, ','); + }); + + return '[[' + args.join(', ') + ']]'; + }; + + return Translator; + }()); + + /** + * @exports translator + */ + const adaptor = { + /** + * The Translator class + */ + Translator: Translator, + + compile: Translator.compile, + escape: Translator.escape, + unescape: Translator.unescape, + getLanguage: Translator.getLanguage, + + flush: function () { + Object.keys(Translator.cache).forEach(function (code) { + Translator.cache[code].translations = {}; + }); + }, + + flushNamespace: function (namespace) { + Object.keys(Translator.cache).forEach(function (code) { + if (Translator.cache[code] && + Translator.cache[code].translations && + Translator.cache[code].translations[namespace] + ) { + Translator.cache[code].translations[namespace] = null; + } + }); + }, + + + /** + * Legacy translator function for backwards compatibility + */ + translate: function translate(text, language, callback) { + // TODO: deprecate? + + let cb = callback; + let lang = language; + if (typeof language === 'function') { + cb = language; + lang = null; + } + + if (!(typeof text === 'string' || text instanceof String) || text === '') { + if (cb) { + return setTimeout(cb, 0, ''); + } + return ''; + } + + return Translator.create(lang).translate(text).then(function (output) { + if (cb) { + setTimeout(cb, 0, output); + } + return output; + }, function (err) { + warn('Translation failed: ' + err.stack); + }); + }, + translateKeys: async function (keys, language, callback) { + let cb = callback; + let lang = language; + if (typeof language === 'function') { + cb = language; + lang = null; + } + const translations = await Promise.all(keys.map(key => adaptor.translate(key, lang))); + if (typeof cb === 'function') { + return setTimeout(cb, 0, translations); + } + return translations; + }, + + /** + * Add translations to the cache + */ + addTranslation: function addTranslation(language, namespace, translation) { + Translator.create(language).getTranslation(namespace).then(function (translations) { + assign(translations, translation); + }); + }, + + /** + * Get the translations object + */ + getTranslations: function getTranslations(language, namespace, callback) { + callback = callback || function () {}; + Translator.create(language).getTranslation(namespace).then(callback); + }, + + /** + * Alias of getTranslations + */ + load: function load(language, namespace, callback) { + adaptor.getTranslations(language, namespace, callback); + }, + + toggleTimeagoShorthand: function toggleTimeagoShorthand(callback) { + /* eslint "prefer-object-spread": "off" */ + function toggle() { + const tmp = assign({}, jQuery.timeago.settings.strings); + jQuery.timeago.settings.strings = assign({}, adaptor.timeagoShort); + adaptor.timeagoShort = assign({}, tmp); + if (typeof callback === 'function') { + callback(); + } + } + + if (!adaptor.timeagoShort) { + let languageCode = utils.userLangToTimeagoCode(config.userLang); + if (!config.timeagoCodes.includes(languageCode + '-short')) { + languageCode = 'en'; + } + + const originalSettings = assign({}, jQuery.timeago.settings.strings); + adaptor.switchTimeagoLanguage(languageCode + '-short', function () { + adaptor.timeagoShort = assign({}, jQuery.timeago.settings.strings); + jQuery.timeago.settings.strings = assign({}, originalSettings); + toggle(); + }); + } else { + toggle(); + } + }, + + switchTimeagoLanguage: function switchTimeagoLanguage(langCode, callback) { + // Delete the cached shorthand strings if present + delete adaptor.timeagoShort; + import(/* webpackChunkName: "timeago/[request]" */ 'timeago/locales/jquery.timeago.' + langCode).then(callback); + }, + }; + + return adaptor; +}; diff --git a/public/src/modules/translator.js b/public/src/modules/translator.js index 45ebca0a7e..b2e51ab4cf 100644 --- a/public/src/modules/translator.js +++ b/public/src/modules/translator.js @@ -1,6 +1,8 @@ 'use strict'; -(function (factory) { +const factory = require('./translator.common'); + +define('translator', ['jquery', 'utils'], function (jQuery, utils) { function loadClient(language, namespace) { return new Promise(function (resolve, reject) { jQuery.getJSON([config.asset_base_url, 'language', language, namespace].join('/') + '.json?' + config['cache-buster'], function (data) { @@ -18,680 +20,6 @@ }); }); } - let warn = function () { console.warn.apply(console, arguments); }; - if (typeof define === 'function' && define.amd) { - // AMD. Register as a named module - define('translator', [], function () { - return factory(utils, loadClient, warn); - }); - } else if (typeof module === 'object' && module.exports) { - // Node - (function () { - if (global.env === 'development') { - const winston = require('winston'); - warn = function (a) { - winston.warn(a); - }; - } - - module.exports = factory(require('../utils'), function (lang, namespace) { - const languages = require('../../../src/languages'); - return languages.get(lang, namespace); - }, warn); - }()); - } -}(function (utils, load, warn) { - const assign = Object.assign || jQuery.extend; - - function escapeHTML(str) { - return utils.escapeHTML(utils.decodeHTMLEntities( - String(str) - .replace(/[\s\xa0]+/g, ' ') - .replace(/^\s+|\s+$/g, '') - )); - } - - const Translator = (function () { - /** - * Construct a new Translator object - * @param {string} language - Language code for this translator instance - * @exports translator.Translator - */ - function Translator(language) { - const self = this; - - if (!language) { - throw new TypeError('Parameter `language` must be a language string. Received ' + language + (language === '' ? '(empty string)' : '')); - } - - self.modules = Object.keys(Translator.moduleFactories).map(function (namespace) { - const factory = Translator.moduleFactories[namespace]; - return [namespace, factory(language)]; - }).reduce(function (prev, elem) { - const namespace = elem[0]; - const module = elem[1]; - prev[namespace] = module; - - return prev; - }, {}); - - self.lang = language; - self.translations = {}; - } - - Translator.prototype.load = load; - - /** - * Parse the translation instructions into the language of the Translator instance - * @param {string} str - Source string - * @returns {Promise} - */ - Translator.prototype.translate = function translate(str) { - // regex for valid text in namespace / key - const validText = 'a-zA-Z0-9\\-_.\\/'; - const validTextRegex = new RegExp('[' + validText + ']'); - const invalidTextRegex = new RegExp('[^' + validText + '\\]]'); - - // current cursor position - let cursor = 0; - // last break of the input string - let lastBreak = 0; - // length of the input string - const len = str.length; - // array to hold the promises for the translations - // and the strings of untranslated text in between - const toTranslate = []; - - // to store the state of if we're currently in a top-level token for later - let inToken = false; - - // split a translator string into an array of tokens - // but don't split by commas inside other translator strings - function split(text) { - const len = text.length; - const arr = []; - let i = 0; - let brk = 0; - let level = 0; - - while (i + 2 <= len) { - if (text[i] === '[' && text[i + 1] === '[') { - level += 1; - i += 1; - } else if (text[i] === ']' && text[i + 1] === ']') { - level -= 1; - i += 1; - } else if (level === 0 && text[i] === ',' && text[i - 1] !== '\\') { - arr.push(text.slice(brk, i).trim()); - i += 1; - brk = i; - } - i += 1; - } - arr.push(text.slice(brk, i + 1).trim()); - return arr; - } - - // move to the first [[ - cursor = str.indexOf('[[', cursor); - - // the loooop, we'll go to where the cursor - // is equal to the length of the string since - // slice doesn't include the ending index - while (cursor + 2 <= len && cursor !== -1) { - // split the string from the last break - // to the character before the cursor - // add that to the result array - toTranslate.push(str.slice(lastBreak, cursor)); - // set the cursor position past the beginning - // brackets of the translation string - cursor += 2; - // set the last break to our current - // spot since we just broke the string - lastBreak = cursor; - // we're in a token now - inToken = true; - - // the current level of nesting of the translation strings - let level = 0; - let char0; - let char1; - // validating the current string is actually a translation - let textBeforeColonFound = false; - let colonFound = false; - let textAfterColonFound = false; - let commaAfterNameFound = false; - - while (cursor + 2 <= len) { - char0 = str[cursor]; - char1 = str[cursor + 1]; - // found some text after the double bracket, - // so this is probably a translation string - if (!textBeforeColonFound && validTextRegex.test(char0)) { - textBeforeColonFound = true; - cursor += 1; - // found a colon, so this is probably a translation string - } else if (textBeforeColonFound && !colonFound && char0 === ':') { - colonFound = true; - cursor += 1; - // found some text after the colon, - // so this is probably a translation string - } else if (colonFound && !textAfterColonFound && validTextRegex.test(char0)) { - textAfterColonFound = true; - cursor += 1; - } else if (textAfterColonFound && !commaAfterNameFound && char0 === ',') { - commaAfterNameFound = true; - cursor += 1; - // a space or comma was found before the name - // this isn't a translation string, so back out - } else if (!(textBeforeColonFound && colonFound && textAfterColonFound && commaAfterNameFound) && - invalidTextRegex.test(char0)) { - cursor += 1; - lastBreak -= 2; - // no longer in a token - inToken = false; - if (level > 0) { - level -= 1; - } else { - break; - } - // if we're at the beginning of another translation string, - // we're nested, so add to our level - } else if (char0 === '[' && char1 === '[') { - level += 1; - cursor += 2; - // if we're at the end of a translation string - } else if (char0 === ']' && char1 === ']') { - // if we're at the base level, then this is the end - if (level === 0) { - // so grab the name and args - const currentSlice = str.slice(lastBreak, cursor); - const result = split(currentSlice); - const name = result[0]; - const args = result.slice(1); - - // make a backup based on the raw string of the token - // if there are arguments to the token - let backup = ''; - if (args && args.length) { - backup = this.translate(currentSlice); - } - // add the translation promise to the array - toTranslate.push(this.translateKey(name, args, backup)); - // skip past the ending brackets - cursor += 2; - // set this as our last break - lastBreak = cursor; - // and we're no longer in a translation string, - // so continue with the main loop - inToken = false; - break; - } - // otherwise we lower the level - level -= 1; - // and skip past the ending brackets - cursor += 2; - } else { - // otherwise just move to the next character - cursor += 1; - } - } - - // skip to the next [[ - cursor = str.indexOf('[[', cursor); - } - - // ending string of source - let last = str.slice(lastBreak); - - // if we were mid-token, treat it as invalid - if (inToken) { - last = this.translate(last); - } - - // add the remaining text after the last translation string - toTranslate.push(last); - - // and return a promise for the concatenated translated string - return Promise.all(toTranslate).then(function (translated) { - return translated.join(''); - }); - }; - - /** - * Translates a specific key and array of arguments - * @param {string} name - Translation key (ex. 'global:home') - * @param {string[]} args - Arguments for `%1`, `%2`, etc - * @param {string|Promise} backup - Text to use in case the key can't be found - * @returns {Promise} - */ - Translator.prototype.translateKey = function translateKey(name, args, backup) { - const self = this; - - const result = name.split(':', 2); - const namespace = result[0]; - const key = result[1]; - - if (self.modules[namespace]) { - return Promise.resolve(self.modules[namespace](key, args)); - } - - if (namespace && result.length === 1) { - return Promise.resolve('[[' + namespace + ']]'); - } - - if (namespace && !key) { - warn('Missing key in translation token "' + name + '" for language "' + self.lang + '"'); - return Promise.resolve('[[' + namespace + ']]'); - } - - const translation = this.getTranslation(namespace, key); - return translation.then(function (translated) { - // check if the translation is missing first - if (!translated) { - warn('Missing translation "' + name + '" for language "' + self.lang + '"'); - return backup || key; - } - - const argsToTranslate = args.map(function (arg) { - return self.translate(escapeHTML(arg)); - }); - - return Promise.all(argsToTranslate).then(function (translatedArgs) { - let out = translated; - translatedArgs.forEach(function (arg, i) { - let escaped = arg.replace(/%(?=\d)/g, '%').replace(/\\,/g, ','); - // fix double escaped translation keys, see https://github.com/NodeBB/NodeBB/issues/9206 - escaped = escaped.replace(/&lsqb;/g, '[') - .replace(/&rsqb;/g, ']'); - out = out.replace(new RegExp('%' + (i + 1), 'g'), escaped); - }); - return out; - }); - }); - }; - - /** - * Load translation file (or use a cached version), and optionally return the translation of a certain key - * @param {string} namespace - The file name of the translation namespace - * @param {string} [key] - The key of the specific translation to getJSON - * @returns {Promise<{ [key: string]: string } | string>} - */ - Translator.prototype.getTranslation = function getTranslation(namespace, key) { - let translation; - if (!namespace) { - warn('[translator] Parameter `namespace` is ' + namespace + (namespace === '' ? '(empty string)' : '')); - translation = Promise.resolve({}); - } else { - this.translations[namespace] = this.translations[namespace] || - this.load(this.lang, namespace).catch(function () { return {}; }); - translation = this.translations[namespace]; - } - - if (key) { - return translation.then(function (x) { - if (typeof x[key] === 'string') return x[key]; - const keyParts = key.split('.'); - for (let i = 0; i <= keyParts.length; i++) { - if (i === keyParts.length) { - // default to trying to find key with the same name as parent or equal to empty string - return x[keyParts[i - 1]] !== undefined ? x[keyParts[i - 1]] : x['']; - } - switch (typeof x[keyParts[i]]) { - case 'object': - x = x[keyParts[i]]; - break; - case 'string': - if (i === keyParts.length - 1) { - return x[keyParts[i]]; - } - - return false; - - default: - return false; - } - } - }); - } - return translation; - }; - - /** - * @param {Node} node - * @returns {Node[]} - */ - function descendantTextNodes(node) { - const textNodes = []; - - function helper(node) { - if (node.nodeType === 3) { - textNodes.push(node); - } else { - for (let i = 0, c = node.childNodes, l = c.length; i < l; i += 1) { - helper(c[i]); - } - } - } - - helper(node); - return textNodes; - } - - /** - * Recursively translate a DOM element in place - * @param {Element} element - Root element to translate - * @param {string[]} [attributes] - Array of node attributes to translate - * @returns {Promise} - */ - Translator.prototype.translateInPlace = function translateInPlace(element, attributes) { - attributes = attributes || ['placeholder', 'title']; - - const nodes = descendantTextNodes(element); - const text = nodes.map(function (node) { - return utils.escapeHTML(node.nodeValue); - }).join(' || '); - - const attrNodes = attributes.reduce(function (prev, attr) { - const tuples = Array.prototype.map.call(element.querySelectorAll('[' + attr + '*="[["]'), function (el) { - return [attr, el]; - }); - return prev.concat(tuples); - }, []); - const attrText = attrNodes.map(function (node) { - return node[1].getAttribute(node[0]); - }).join(' || '); - - return Promise.all([ - this.translate(text), - this.translate(attrText), - ]).then(function (ref) { - const translated = ref[0]; - const translatedAttrs = ref[1]; - if (translated) { - translated.split(' || ').forEach(function (html, i) { - $(nodes[i]).replaceWith(html); - }); - } - if (translatedAttrs) { - translatedAttrs.split(' || ').forEach(function (text, i) { - attrNodes[i][1].setAttribute(attrNodes[i][0], text); - }); - } - }); - }; - - /** - * Get the language of the current environment, falling back to defaults - * @returns {string} - */ - Translator.getLanguage = function getLanguage() { - let lang; - - if (typeof window === 'object' && window.config && window.utils) { - lang = utils.params().lang || config.userLang || config.defaultLang || 'en-GB'; - } else { - const meta = require('../../../src/meta'); - lang = meta.config && meta.config.defaultLang ? meta.config.defaultLang : 'en-GB'; - } - - return lang; - }; - - /** - * Create and cache a new Translator instance, or return a cached one - * @param {string} [language] - ('en-GB') Language string - * @returns {Translator} - */ - Translator.create = function create(language) { - if (!language) { - language = Translator.getLanguage(); - } - - Translator.cache[language] = Translator.cache[language] || new Translator(language); - - return Translator.cache[language]; - }; - - Translator.cache = {}; - - /** - * Register a custom module to handle translations - * @param {string} namespace - Namespace to handle translation for - * @param {Function} factory - Function to return the translation function for this namespace - */ - Translator.registerModule = function registerModule(namespace, factory) { - Translator.moduleFactories[namespace] = factory; - - Object.keys(Translator.cache).forEach(function (key) { - const translator = Translator.cache[key]; - translator.modules[namespace] = factory(translator.lang); - }); - }; - - Translator.moduleFactories = {}; - - /** - * Remove the translator patterns from text - * @param {string} text - * @returns {string} - */ - Translator.removePatterns = function removePatterns(text) { - const len = text.length; - let cursor = 0; - let lastBreak = 0; - let level = 0; - let out = ''; - let sub; - - while (cursor < len) { - sub = text.slice(cursor, cursor + 2); - if (sub === '[[') { - if (level === 0) { - out += text.slice(lastBreak, cursor); - } - level += 1; - cursor += 2; - } else if (sub === ']]') { - level -= 1; - cursor += 2; - if (level === 0) { - lastBreak = cursor; - } - } else { - cursor += 1; - } - } - out += text.slice(lastBreak, cursor); - return out; - }; - - /** - * Escape translator patterns in text - * @param {string} text - * @returns {string} - */ - Translator.escape = function escape(text) { - return typeof text === 'string' ? text.replace(/\[\[/g, '[[').replace(/\]\]/g, ']]') : text; - }; - - /** - * Unescape escaped translator patterns in text - * @param {string} text - * @returns {string} - */ - Translator.unescape = function unescape(text) { - return typeof text === 'string' ? - text.replace(/[/g, '[').replace(/\\\[/g, '[') - .replace(/]/g, ']').replace(/\\\]/g, ']') : - text; - }; - - /** - * Construct a translator pattern - * @param {string} name - Translation name - * @param {...string} arg - Optional argument for the pattern - */ - Translator.compile = function compile() { - const args = Array.prototype.slice.call(arguments, 0).map(function (text) { - // escape commas and percent signs in arguments - return String(text).replace(/%/g, '%').replace(/,/g, ','); - }); - - return '[[' + args.join(', ') + ']]'; - }; - - return Translator; - }()); - - /** - * @exports translator - */ - const adaptor = { - /** - * The Translator class - */ - Translator: Translator, - - compile: Translator.compile, - escape: Translator.escape, - unescape: Translator.unescape, - getLanguage: Translator.getLanguage, - - flush: function () { - Object.keys(Translator.cache).forEach(function (code) { - Translator.cache[code].translations = {}; - }); - }, - - flushNamespace: function (namespace) { - Object.keys(Translator.cache).forEach(function (code) { - if (Translator.cache[code] && - Translator.cache[code].translations && - Translator.cache[code].translations[namespace] - ) { - Translator.cache[code].translations[namespace] = null; - } - }); - }, - - - /** - * Legacy translator function for backwards compatibility - */ - translate: function translate(text, language, callback) { - // TODO: deprecate? - - let cb = callback; - let lang = language; - if (typeof language === 'function') { - cb = language; - lang = null; - } - - if (!(typeof text === 'string' || text instanceof String) || text === '') { - if (cb) { - return setTimeout(cb, 0, ''); - } - return ''; - } - - return Translator.create(lang).translate(text).then(function (output) { - if (cb) { - setTimeout(cb, 0, output); - } - return output; - }, function (err) { - warn('Translation failed: ' + err.stack); - }); - }, - translateKeys: async function (keys, language, callback) { - let cb = callback; - let lang = language; - if (typeof language === 'function') { - cb = language; - lang = null; - } - const translations = await Promise.all(keys.map(key => adaptor.translate(key, lang))); - if (typeof cb === 'function') { - return setTimeout(cb, 0, translations); - } - return translations; - }, - - /** - * Add translations to the cache - */ - addTranslation: function addTranslation(language, namespace, translation) { - Translator.create(language).getTranslation(namespace).then(function (translations) { - assign(translations, translation); - }); - }, - - /** - * Get the translations object - */ - getTranslations: function getTranslations(language, namespace, callback) { - callback = callback || function () {}; - Translator.create(language).getTranslation(namespace).then(callback); - }, - - /** - * Alias of getTranslations - */ - load: function load(language, namespace, callback) { - adaptor.getTranslations(language, namespace, callback); - }, - - toggleTimeagoShorthand: function toggleTimeagoShorthand(callback) { - /* eslint "prefer-object-spread": "off" */ - function toggle() { - const tmp = assign({}, jQuery.timeago.settings.strings); - jQuery.timeago.settings.strings = assign({}, adaptor.timeagoShort); - adaptor.timeagoShort = assign({}, tmp); - if (typeof callback === 'function') { - callback(); - } - } - - if (!adaptor.timeagoShort) { - let languageCode = utils.userLangToTimeagoCode(config.userLang); - if (!config.timeagoCodes.includes(languageCode + '-short')) { - languageCode = 'en'; - } - - const originalSettings = assign({}, jQuery.timeago.settings.strings); - adaptor.switchTimeagoLanguage(languageCode + '-short', function () { - adaptor.timeagoShort = assign({}, jQuery.timeago.settings.strings); - jQuery.timeago.settings.strings = assign({}, originalSettings); - toggle(); - }); - } else { - toggle(); - } - }, - - switchTimeagoLanguage: function switchTimeagoLanguage(langCode, callback) { - // Delete the cached shorthand strings if present - delete adaptor.timeagoShort; - - const stringsModule = 'timeago/locales/jquery.timeago.' + langCode; - // without undef, requirejs won't load the strings a second time - require.undef(stringsModule); - require([stringsModule], function () { - callback(); - }); - }, - - prepareDOM: function prepareDOM() { - // Add directional code if necessary - adaptor.translate('[[language:dir]]', function (value) { - if (value && !$('html').attr('data-dir')) { - jQuery('html').css('direction', value).attr('data-dir', value); - } - }); - }, - }; - - return adaptor; -})); + const warn = function () { console.warn.apply(console, arguments); }; + return factory(utils, loadClient, warn); +}); diff --git a/public/src/overrides.js b/public/src/overrides.js index c00d3b530b..4e02be3d9d 100644 --- a/public/src/overrides.js +++ b/public/src/overrides.js @@ -1,89 +1,88 @@ 'use strict'; +const translator = require('./modules/translator'); -overrides = window.overrides || {}; +window.overrides = window.overrides || {}; + +function translate(elements, type, str) { + return elements.each(function () { + var el = $(this); + translator.translate(str, function (translated) { + el[type](translated); + }); + }); +} if (typeof window !== 'undefined') { (function ($) { - require(['translator'], function (translator) { - $.fn.getCursorPosition = function () { - const el = $(this).get(0); - let pos = 0; - if ('selectionStart' in el) { - pos = el.selectionStart; - } else if ('selection' in document) { - el.focus(); - const Sel = document.selection.createRange(); - const SelLength = document.selection.createRange().text.length; - Sel.moveStart('character', -el.value.length); - pos = Sel.text.length - SelLength; + $.fn.getCursorPosition = function () { + const el = $(this).get(0); + let pos = 0; + if ('selectionStart' in el) { + pos = el.selectionStart; + } else if ('selection' in document) { + el.focus(); + const Sel = document.selection.createRange(); + const SelLength = document.selection.createRange().text.length; + Sel.moveStart('character', -el.value.length); + pos = Sel.text.length - SelLength; + } + return pos; + }; + + $.fn.selectRange = function (start, end) { + if (!end) { + end = start; + } + return this.each(function () { + if (this.setSelectionRange) { + this.focus(); + this.setSelectionRange(start, end); + } else if (this.createTextRange) { + const range = this.createTextRange(); + range.collapse(true); + range.moveEnd('character', end); + range.moveStart('character', start); + range.select(); } - return pos; - }; + }); + }; + + // http://stackoverflow.com/questions/511088/use-javascript-to-place-cursor-at-end-of-text-in-text-input-element + $.fn.putCursorAtEnd = function () { + return this.each(function () { + $(this).focus(); - $.fn.selectRange = function (start, end) { - if (!end) { - end = start; + if (this.setSelectionRange) { + const len = $(this).val().length * 2; + this.setSelectionRange(len, len); + } else { + $(this).val($(this).val()); } - return this.each(function () { - if (this.setSelectionRange) { - this.focus(); - this.setSelectionRange(start, end); - } else if (this.createTextRange) { - const range = this.createTextRange(); - range.collapse(true); - range.moveEnd('character', end); - range.moveStart('character', start); - range.select(); - } - }); - }; - - // http://stackoverflow.com/questions/511088/use-javascript-to-place-cursor-at-end-of-text-in-text-input-element - $.fn.putCursorAtEnd = function () { - return this.each(function () { - $(this).focus(); - - if (this.setSelectionRange) { - const len = $(this).val().length * 2; - this.setSelectionRange(len, len); - } else { - $(this).val($(this).val()); - } - this.scrollTop = 999999; - }); - }; - - $.fn.translateHtml = function (str) { - return translate(this, 'html', str); - }; - - $.fn.translateText = function (str) { - return translate(this, 'text', str); - }; - - $.fn.translateVal = function (str) { - return translate(this, 'val', str); - }; - - $.fn.translateAttr = function (attr, str) { - return this.each(function () { - const el = $(this); - translator.translate(str, function (translated) { - el.attr(attr, translated); - }); - }); - }; - - function translate(elements, type, str) { - return elements.each(function () { - const el = $(this); - translator.translate(str, function (translated) { - el[type](translated); - }); + this.scrollTop = 999999; + }); + }; + + $.fn.translateHtml = function (str) { + return translate(this, 'html', str); + }; + + $.fn.translateText = function (str) { + return translate(this, 'text', str); + }; + + $.fn.translateVal = function (str) { + return translate(this, 'val', str); + }; + + $.fn.translateAttr = function (attr, str) { + return this.each(function () { + const el = $(this); + translator.translate(str, function (translated) { + el.attr(attr, translated); }); - } - }); + }); + }; }(jQuery || { fn: {} })); (function () { diff --git a/public/src/require-config.js b/public/src/require-config.js deleted file mode 100644 index 00607597e2..0000000000 --- a/public/src/require-config.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; - -require.config({ - baseUrl: config.assetBaseUrl + '/src/modules', - waitSeconds: 0, - urlArgs: config['cache-buster'], - paths: { - forum: '../client', - admin: '../admin', - vendor: '../../vendor', - plugins: '../../plugins', - }, -}); diff --git a/public/src/sockets.js b/public/src/sockets.js index 37698f1a4e..2927c21619 100644 --- a/public/src/sockets.js +++ b/public/src/sockets.js @@ -1,8 +1,11 @@ 'use strict'; +// eslint-disable-next-line no-redeclare +const io = require('socket.io-client'); +// eslint-disable-next-line no-redeclare +const $ = require('jquery'); app = window.app || {}; -socket = window.socket; (function () { let reconnecting = false; @@ -14,7 +17,7 @@ socket = window.socket; path: config.relative_path + '/socket.io', }; - socket = io(config.websocketAddress, ioParams); + window.socket = io(config.websocketAddress, ioParams); const oEmit = socket.emit; socket.emit = function (event, data, callback) { diff --git a/public/src/utils.common.js b/public/src/utils.common.js new file mode 100644 index 0000000000..3860861ee0 --- /dev/null +++ b/public/src/utils.common.js @@ -0,0 +1,759 @@ +'use strict'; + + +// add default escape function for escaping HTML entities +const escapeCharMap = Object.freeze({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`', + '=': '=', +}); +function replaceChar(c) { + return escapeCharMap[c]; +} +const escapeChars = /[&<>"'`=]/g; + +const HTMLEntities = Object.freeze({ + amp: '&', + gt: '>', + lt: '<', + quot: '"', + apos: "'", + AElig: 198, + Aacute: 193, + Acirc: 194, + Agrave: 192, + Aring: 197, + Atilde: 195, + Auml: 196, + Ccedil: 199, + ETH: 208, + Eacute: 201, + Ecirc: 202, + Egrave: 200, + Euml: 203, + Iacute: 205, + Icirc: 206, + Igrave: 204, + Iuml: 207, + Ntilde: 209, + Oacute: 211, + Ocirc: 212, + Ograve: 210, + Oslash: 216, + Otilde: 213, + Ouml: 214, + THORN: 222, + Uacute: 218, + Ucirc: 219, + Ugrave: 217, + Uuml: 220, + Yacute: 221, + aacute: 225, + acirc: 226, + aelig: 230, + agrave: 224, + aring: 229, + atilde: 227, + auml: 228, + ccedil: 231, + eacute: 233, + ecirc: 234, + egrave: 232, + eth: 240, + euml: 235, + iacute: 237, + icirc: 238, + igrave: 236, + iuml: 239, + ntilde: 241, + oacute: 243, + ocirc: 244, + ograve: 242, + oslash: 248, + otilde: 245, + ouml: 246, + szlig: 223, + thorn: 254, + uacute: 250, + ucirc: 251, + ugrave: 249, + uuml: 252, + yacute: 253, + yuml: 255, + copy: 169, + reg: 174, + nbsp: 160, + iexcl: 161, + cent: 162, + pound: 163, + curren: 164, + yen: 165, + brvbar: 166, + sect: 167, + uml: 168, + ordf: 170, + laquo: 171, + not: 172, + shy: 173, + macr: 175, + deg: 176, + plusmn: 177, + sup1: 185, + sup2: 178, + sup3: 179, + acute: 180, + micro: 181, + para: 182, + middot: 183, + cedil: 184, + ordm: 186, + raquo: 187, + frac14: 188, + frac12: 189, + frac34: 190, + iquest: 191, + times: 215, + divide: 247, + 'OElig;': 338, + 'oelig;': 339, + 'Scaron;': 352, + 'scaron;': 353, + 'Yuml;': 376, + 'fnof;': 402, + 'circ;': 710, + 'tilde;': 732, + 'Alpha;': 913, + 'Beta;': 914, + 'Gamma;': 915, + 'Delta;': 916, + 'Epsilon;': 917, + 'Zeta;': 918, + 'Eta;': 919, + 'Theta;': 920, + 'Iota;': 921, + 'Kappa;': 922, + 'Lambda;': 923, + 'Mu;': 924, + 'Nu;': 925, + 'Xi;': 926, + 'Omicron;': 927, + 'Pi;': 928, + 'Rho;': 929, + 'Sigma;': 931, + 'Tau;': 932, + 'Upsilon;': 933, + 'Phi;': 934, + 'Chi;': 935, + 'Psi;': 936, + 'Omega;': 937, + 'alpha;': 945, + 'beta;': 946, + 'gamma;': 947, + 'delta;': 948, + 'epsilon;': 949, + 'zeta;': 950, + 'eta;': 951, + 'theta;': 952, + 'iota;': 953, + 'kappa;': 954, + 'lambda;': 955, + 'mu;': 956, + 'nu;': 957, + 'xi;': 958, + 'omicron;': 959, + 'pi;': 960, + 'rho;': 961, + 'sigmaf;': 962, + 'sigma;': 963, + 'tau;': 964, + 'upsilon;': 965, + 'phi;': 966, + 'chi;': 967, + 'psi;': 968, + 'omega;': 969, + 'thetasym;': 977, + 'upsih;': 978, + 'piv;': 982, + 'ensp;': 8194, + 'emsp;': 8195, + 'thinsp;': 8201, + 'zwnj;': 8204, + 'zwj;': 8205, + 'lrm;': 8206, + 'rlm;': 8207, + 'ndash;': 8211, + 'mdash;': 8212, + 'lsquo;': 8216, + 'rsquo;': 8217, + 'sbquo;': 8218, + 'ldquo;': 8220, + 'rdquo;': 8221, + 'bdquo;': 8222, + 'dagger;': 8224, + 'Dagger;': 8225, + 'bull;': 8226, + 'hellip;': 8230, + 'permil;': 8240, + 'prime;': 8242, + 'Prime;': 8243, + 'lsaquo;': 8249, + 'rsaquo;': 8250, + 'oline;': 8254, + 'frasl;': 8260, + 'euro;': 8364, + 'image;': 8465, + 'weierp;': 8472, + 'real;': 8476, + 'trade;': 8482, + 'alefsym;': 8501, + 'larr;': 8592, + 'uarr;': 8593, + 'rarr;': 8594, + 'darr;': 8595, + 'harr;': 8596, + 'crarr;': 8629, + 'lArr;': 8656, + 'uArr;': 8657, + 'rArr;': 8658, + 'dArr;': 8659, + 'hArr;': 8660, + 'forall;': 8704, + 'part;': 8706, + 'exist;': 8707, + 'empty;': 8709, + 'nabla;': 8711, + 'isin;': 8712, + 'notin;': 8713, + 'ni;': 8715, + 'prod;': 8719, + 'sum;': 8721, + 'minus;': 8722, + 'lowast;': 8727, + 'radic;': 8730, + 'prop;': 8733, + 'infin;': 8734, + 'ang;': 8736, + 'and;': 8743, + 'or;': 8744, + 'cap;': 8745, + 'cup;': 8746, + 'int;': 8747, + 'there4;': 8756, + 'sim;': 8764, + 'cong;': 8773, + 'asymp;': 8776, + 'ne;': 8800, + 'equiv;': 8801, + 'le;': 8804, + 'ge;': 8805, + 'sub;': 8834, + 'sup;': 8835, + 'nsub;': 8836, + 'sube;': 8838, + 'supe;': 8839, + 'oplus;': 8853, + 'otimes;': 8855, + 'perp;': 8869, + 'sdot;': 8901, + 'lceil;': 8968, + 'rceil;': 8969, + 'lfloor;': 8970, + 'rfloor;': 8971, + 'lang;': 9001, + 'rang;': 9002, + 'loz;': 9674, + 'spades;': 9824, + 'clubs;': 9827, + 'hearts;': 9829, + 'diams;': 9830, +}); + +/* eslint-disable no-redeclare */ +const utils = { + generateUUID: function () { + /* eslint-disable no-bitwise */ + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : ((r & 0x3) | 0x8); + return v.toString(16); + }); + /* eslint-enable no-bitwise */ + }, + // https://github.com/substack/node-ent/blob/master/index.js + decodeHTMLEntities: function (html) { + return String(html) + .replace(/&#(\d+);?/g, function (_, code) { + return String.fromCharCode(code); + }) + .replace(/&#[xX]([A-Fa-f0-9]+);?/g, function (_, hex) { + return String.fromCharCode(parseInt(hex, 16)); + }) + .replace(/&([^;\W]+;?)/g, function (m, e) { + const ee = e.replace(/;$/, ''); + const target = HTMLEntities[e] || (e.match(/;$/) && HTMLEntities[ee]); + + if (typeof target === 'number') { + return String.fromCharCode(target); + } else if (typeof target === 'string') { + return target; + } + + return m; + }); + }, + // https://github.com/jprichardson/string.js/blob/master/lib/string.js + stripHTMLTags: function (str, tags) { + const pattern = (tags || ['']).join('|'); + return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), ''); + }, + + cleanUpTag: function (tag, maxLength) { + if (typeof tag !== 'string' || !tag.length) { + return ''; + } + + tag = tag.trim().toLowerCase(); + // see https://github.com/NodeBB/NodeBB/issues/4378 + tag = tag.replace(/\u202E/gi, ''); + tag = tag.replace(/[,/#!$^*;:{}=_`<>'"~()?|]/g, ''); + tag = tag.slice(0, maxLength || 15).trim(); + const matches = tag.match(/^[.-]*(.+?)[.-]*$/); + if (matches && matches.length > 1) { + tag = matches[1]; + } + return tag; + }, + + removePunctuation: function (str) { + return str.replace(/[.,-/#!$%^&*;:{}=\-_`<>'"~()?]/g, ''); + }, + + isEmailValid: function (email) { + return typeof email === 'string' && email.length && email.indexOf('@') !== -1 && email.indexOf(',') === -1 && email.indexOf(';') === -1; + }, + + isUserNameValid: function (name) { + return (name && name !== '' && (/^['" \-+.*[\]0-9\u00BF-\u1FFF\u2C00-\uD7FF\w]+$/.test(name))); + }, + + isPasswordValid: function (password) { + return typeof password === 'string' && password.length; + }, + + isNumber: function (n) { + // `isFinite('') === true` so isNan parseFloat check is necessary + return !isNaN(parseFloat(n)) && isFinite(n); + }, + + languageKeyRegex: /\[\[[\w]+:.+\]\]/, + hasLanguageKey: function (input) { + return utils.languageKeyRegex.test(input); + }, + userLangToTimeagoCode: function (userLang) { + const mapping = { + 'en-GB': 'en', + 'en-US': 'en', + 'fa-IR': 'fa', + 'pt-BR': 'pt-br', + nb: 'no', + }; + return mapping.hasOwnProperty(userLang) ? mapping[userLang] : userLang; + }, + // shallow objects merge + merge: function () { + const result = {}; + let obj; + let keys; + for (let i = 0; i < arguments.length; i += 1) { + obj = arguments[i] || {}; + keys = Object.keys(obj); + for (let j = 0; j < keys.length; j += 1) { + result[keys[j]] = obj[keys[j]]; + } + } + return result; + }, + + fileExtension: function (path) { + return ('' + path).split('.').pop(); + }, + + extensionMimeTypeMap: { + bmp: 'image/bmp', + cmx: 'image/x-cmx', + cod: 'image/cis-cod', + gif: 'image/gif', + ico: 'image/x-icon', + ief: 'image/ief', + jfif: 'image/pipeg', + jpe: 'image/jpeg', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + pbm: 'image/x-portable-bitmap', + pgm: 'image/x-portable-graymap', + pnm: 'image/x-portable-anymap', + ppm: 'image/x-portable-pixmap', + ras: 'image/x-cmu-raster', + rgb: 'image/x-rgb', + svg: 'image/svg+xml', + tif: 'image/tiff', + tiff: 'image/tiff', + xbm: 'image/x-xbitmap', + xpm: 'image/x-xpixmap', + xwd: 'image/x-xwindowdump', + }, + + fileMimeType: function (path) { + return utils.extensionToMimeType(utils.fileExtension(path)); + }, + + extensionToMimeType: function (extension) { + return utils.extensionMimeTypeMap.hasOwnProperty(extension) ? utils.extensionMimeTypeMap[extension] : '*'; + }, + + isPromise: function (object) { + // https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise#comment97339131_27746324 + return object && typeof object.then === 'function'; + }, + + promiseParallel: function (obj) { + const keys = Object.keys(obj); + return Promise.all( + keys.map(function (k) { return obj[k]; }) + ).then(function (results) { + const data = {}; + keys.forEach(function (k, i) { + data[k] = results[i]; + }); + return data; + }); + }, + + // https://github.com/sindresorhus/is-absolute-url + isAbsoluteUrlRE: /^[a-zA-Z][a-zA-Z\d+\-.]*:/, + isWinPathRE: /^[a-zA-Z]:\\/, + isAbsoluteUrl: function (url) { + if (utils.isWinPathRE.test(url)) { + return false; + } + return utils.isAbsoluteUrlRE.test(url); + }, + + isRelativeUrl: function (url) { + return !utils.isAbsoluteUrl(url); + }, + + makeNumberHumanReadable: function (num) { + const n = parseInt(num, 10); + if (!n) { + return num; + } + if (n > 999999) { + return (n / 1000000).toFixed(1) + 'm'; + } else if (n > 999) { + return (n / 1000).toFixed(1) + 'k'; + } + return n; + }, + + // takes a string like 1000 and returns 1,000 + addCommas: function (text) { + return String(text).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,'); + }, + + toISOString: function (timestamp) { + if (!timestamp || !Date.prototype.toISOString) { + return ''; + } + + // Prevent too-high values to be passed to Date object + timestamp = Math.min(timestamp, 8640000000000000); + + try { + return new Date(parseInt(timestamp, 10)).toISOString(); + } catch (e) { + return timestamp; + } + }, + + tags: ['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', + 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', + 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', + 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', + 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', + 'output', 'p', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', + 'small', 'source', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', + 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], + + stripTags: ['abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'base', 'basefont', + 'bdi', 'bdo', 'big', 'blink', 'body', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', + 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', + 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'head', 'header', 'hr', 'html', 'iframe', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', + 'map', 'mark', 'marquee', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', + 'output', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', + 'source', 'span', 'strike', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', + 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], + + escapeRegexChars: function (text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + }, + + escapeHTML: function (str) { + if (str == null) { + return ''; + } + if (!str) { + return String(str); + } + + return str.toString().replace(escapeChars, replaceChar); + }, + + isAndroidBrowser: function () { + // http://stackoverflow.com/questions/9286355/how-to-detect-only-the-native-android-browser + const nua = navigator.userAgent; + return ((nua.indexOf('Mozilla/5.0') > -1 && nua.indexOf('Android ') > -1 && nua.indexOf('AppleWebKit') > -1) && !(nua.indexOf('Chrome') > -1)); + }, + + isTouchDevice: function () { + return 'ontouchstart' in document.documentElement; + }, + + findBootstrapEnvironment: function () { + // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api + const envs = ['xs', 'sm', 'md', 'lg']; + const $el = $('
    '); + + $el.appendTo($('body')); + + for (let i = envs.length - 1; i >= 0; i -= 1) { + const env = envs[i]; + + $el.addClass('hidden-' + env); + if ($el.is(':hidden')) { + $el.remove(); + return env; + } + } + }, + + isMobile: function () { + const env = utils.findBootstrapEnvironment(); + return ['xs', 'sm'].some(function (targetEnv) { + return targetEnv === env; + }); + }, + + getHoursArray: function () { + const currentHour = new Date().getHours(); + const labels = []; + + for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) { + const hour = i < 0 ? 24 + i : i; + labels.push(hour + ':00'); + } + + return labels.reverse(); + }, + + getDaysArray: function (from, amount) { + const currentDay = new Date(parseInt(from, 10) || Date.now()).getTime(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const labels = []; + let tmpDate; + + for (let x = (amount || 30) - 1; x >= 0; x -= 1) { + tmpDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x)); + labels.push(months[tmpDate.getMonth()] + ' ' + tmpDate.getDate()); + } + + return labels; + }, + + /* Retrieved from http://stackoverflow.com/a/7557433 @ 27 Mar 2016 */ + isElementInViewport: function (el) { + // special bonus for those using jQuery + if (typeof jQuery === 'function' && el instanceof jQuery) { + el = el[0]; + } + + const rect = el.getBoundingClientRect(); + + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */ + rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* or $(window).width() */ + ); + }, + + // get all the url params in a single key/value hash + params: function (options = {}) { + let url; + if (options.url && !options.url.startsWith('http')) { + // relative path passed in + options.url = options.url.replace(new RegExp(`/?${config.relative_path.slice(1)}/`, 'g'), ''); + url = new URL(document.location); + url.pathname = options.url; + } else { + url = new URL(options.url || document.location); + } + let params = url.searchParams; + + if (options.full) { // return URLSearchParams object + return params; + } + + // Handle arrays passed in query string (Object.fromEntries does not) + const arrays = {}; + params.forEach((value, key) => { + if (!key.endsWith('[]')) { + return; + } + + key = key.slice(0, -2); + arrays[key] = arrays[key] || []; + arrays[key].push(utils.toType(value)); + }); + Object.keys(arrays).forEach((key) => { + params.delete(`${key}[]`); + }); + + // Backwards compatibility with v1.x -- all values passed through utils.toType() + params = Object.fromEntries(params); + Object.keys(params).forEach((key) => { + params[key] = utils.toType(params[key]); + }); + + return { ...params, ...arrays }; + }, + + param: function (key) { + return this.params()[key]; + }, + + urlToLocation: function (url) { + const a = document.createElement('a'); + a.href = url; + return a; + }, + + // return boolean if string 'true' or string 'false', or if a parsable string which is a number + // also supports JSON object and/or arrays parsing + toType: function (str) { + const type = typeof str; + if (type !== 'string') { + return str; + } + const nb = parseFloat(str); + if (!isNaN(nb) && isFinite(str)) { + return nb; + } + if (str === 'false') { + return false; + } + if (str === 'true') { + return true; + } + + try { + str = JSON.parse(str); + } catch (e) {} + + return str; + }, + + // Safely get/set chained properties on an object + // set example: utils.props(A, 'a.b.c.d', 10) // sets A to {a: {b: {c: {d: 10}}}}, and returns 10 + // get example: utils.props(A, 'a.b.c') // returns {d: 10} + // get example: utils.props(A, 'a.b.c.foo.bar') // returns undefined without throwing a TypeError + // credits to github.com/gkindel + props: function (obj, props, value) { + if (obj === undefined) { + obj = window; + } + if (props == null) { + return undefined; + } + const i = props.indexOf('.'); + if (i === -1) { + if (value !== undefined) { + obj[props] = value; + } + return obj[props]; + } + const prop = props.slice(0, i); + const newProps = props.slice(i + 1); + + if (props !== undefined && !(obj[prop] instanceof Object)) { + obj[prop] = {}; + } + + return utils.props(obj[prop], newProps, value); + }, + + isInternalURI: function (targetLocation, referenceLocation, relative_path) { + return targetLocation.host === '' || // Relative paths are always internal links + ( + targetLocation.host === referenceLocation.host && + // Otherwise need to check if protocol and host match + targetLocation.protocol === referenceLocation.protocol && + // Subfolder installs need this additional check + (relative_path.length > 0 ? targetLocation.pathname.indexOf(relative_path) === 0 : true) + ); + }, + + rtrim: function (str) { + return str.replace(/\s+$/g, ''); + }, + + debounce: function (func, wait, immediate) { + // modified from https://davidwalsh.name/javascript-debounce-function + let timeout; + return function () { + const context = this; + const args = arguments; + const later = function () { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }; + const callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + func.apply(context, args); + } + }; + }, + throttle: function (func, wait, immediate) { + let timeout; + return function () { + const context = this; + const args = arguments; + const later = function () { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }; + const callNow = immediate && !timeout; + if (!timeout) { + timeout = setTimeout(later, wait); + } + if (callNow) { + func.apply(context, args); + } + }; + }, +}; + +module.exports = utils; diff --git a/public/src/utils.js b/public/src/utils.js index 4eb2db22a4..f57d68c354 100644 --- a/public/src/utils.js +++ b/public/src/utils.js @@ -1,793 +1,76 @@ +/* eslint-disable no-redeclare */ + 'use strict'; -(function (factory) { - if (typeof module === 'object' && module.exports) { - module.exports = factory(); +const $ = require('jquery'); +const zxcvbn = require('zxcvbn'); - process.profile = function (operation, start) { - console.log('%s took %d milliseconds', operation, process.elapsedTimeSince(start)); - }; +const utils = require('./utils.common'); - process.elapsedTimeSince = function (start) { - const diff = process.hrtime(start); - return (diff[0] * 1e3) + (diff[1] / 1e6); - }; - } else { - window.utils = factory(); - } - // eslint-disable-next-line -}(function () { - // add default escape function for escaping HTML entities - const escapeCharMap = Object.freeze({ - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '`': '`', - '=': '=', - }); - function replaceChar(c) { - return escapeCharMap[c]; +utils.getLanguage = function () { + let lang = 'en-GB'; + if (typeof window === 'object' && window.config && window.utils) { + lang = utils.params().lang || config.userLang || config.defaultLang || 'en-GB'; } - const escapeChars = /[&<>"'`=]/g; - - const HTMLEntities = Object.freeze({ - amp: '&', - gt: '>', - lt: '<', - quot: '"', - apos: "'", - AElig: 198, - Aacute: 193, - Acirc: 194, - Agrave: 192, - Aring: 197, - Atilde: 195, - Auml: 196, - Ccedil: 199, - ETH: 208, - Eacute: 201, - Ecirc: 202, - Egrave: 200, - Euml: 203, - Iacute: 205, - Icirc: 206, - Igrave: 204, - Iuml: 207, - Ntilde: 209, - Oacute: 211, - Ocirc: 212, - Ograve: 210, - Oslash: 216, - Otilde: 213, - Ouml: 214, - THORN: 222, - Uacute: 218, - Ucirc: 219, - Ugrave: 217, - Uuml: 220, - Yacute: 221, - aacute: 225, - acirc: 226, - aelig: 230, - agrave: 224, - aring: 229, - atilde: 227, - auml: 228, - ccedil: 231, - eacute: 233, - ecirc: 234, - egrave: 232, - eth: 240, - euml: 235, - iacute: 237, - icirc: 238, - igrave: 236, - iuml: 239, - ntilde: 241, - oacute: 243, - ocirc: 244, - ograve: 242, - oslash: 248, - otilde: 245, - ouml: 246, - szlig: 223, - thorn: 254, - uacute: 250, - ucirc: 251, - ugrave: 249, - uuml: 252, - yacute: 253, - yuml: 255, - copy: 169, - reg: 174, - nbsp: 160, - iexcl: 161, - cent: 162, - pound: 163, - curren: 164, - yen: 165, - brvbar: 166, - sect: 167, - uml: 168, - ordf: 170, - laquo: 171, - not: 172, - shy: 173, - macr: 175, - deg: 176, - plusmn: 177, - sup1: 185, - sup2: 178, - sup3: 179, - acute: 180, - micro: 181, - para: 182, - middot: 183, - cedil: 184, - ordm: 186, - raquo: 187, - frac14: 188, - frac12: 189, - frac34: 190, - iquest: 191, - times: 215, - divide: 247, - 'OElig;': 338, - 'oelig;': 339, - 'Scaron;': 352, - 'scaron;': 353, - 'Yuml;': 376, - 'fnof;': 402, - 'circ;': 710, - 'tilde;': 732, - 'Alpha;': 913, - 'Beta;': 914, - 'Gamma;': 915, - 'Delta;': 916, - 'Epsilon;': 917, - 'Zeta;': 918, - 'Eta;': 919, - 'Theta;': 920, - 'Iota;': 921, - 'Kappa;': 922, - 'Lambda;': 923, - 'Mu;': 924, - 'Nu;': 925, - 'Xi;': 926, - 'Omicron;': 927, - 'Pi;': 928, - 'Rho;': 929, - 'Sigma;': 931, - 'Tau;': 932, - 'Upsilon;': 933, - 'Phi;': 934, - 'Chi;': 935, - 'Psi;': 936, - 'Omega;': 937, - 'alpha;': 945, - 'beta;': 946, - 'gamma;': 947, - 'delta;': 948, - 'epsilon;': 949, - 'zeta;': 950, - 'eta;': 951, - 'theta;': 952, - 'iota;': 953, - 'kappa;': 954, - 'lambda;': 955, - 'mu;': 956, - 'nu;': 957, - 'xi;': 958, - 'omicron;': 959, - 'pi;': 960, - 'rho;': 961, - 'sigmaf;': 962, - 'sigma;': 963, - 'tau;': 964, - 'upsilon;': 965, - 'phi;': 966, - 'chi;': 967, - 'psi;': 968, - 'omega;': 969, - 'thetasym;': 977, - 'upsih;': 978, - 'piv;': 982, - 'ensp;': 8194, - 'emsp;': 8195, - 'thinsp;': 8201, - 'zwnj;': 8204, - 'zwj;': 8205, - 'lrm;': 8206, - 'rlm;': 8207, - 'ndash;': 8211, - 'mdash;': 8212, - 'lsquo;': 8216, - 'rsquo;': 8217, - 'sbquo;': 8218, - 'ldquo;': 8220, - 'rdquo;': 8221, - 'bdquo;': 8222, - 'dagger;': 8224, - 'Dagger;': 8225, - 'bull;': 8226, - 'hellip;': 8230, - 'permil;': 8240, - 'prime;': 8242, - 'Prime;': 8243, - 'lsaquo;': 8249, - 'rsaquo;': 8250, - 'oline;': 8254, - 'frasl;': 8260, - 'euro;': 8364, - 'image;': 8465, - 'weierp;': 8472, - 'real;': 8476, - 'trade;': 8482, - 'alefsym;': 8501, - 'larr;': 8592, - 'uarr;': 8593, - 'rarr;': 8594, - 'darr;': 8595, - 'harr;': 8596, - 'crarr;': 8629, - 'lArr;': 8656, - 'uArr;': 8657, - 'rArr;': 8658, - 'dArr;': 8659, - 'hArr;': 8660, - 'forall;': 8704, - 'part;': 8706, - 'exist;': 8707, - 'empty;': 8709, - 'nabla;': 8711, - 'isin;': 8712, - 'notin;': 8713, - 'ni;': 8715, - 'prod;': 8719, - 'sum;': 8721, - 'minus;': 8722, - 'lowast;': 8727, - 'radic;': 8730, - 'prop;': 8733, - 'infin;': 8734, - 'ang;': 8736, - 'and;': 8743, - 'or;': 8744, - 'cap;': 8745, - 'cup;': 8746, - 'int;': 8747, - 'there4;': 8756, - 'sim;': 8764, - 'cong;': 8773, - 'asymp;': 8776, - 'ne;': 8800, - 'equiv;': 8801, - 'le;': 8804, - 'ge;': 8805, - 'sub;': 8834, - 'sup;': 8835, - 'nsub;': 8836, - 'sube;': 8838, - 'supe;': 8839, - 'oplus;': 8853, - 'otimes;': 8855, - 'perp;': 8869, - 'sdot;': 8901, - 'lceil;': 8968, - 'rceil;': 8969, - 'lfloor;': 8970, - 'rfloor;': 8971, - 'lang;': 9001, - 'rang;': 9002, - 'loz;': 9674, - 'spades;': 9824, - 'clubs;': 9827, - 'hearts;': 9829, - 'diams;': 9830, - }); - - const utils = { - generateUUID: function () { - /* eslint-disable no-bitwise */ - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : ((r & 0x3) | 0x8); - return v.toString(16); - }); - /* eslint-enable no-bitwise */ - }, - // https://github.com/substack/node-ent/blob/master/index.js - decodeHTMLEntities: function (html) { - return String(html) - .replace(/&#(\d+);?/g, function (_, code) { - return String.fromCharCode(code); - }) - .replace(/&#[xX]([A-Fa-f0-9]+);?/g, function (_, hex) { - return String.fromCharCode(parseInt(hex, 16)); - }) - .replace(/&([^;\W]+;?)/g, function (m, e) { - const ee = e.replace(/;$/, ''); - const target = HTMLEntities[e] || (e.match(/;$/) && HTMLEntities[ee]); - - if (typeof target === 'number') { - return String.fromCharCode(target); - } else if (typeof target === 'string') { - return target; - } - - return m; - }); - }, - // https://github.com/jprichardson/string.js/blob/master/lib/string.js - stripHTMLTags: function (str, tags) { - const pattern = (tags || ['']).join('|'); - return String(str).replace(new RegExp('<(\\/)?(' + (pattern || '[^\\s>]+') + ')(\\s+[^<>]*?)?\\s*(\\/)?>', 'gi'), ''); - }, - - cleanUpTag: function (tag, maxLength) { - if (typeof tag !== 'string' || !tag.length) { - return ''; - } - - tag = tag.trim().toLowerCase(); - // see https://github.com/NodeBB/NodeBB/issues/4378 - tag = tag.replace(/\u202E/gi, ''); - tag = tag.replace(/[,/#!$^*;:{}=_`<>'"~()?|]/g, ''); - tag = tag.slice(0, maxLength || 15).trim(); - const matches = tag.match(/^[.-]*(.+?)[.-]*$/); - if (matches && matches.length > 1) { - tag = matches[1]; - } - return tag; - }, - - removePunctuation: function (str) { - return str.replace(/[.,-/#!$%^&*;:{}=\-_`<>'"~()?]/g, ''); - }, - - isEmailValid: function (email) { - return typeof email === 'string' && email.length && email.indexOf('@') !== -1 && email.indexOf(',') === -1 && email.indexOf(';') === -1; - }, - - isUserNameValid: function (name) { - return (name && name !== '' && (/^['" \-+.*[\]0-9\u00BF-\u1FFF\u2C00-\uD7FF\w]+$/.test(name))); - }, - - isPasswordValid: function (password) { - return typeof password === 'string' && password.length; - }, - - isNumber: function (n) { - // `isFinite('') === true` so isNan parseFloat check is necessary - return !isNaN(parseFloat(n)) && isFinite(n); - }, - - languageKeyRegex: /\[\[[\w]+:.+\]\]/, - hasLanguageKey: function (input) { - return utils.languageKeyRegex.test(input); - }, - userLangToTimeagoCode: function (userLang) { - const mapping = { - 'en-GB': 'en', - 'en-US': 'en', - 'fa-IR': 'fa', - 'pt-BR': 'pt-br', - nb: 'no', - }; - return mapping.hasOwnProperty(userLang) ? mapping[userLang] : userLang; - }, - // shallow objects merge - merge: function () { - const result = {}; - let obj; - let keys; - for (let i = 0; i < arguments.length; i += 1) { - obj = arguments[i] || {}; - keys = Object.keys(obj); - for (let j = 0; j < keys.length; j += 1) { - result[keys[j]] = obj[keys[j]]; - } - } - return result; - }, - - fileExtension: function (path) { - return ('' + path).split('.').pop(); - }, - - extensionMimeTypeMap: { - bmp: 'image/bmp', - cmx: 'image/x-cmx', - cod: 'image/cis-cod', - gif: 'image/gif', - ico: 'image/x-icon', - ief: 'image/ief', - jfif: 'image/pipeg', - jpe: 'image/jpeg', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - png: 'image/png', - pbm: 'image/x-portable-bitmap', - pgm: 'image/x-portable-graymap', - pnm: 'image/x-portable-anymap', - ppm: 'image/x-portable-pixmap', - ras: 'image/x-cmu-raster', - rgb: 'image/x-rgb', - svg: 'image/svg+xml', - tif: 'image/tiff', - tiff: 'image/tiff', - xbm: 'image/x-xbitmap', - xpm: 'image/x-xpixmap', - xwd: 'image/x-xwindowdump', - }, - - fileMimeType: function (path) { - return utils.extensionToMimeType(utils.fileExtension(path)); - }, - - extensionToMimeType: function (extension) { - return utils.extensionMimeTypeMap.hasOwnProperty(extension) ? utils.extensionMimeTypeMap[extension] : '*'; - }, - - isPromise: function (object) { - // https://stackoverflow.com/questions/27746304/how-do-i-tell-if-an-object-is-a-promise#comment97339131_27746324 - return object && typeof object.then === 'function'; - }, - - promiseParallel: function (obj) { - const keys = Object.keys(obj); - return Promise.all( - keys.map(function (k) { return obj[k]; }) - ).then(function (results) { - const data = {}; - keys.forEach(function (k, i) { - data[k] = results[i]; - }); - return data; - }); - }, - - // https://github.com/sindresorhus/is-absolute-url - isAbsoluteUrlRE: /^[a-zA-Z][a-zA-Z\d+\-.]*:/, - isWinPathRE: /^[a-zA-Z]:\\/, - isAbsoluteUrl: function (url) { - if (utils.isWinPathRE.test(url)) { - return false; - } - return utils.isAbsoluteUrlRE.test(url); - }, - - isRelativeUrl: function (url) { - return !utils.isAbsoluteUrl(url); - }, - - makeNumbersHumanReadable: function (elements) { - elements.each(function () { - $(this) - .html(utils.makeNumberHumanReadable($(this).attr('title'))) - .removeClass('hidden'); - }); - }, - - makeNumberHumanReadable: function (num) { - const n = parseInt(num, 10); - if (!n) { - return num; - } - if (n > 999999) { - return (n / 1000000).toFixed(1) + 'm'; - } else if (n > 999) { - return (n / 1000).toFixed(1) + 'k'; - } - return n; - }, - - addCommasToNumbers: function (elements) { - elements.each(function (index, element) { - $(element) - .html(utils.addCommas($(element).html())) - .removeClass('hidden'); - }); - }, - - // takes a string like 1000 and returns 1,000 - addCommas: function (text) { - return String(text).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,'); - }, - - toISOString: function (timestamp) { - if (!timestamp || !Date.prototype.toISOString) { - return ''; - } - - // Prevent too-high values to be passed to Date object - timestamp = Math.min(timestamp, 8640000000000000); - - try { - return new Date(parseInt(timestamp, 10)).toISOString(); - } catch (e) { - return timestamp; - } - }, - - tags: ['a', 'abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'b', 'base', 'basefont', - 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', - 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', - 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', - 'map', 'mark', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', - 'output', 'p', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', - 'small', 'source', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', - 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], - - stripTags: ['abbr', 'acronym', 'address', 'applet', 'area', 'article', 'aside', 'audio', 'base', 'basefont', - 'bdi', 'bdo', 'big', 'blink', 'body', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', - 'command', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'em', 'embed', - 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'head', 'header', 'hr', 'html', 'iframe', 'input', 'ins', 'kbd', 'keygen', 'label', 'legend', 'li', 'link', - 'map', 'mark', 'marquee', 'menu', 'meta', 'meter', 'nav', 'noframes', 'noscript', 'object', 'ol', 'optgroup', 'option', - 'output', 'param', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'script', 'section', 'select', - 'source', 'span', 'strike', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'textarea', 'tfoot', - 'th', 'thead', 'time', 'title', 'tr', 'track', 'tt', 'u', 'ul', 'const', 'video', 'wbr'], + return lang; +}; - escapeRegexChars: function (text) { - return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); - }, - escapeHTML: function (str) { - if (str == null) { - return ''; - } - if (!str) { - return String(str); - } - - return str.toString().replace(escapeChars, replaceChar); - }, - - isAndroidBrowser: function () { - // http://stackoverflow.com/questions/9286355/how-to-detect-only-the-native-android-browser - const nua = navigator.userAgent; - return ((nua.indexOf('Mozilla/5.0') > -1 && nua.indexOf('Android ') > -1 && nua.indexOf('AppleWebKit') > -1) && !(nua.indexOf('Chrome') > -1)); - }, - - isTouchDevice: function () { - return 'ontouchstart' in document.documentElement; - }, - - findBootstrapEnvironment: function () { - // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api - const envs = ['xs', 'sm', 'md', 'lg']; - const $el = $('
    '); - - $el.appendTo($('body')); - - for (let i = envs.length - 1; i >= 0; i -= 1) { - const env = envs[i]; - - $el.addClass('hidden-' + env); - if ($el.is(':hidden')) { - $el.remove(); - return env; - } - } - }, - - isMobile: function () { - const env = utils.findBootstrapEnvironment(); - return ['xs', 'sm'].some(function (targetEnv) { - return targetEnv === env; - }); - }, - - getHoursArray: function () { - const currentHour = new Date().getHours(); - const labels = []; - - for (let i = currentHour, ii = currentHour - 24; i > ii; i -= 1) { - const hour = i < 0 ? 24 + i : i; - labels.push(hour + ':00'); - } - - return labels.reverse(); - }, - - getDaysArray: function (from, amount) { - const currentDay = new Date(parseInt(from, 10) || Date.now()).getTime(); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const labels = []; - let tmpDate; - - for (let x = (amount || 30) - 1; x >= 0; x -= 1) { - tmpDate = new Date(currentDay - (1000 * 60 * 60 * 24 * x)); - labels.push(months[tmpDate.getMonth()] + ' ' + tmpDate.getDate()); - } - - return labels; - }, - - /* Retrieved from http://stackoverflow.com/a/7557433 @ 27 Mar 2016 */ - isElementInViewport: function (el) { - // special bonus for those using jQuery - if (typeof jQuery === 'function' && el instanceof jQuery) { - el = el[0]; - } - - const rect = el.getBoundingClientRect(); - - return ( - rect.top >= 0 && - rect.left >= 0 && - rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /* or $(window).height() */ - rect.right <= (window.innerWidth || document.documentElement.clientWidth) /* or $(window).width() */ - ); - }, - - // get all the url params in a single key/value hash - params: function (options) { - const hash = {}; - - options = options || {}; - options.skipToType = options.skipToType || {}; - - let searchStr = window.location.search; - if (options.hasOwnProperty('url')) { - if (options.url) { - const a = utils.urlToLocation(options.url); - searchStr = a ? a.search : ''; - } else { - searchStr = ''; - } - } - const params = searchStr.substring(1).split('&'); - - params.forEach(function (param) { - const val = param.split('='); - let key = decodeURIComponent(val[0]); - const value = ( - options.disableToType || - options.skipToType[key] ? decodeURIComponent(val[1]) : utils.toType(decodeURIComponent(val[1])) - ); - - if (key) { - if (key.slice(-2) === '[]') { - key = key.slice(0, -2); - } - if (!hash[key]) { - hash[key] = value; - } else { - if (!Array.isArray(hash[key])) { - hash[key] = [hash[key]]; - } - hash[key].push(value); - } - } - }); - return hash; - }, - - param: function (key) { - return this.params()[key]; - }, - - urlToLocation: function (url) { - const a = document.createElement('a'); - a.href = url; - return a; - }, - - // return boolean if string 'true' or string 'false', or if a parsable string which is a number - // also supports JSON object and/or arrays parsing - toType: function (str) { - const type = typeof str; - if (type !== 'string') { - return str; - } - const nb = parseFloat(str); - if (!isNaN(nb) && isFinite(str)) { - return nb; - } - if (str === 'false') { - return false; - } - if (str === 'true') { - return true; - } - - try { - str = JSON.parse(str); - } catch (e) {} +utils.makeNumbersHumanReadable = function (elements) { + elements.each(function () { + $(this) + .html(utils.makeNumberHumanReadable($(this).attr('title'))) + .removeClass('hidden'); + }); +}; - return str; - }, +utils.addCommasToNumbers = function (elements) { + elements.each(function (index, element) { + $(element) + .html(utils.addCommas($(element).html())) + .removeClass('hidden'); + }); +}; - // Safely get/set chained properties on an object - // set example: utils.props(A, 'a.b.c.d', 10) // sets A to {a: {b: {c: {d: 10}}}}, and returns 10 - // get example: utils.props(A, 'a.b.c') // returns {d: 10} - // get example: utils.props(A, 'a.b.c.foo.bar') // returns undefined without throwing a TypeError - // credits to github.com/gkindel - props: function (obj, props, value) { - if (obj === undefined) { - obj = window; - } - if (props == null) { - return undefined; - } - const i = props.indexOf('.'); - if (i === -1) { - if (value !== undefined) { - obj[props] = value; - } - return obj[props]; - } - const prop = props.slice(0, i); - const newProps = props.slice(i + 1); +utils.findBootstrapEnvironment = function () { + // http://stackoverflow.com/questions/14441456/how-to-detect-which-device-view-youre-on-using-twitter-bootstrap-api + const envs = ['xs', 'sm', 'md', 'lg']; + const $el = $('
    '); - if (props !== undefined && !(obj[prop] instanceof Object)) { - obj[prop] = {}; - } + $el.appendTo($('body')); - return utils.props(obj[prop], newProps, value); - }, + for (let i = envs.length - 1; i >= 0; i -= 1) { + const env = envs[i]; - isInternalURI: function (targetLocation, referenceLocation, relative_path) { - return targetLocation.host === '' || // Relative paths are always internal links - ( - targetLocation.host === referenceLocation.host && - // Otherwise need to check if protocol and host match - targetLocation.protocol === referenceLocation.protocol && - // Subfolder installs need this additional check - (relative_path.length > 0 ? targetLocation.pathname.indexOf(relative_path) === 0 : true) - ); - }, + $el.addClass('hidden-' + env); + if ($el.is(':hidden')) { + $el.remove(); + return env; + } + } +}; - rtrim: function (str) { - return str.replace(/\s+$/g, ''); - }, +utils.isMobile = function () { + const env = utils.findBootstrapEnvironment(); + return ['xs', 'sm'].some(function (targetEnv) { + return targetEnv === env; + }); +}; + +utils.assertPasswordValidity = (password) => { + // More checks on top of basic utils.isPasswordValid() + if (!utils.isPasswordValid(password)) { + throw new Error('[[user:change_password_error]]'); + } else if (password.length < ajaxify.data.minimumPasswordLength) { + throw new Error('[[reset_password:password_too_short]]'); + } else if (password.length > 512) { + throw new Error('[[error:password-too-long]]'); + } - debounce: function (func, wait, immediate) { - // modified from https://davidwalsh.name/javascript-debounce-function - let timeout; - return function () { - const context = this; - const args = arguments; - const later = function () { - timeout = null; - if (!immediate) { - func.apply(context, args); - } - }; - const callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) { - func.apply(context, args); - } - }; - }, - throttle: function (func, wait, immediate) { - let timeout; - return function () { - const context = this; - const args = arguments; - const later = function () { - timeout = null; - if (!immediate) { - func.apply(context, args); - } - }; - const callNow = immediate && !timeout; - if (!timeout) { - timeout = setTimeout(later, wait); - } - if (callNow) { - func.apply(context, args); - } - }; - }, - }; + const passwordStrength = zxcvbn(password); + if (passwordStrength.score < ajaxify.data.minimumPasswordStrength) { + throw new Error('[[user:weak_password]]'); + } +}; - return utils; -})); +module.exports = utils; diff --git a/public/src/widgets.js b/public/src/widgets.js index 95eaf2f888..e6612a2952 100644 --- a/public/src/widgets.js +++ b/public/src/widgets.js @@ -1,55 +1,52 @@ 'use strict'; -(function (ajaxify) { - ajaxify.widgets = {}; +module.exports.render = function (template) { + if (template.match(/^admin/)) { + return; + } - ajaxify.widgets.render = function (template) { - if (template.match(/^admin/)) { + const locations = Object.keys(ajaxify.data.widgets); + + locations.forEach(function (location) { + let area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); + if (area.length) { return; } - const locations = Object.keys(ajaxify.data.widgets); + const widgetsAtLocation = ajaxify.data.widgets[location] || []; + let html = ''; - locations.forEach(function (location) { - let area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); - if (area.length) { - return; - } + widgetsAtLocation.forEach(function (widget) { + html += widget.html; + }); - const widgetsAtLocation = ajaxify.data.widgets[location] || []; - let html = ''; - - widgetsAtLocation.forEach(function (widget) { - html += widget.html; - }); - - if (location === 'footer' && !$('#content [widget-area="footer"],#content [data-widget-area="footer"]').length) { - $('#content').append($('
    ')); - } else if (location === 'sidebar' && !$('#content [widget-area="sidebar"],#content [data-widget-area="sidebar"]').length) { - if ($('[component="account/cover"]').length) { - $('[component="account/cover"]').nextAll().wrapAll($('
    ')); - } else if ($('[component="groups/cover"]').length) { - $('[component="groups/cover"]').nextAll().wrapAll($('
    ')); - } else { - $('#content > *').wrapAll($('
    ')); - } - } else if (location === 'header' && !$('#content [widget-area="header"],#content [data-widget-area="header"]').length) { - $('#content').prepend($('
    ')); + if (location === 'footer' && !$('#content [widget-area="footer"],#content [data-widget-area="footer"]').length) { + $('#content').append($('
    ')); + } else if (location === 'sidebar' && !$('#content [widget-area="sidebar"],#content [data-widget-area="sidebar"]').length) { + if ($('[component="account/cover"]').length) { + $('[component="account/cover"]').nextAll().wrapAll($('
    ')); + } else if ($('[component="groups/cover"]').length) { + $('[component="groups/cover"]').nextAll().wrapAll($('
    ')); + } else { + $('#content > *').wrapAll($('
    ')); } + } else if (location === 'header' && !$('#content [widget-area="header"],#content [data-widget-area="header"]').length) { + $('#content').prepend($('
    ')); + } - area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); - if (html && area.length) { - area.html(html); - area.find('img:not(.not-responsive)').addClass('img-responsive'); - } + area = $('#content [widget-area="' + location + '"],#content [data-widget-area="' + location + '"]').eq(0); + if (html && area.length) { + area.html(html); + area.find('img:not(.not-responsive)').addClass('img-responsive'); + } - if (widgetsAtLocation.length) { - area.removeClass('hidden'); - } - }); + if (widgetsAtLocation.length) { + area.removeClass('hidden'); + } + }); + + require(['hooks'], function (hooks) { + hooks.fire('action:widgets.loaded', {}); + }); +}; - require(['hooks'], function (hooks) { - hooks.fire('action:widgets.loaded', {}); - }); - }; -}(ajaxify || {})); diff --git a/src/api/flags.js b/src/api/flags.js index 2fe37ea51f..1d77470628 100644 --- a/src/api/flags.js +++ b/src/api/flags.js @@ -28,7 +28,7 @@ flagsApi.create = async (caller, data) => { flagsApi.update = async (caller, data) => { const allowed = await user.isPrivileged(caller.uid); if (!allowed) { - throw new Error('[[no-privileges]]'); + throw new Error('[[error:no-privileges]]'); } const { flagId } = data; diff --git a/src/cli/index.js b/src/cli/index.js index 7b5670dc75..b8b37ce4ea 100644 --- a/src/cli/index.js +++ b/src/cli/index.js @@ -181,6 +181,7 @@ program .command('build [targets...]') .description(`Compile static assets ${chalk.red('(JS, CSS, templates, languages)')}`) .option('-s, --series', 'Run builds in series without extra processes') + .option('-w, --webpack', 'Bundle assets with webpack', true) .action((targets, options) => { if (program.opts().dev) { process.env.NODE_ENV = 'development'; diff --git a/src/controllers/admin/settings.js b/src/controllers/admin/settings.js index d4abecd479..719f6e206a 100644 --- a/src/controllers/admin/settings.js +++ b/src/controllers/admin/settings.js @@ -11,7 +11,7 @@ const navigationAdmin = require('../../navigation/admin'); const social = require('../../social'); const helpers = require('../helpers'); -const translator = require('../../../public/src/modules/translator'); +const translator = require('../../translator'); const settingsController = module.exports; diff --git a/src/controllers/write/flags.js b/src/controllers/write/flags.js index ea14abe942..3bda63888e 100644 --- a/src/controllers/write/flags.js +++ b/src/controllers/write/flags.js @@ -30,6 +30,11 @@ Flags.update = async (req, res) => { helpers.formatApiResponse(200, res, { history }); }; +Flags.delete = async (req, res) => { + await flags.purge([req.params.flagId]); + helpers.formatApiResponse(200, res); +}; + Flags.appendNote = async (req, res) => { const payload = await api.flags.appendNote(req, { flagId: req.params.flagId, diff --git a/src/flags.js b/src/flags.js index 211496ce50..932905067d 100644 --- a/src/flags.js +++ b/src/flags.js @@ -15,7 +15,7 @@ const topics = require('./topics'); const posts = require('./posts'); const privileges = require('./privileges'); const plugins = require('./plugins'); -const utils = require('../public/src/utils'); +const utils = require('./utils'); const batch = require('./batch'); const Flags = module.exports; @@ -545,6 +545,27 @@ Flags.exists = async function (type, id, uid) { return await db.isSortedSetMember('flags:hash', [type, id, uid].join(':')); }; +Flags.canView = async (flagId, uid) => { + const exists = await db.isSortedSetMember('flags:datetime', flagId); + if (!exists) { + return false; + } + + const [{ type, targetId }, isAdminOrGlobalMod] = await Promise.all([ + db.getObject(`flag:${flagId}`), + user.isAdminOrGlobalMod(uid), + ]); + + if (type === 'post') { + const cid = await Flags.getTargetCid(type, targetId); + const isModerator = await user.isModerator(uid, cid); + + return isAdminOrGlobalMod || isModerator; + } + + return isAdminOrGlobalMod; +}; + Flags.canFlag = async function (type, id, uid, skipLimitCheck = false) { const limit = meta.config['flags:limitPerTarget']; if (!skipLimitCheck && limit > 0) { diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000000..b75e950c26 --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = require('../public/src/modules/helpers.common')( + require('./utils'), + require('benchpressjs'), + require('nconf').get('relative_path'), +); diff --git a/src/meta/build.js b/src/meta/build.js index 207318fe56..eae68b4d12 100644 --- a/src/meta/build.js +++ b/src/meta/build.js @@ -176,6 +176,11 @@ exports.build = async function (targets, options) { const startTime = Date.now(); await buildTargets(targets, !series); + + if (options.webpack) { + await exports.webpack(options); + } + const totalTime = (Date.now() - startTime) / 1000; await cacheBuster.write(); winston.info(`[build] Asset compilation successful. Completed in ${totalTime}sec.`); @@ -185,8 +190,54 @@ exports.build = async function (targets, options) { } }; +function getWebpackConfig() { + return require(process.env.NODE_ENV !== 'development' ? '../../webpack.prod' : '../../webpack.dev'); +} + +exports.webpack = async function (options) { + winston.info(`[build] ${(options.watch ? 'Watching' : 'Bundling')} with Webpack.`); + const webpack = require('webpack'); + const fs = require('fs'); + const util = require('util'); + const db = require('../database'); + + const activePlugins = await db.getSortedSetRange('plugins:active', 0, -1); + if (!activePlugins.includes('nodebb-plugin-composer-default')) { + activePlugins.push('nodebb-plugin-composer-default'); + } + await fs.promises.writeFile(path.resolve(__dirname, '../../build/active_plugins.json'), JSON.stringify(activePlugins)); + + const webpackCfg = getWebpackConfig(); + const compiler = webpack(webpackCfg); + const webpackRun = util.promisify(compiler.run).bind(compiler); + const webpackWatch = util.promisify(compiler.watch).bind(compiler); + try { + let stats; + if (options.watch) { + stats = await webpackWatch(webpackCfg.watchOptions); + compiler.hooks.assetEmitted.tap('nbbWatchPlugin', (file) => { + console.log(`webpack:assetEmitted > ${webpackCfg.output.publicPath}${file}`); + }); + } else { + stats = await webpackRun(); + } + + if (stats.hasErrors() || stats.hasWarnings()) { + console.log(stats.toString('minimal')); + } else { + const statsJson = stats.toJson(); + winston.info(`[build] ${(options.watch ? 'Watching' : 'Bundling')} took ${statsJson.time} ms`); + } + } catch (err) { + console.error(err.stack || err); + if (err.details) { + console.error(err.details); + } + } +}; + exports.buildAll = async function () { - await exports.build(allTargets); + await exports.build(allTargets, { webpack: true }); }; require('../promisify')(exports); diff --git a/src/meta/js.js b/src/meta/js.js index 44aee1bea4..69ab76f369 100644 --- a/src/meta/js.js +++ b/src/meta/js.js @@ -16,102 +16,15 @@ const JS = module.exports; JS.scripts = { base: [ - 'node_modules/socket.io-client/dist/socket.io.js', - 'node_modules/requirejs/require.js', - 'public/src/require-config.js', - 'node_modules/jquery/dist/jquery.js', - 'node_modules/textcomplete/dist/textcomplete.min.js', - 'node_modules/textcomplete.contenteditable/dist/textcomplete.codemirror.min.js', - 'node_modules/visibilityjs/lib/visibility.core.js', - 'node_modules/bootstrap/dist/js/bootstrap.js', 'node_modules/@adactive/bootstrap-tagsinput/src/bootstrap-tagsinput.js', - 'node_modules/benchpressjs/build/benchpress.js', 'node_modules/jquery-serializeobject/jquery.serializeObject.js', 'node_modules/jquery-deserialize/src/jquery.deserialize.js', - 'public/vendor/bootbox/wrapper.js', - - 'public/src/utils.js', - 'public/src/sockets.js', - 'public/src/app.js', - 'public/src/ajaxify.js', - 'public/src/overrides.js', - 'public/src/widgets.js', ], - // files listed below are only available client-side, or are bundled in to reduce # of network requests on cold load - rjs: [ - 'public/src/client/header.js', - 'public/src/client/header/chat.js', - 'public/src/client/header/notifications.js', - 'public/src/client/infinitescroll.js', - 'public/src/client/pagination.js', - 'public/src/client/recent.js', - 'public/src/client/unread.js', - 'public/src/client/topic.js', - 'public/src/client/topic/events.js', - 'public/src/client/topic/posts.js', - 'public/src/client/topic/images.js', - 'public/src/client/topic/votes.js', - 'public/src/client/topic/postTools.js', - 'public/src/client/topic/threadTools.js', - 'public/src/client/categories.js', - 'public/src/client/category.js', - 'public/src/client/category/tools.js', - - 'public/src/modules/translator.js', - 'public/src/modules/components.js', - 'public/src/modules/hooks.js', - 'public/src/modules/sort.js', - 'public/src/modules/navigator.js', - 'public/src/modules/topicSelect.js', - 'public/src/modules/topicList.js', - 'public/src/modules/categoryFilter.js', - 'public/src/modules/categorySelector.js', - 'public/src/modules/categorySearch.js', - 'public/src/modules/share.js', - 'public/src/modules/alerts.js', - 'public/src/modules/taskbar.js', - 'public/src/modules/helpers.js', - 'public/src/modules/storage.js', - 'public/src/modules/handleBack.js', - 'public/src/modules/messages.js', - 'public/src/modules/search.js', - ], - - admin: [ - 'node_modules/material-design-lite/material.js', - 'public/src/admin/admin.js', - 'node_modules/jquery-deserialize/src/jquery.deserialize.js', - ], - - // modules listed below are built (/src/modules) so they can be defined anonymously - modules: { - 'Chart.js': 'node_modules/chart.js/dist/Chart.min.js', - 'mousetrap.js': 'node_modules/mousetrap/mousetrap.min.js', - 'cropper.js': 'node_modules/cropperjs/dist/cropper.min.js', - 'jquery-ui': 'node_modules/jquery-ui/ui', - 'zxcvbn.js': 'node_modules/zxcvbn/dist/zxcvbn.js', - - // only get ace files required by acp - 'ace/ace.js': 'node_modules/ace-builds/src-min/ace.js', - 'ace/mode-less.js': 'node_modules/ace-builds/src-min/mode-less.js', - 'ace/mode-javascript.js': 'node_modules/ace-builds/src-min/mode-javascript.js', - 'ace/mode-html.js': 'node_modules/ace-builds/src-min/mode-html.js', - 'ace/theme-twilight.js': 'node_modules/ace-builds/src-min/theme-twilight.js', - 'ace/worker-css.js': 'node_modules/ace-builds/src-min/worker-css.js', - 'ace/worker-javascript.js': 'node_modules/ace-builds/src-min/worker-javascript.js', - 'ace/worker-html.js': 'node_modules/ace-builds/src-min/worker-html.js', - 'ace/ext-searchbox.js': 'node_modules/ace-builds/src-min/ext-searchbox.js', - - 'clipboard.js': 'node_modules/clipboard/dist/clipboard.min.js', - 'tinycon.js': 'node_modules/tinycon/tinycon.js', - 'slideout.js': 'node_modules/slideout/dist/slideout.min.js', - 'compare-versions.js': 'node_modules/compare-versions/index.js', - 'timeago/locales': 'node_modules/timeago/locales', - 'jquery-form.js': 'node_modules/jquery-form/dist/jquery.form.min.js', - 'xregexp.js': 'node_modules/xregexp/xregexp-all.js', - }, + // plugins add entries into this object, + // they get linked into /build/public/src/modules + modules: { }, }; async function linkIfLinux(srcPath, destPath) { @@ -124,35 +37,13 @@ async function linkIfLinux(srcPath, destPath) { const basePath = path.resolve(__dirname, '../..'); -async function minifyModules(modules, fork) { - const moduleDirs = modules.reduce((prev, mod) => { - const dir = path.resolve(path.dirname(mod.destPath)); - if (!prev.includes(dir)) { - prev.push(dir); - } - return prev; - }, []); - - await Promise.all(moduleDirs.map(dir => mkdirp(dir))); - - const filtered = modules.reduce((prev, mod) => { - if (mod.srcPath.endsWith('.min.js') || path.dirname(mod.srcPath).endsWith('min')) { - prev.skip.push(mod); - } else { - prev.minify.push(mod); - } - - return prev; - }, { minify: [], skip: [] }); +async function linkModules() { + const { modules } = JS.scripts; await Promise.all([ - minifier.js.minifyBatch(filtered.minify, fork), - ...filtered.skip.map(mod => linkIfLinux(mod.srcPath, mod.destPath)), + mkdirp(path.join(__dirname, '../../build/public/src/admin/plugins')), + mkdirp(path.join(__dirname, '../../build/public/src/client/plugins')), ]); -} - -async function linkModules() { - const { modules } = JS.scripts; await Promise.all(Object.keys(modules).map(async (relPath) => { const srcPath = path.join(__dirname, '../../', modules[relPath]); @@ -163,56 +54,14 @@ async function linkModules() { ]); if (stats.isDirectory()) { await file.linkDirs(srcPath, destPath, true); - return; + } else { + await linkIfLinux(srcPath, destPath); } - - await linkIfLinux(srcPath, destPath); })); } const moduleDirs = ['modules', 'admin', 'client']; -async function getModuleList() { - let modules = Object.keys(JS.scripts.modules).map(relPath => ({ - srcPath: path.join(__dirname, '../../', JS.scripts.modules[relPath]), - destPath: path.join(__dirname, '../../build/public/src/modules', relPath), - })); - - const coreDirs = moduleDirs.map(dir => ({ - srcPath: path.join(__dirname, '../../public/src', dir), - destPath: path.join(__dirname, '../../build/public/src', dir), - })); - - modules = modules.concat(coreDirs); - - const moduleFiles = []; - await Promise.all(modules.map(async (module) => { - const { srcPath } = module; - const { destPath } = module; - - const stats = await fs.promises.stat(srcPath); - if (!stats.isDirectory()) { - moduleFiles.push(module); - return; - } - - const files = await file.walk(srcPath); - - const mods = files.filter( - filePath => path.extname(filePath) === '.js' - ).map(filePath => ({ - srcPath: path.normalize(filePath), - destPath: path.join(destPath, path.relative(srcPath, filePath)), - })); - - moduleFiles.push(...mods); - })); - moduleFiles.forEach((mod) => { - mod.filename = path.relative(basePath, mod.srcPath).replace(/\\/g, '/'); - }); - return moduleFiles; -} - async function clearModules() { const builtPaths = moduleDirs.map( p => path.join(__dirname, '../../build/public/src', p) @@ -222,64 +71,17 @@ async function clearModules() { ); } -JS.buildModules = async function (fork) { +JS.buildModules = async function () { await clearModules(); - if (process.env.NODE_ENV === 'development') { - await linkModules(); - return; - } - const modules = await getModuleList(); - await minifyModules(modules, fork); -}; - -async function requirejsOptimize(target) { - const requirejs = require('requirejs'); - let scriptText = ''; - const sharedCfg = { - paths: { - jquery: 'empty:', - }, - optimize: 'none', - out: function (text) { - scriptText += text; - }, - }; - const bundledModules = [ - { - baseUrl: path.join(basePath, 'node_modules'), - name: 'timeago/jquery.timeago', - }, - { - baseUrl: path.join(basePath, 'node_modules/nprogress'), - name: 'nprogress', - }, - { - baseUrl: path.join(basePath, 'node_modules/bootbox'), - name: 'bootbox', - }, - ]; - const targetModules = { - admin: [ - { - baseUrl: path.join(basePath, 'node_modules/sortablejs'), - name: 'Sortable', - }, - ], - client: [], - }; - const optimizeAsync = util.promisify((config, cb) => { - requirejs.optimize(config, () => cb(), err => cb(err)); - }); - const allModules = bundledModules.concat(targetModules[target]); + const fse = require('fs-extra'); + await fse.copy( + path.join(__dirname, `../../public/src`), + path.join(__dirname, `../../build/public/src`) + ); - for (const moduleCfg of allModules) { - // eslint-disable-next-line no-await-in-loop - await optimizeAsync({ ...sharedCfg, ...moduleCfg }); - } - const filePath = path.join(__dirname, `../../build/public/rjs-bundle-${target}.js`); - await fs.promises.writeFile(filePath, scriptText); -} + await linkModules(); +}; JS.linkStatics = async function () { await rimrafAsync(path.join(__dirname, '../../build/public/plugins')); @@ -313,15 +115,7 @@ async function getBundleScriptList(target) { pluginScripts = pluginScripts.concat(scripts); })); - let scripts = JS.scripts.base; - - if (target === 'client') { - scripts = scripts.concat(JS.scripts.rjs); - } else if (target === 'acp') { - scripts = scripts.concat(JS.scripts.admin); - } - - scripts = scripts.concat(pluginScripts).map((script) => { + pluginScripts = JS.scripts.base.concat(pluginScripts).map((script) => { const srcPath = path.resolve(basePath, script).replace(/\\/g, '/'); return { srcPath: srcPath, @@ -329,29 +123,18 @@ async function getBundleScriptList(target) { }; }); - return scripts; + return pluginScripts; } JS.buildBundle = async function (target, fork) { - const fileNames = { - client: 'nodebb.min.js', - admin: 'acp.min.js', - }; - await requirejsOptimize(target); + const filename = `scripts-${target}.js`; const files = await getBundleScriptList(target); - - const srcPath = path.join(__dirname, `../../build/public/rjs-bundle-${target}.js`); - files.push({ - srcPath: srcPath, - filename: path.relative(basePath, srcPath).replace(/\\/g, '/'), - }); - - const minify = process.env.NODE_ENV !== 'development'; - const filePath = path.join(__dirname, '../../build/public', fileNames[target]); + const minify = false; // webpack will minify in prod + const filePath = path.join(__dirname, '../../build/public', filename); await minifier.js.bundle({ files: files, - filename: fileNames[target], + filename: filename, destPath: filePath, }, minify, fork); }; diff --git a/src/middleware/admin.js b/src/middleware/admin.js index 0f77f0121e..ed49408be8 100644 --- a/src/middleware/admin.js +++ b/src/middleware/admin.js @@ -9,7 +9,7 @@ const user = require('../user'); const meta = require('../meta'); const plugins = require('../plugins'); const privileges = require('../privileges'); -const utils = require('../../public/src/utils'); +const utils = require('../utils'); const versions = require('../admin/versions'); const helpers = require('./helpers'); diff --git a/src/middleware/assert.js b/src/middleware/assert.js index 12576650ef..f91fde93f4 100644 --- a/src/middleware/assert.js +++ b/src/middleware/assert.js @@ -8,13 +8,13 @@ const path = require('path'); const nconf = require('nconf'); -const db = require('../database'); const file = require('../file'); const user = require('../user'); const groups = require('../groups'); const topics = require('../topics'); const posts = require('../posts'); const messaging = require('../messaging'); +const flags = require('../flags'); const slugify = require('../slugify'); const helpers = require('./helpers'); @@ -56,7 +56,8 @@ Assert.post = helpers.try(async (req, res, next) => { }); Assert.flag = helpers.try(async (req, res, next) => { - if (!await db.isSortedSetMember('flags:datetime', req.params.flagId)) { + const canView = await flags.canView(req.params.flagId, req.uid); + if (!canView) { return controllerHelpers.formatApiResponse(404, res, new Error('[[error:no-flag]]')); } diff --git a/src/plugins/data.js b/src/plugins/data.js index 565b8ba623..ad125fda72 100644 --- a/src/plugins/data.js +++ b/src/plugins/data.js @@ -118,8 +118,7 @@ Data.getStaticDirectories = async function (pluginData) { } await Promise.all(dirs.map(route => processDir(route))); - winston.verbose(`[plugins] found ${Object.keys(staticDirs).length - } static directories for ${pluginData.id}`); + winston.verbose(`[plugins] found ${Object.keys(staticDirs).length} static directories for ${pluginData.id}`); return staticDirs; }; diff --git a/src/privileges/admin.js b/src/privileges/admin.js index 3cf42fa7a8..9c05749caa 100644 --- a/src/privileges/admin.js +++ b/src/privileges/admin.js @@ -11,34 +11,24 @@ const utils = require('../utils'); const privsAdmin = module.exports; -privsAdmin.privilegeLabels = [ - { name: '[[admin/manage/privileges:admin-dashboard]]' }, - { name: '[[admin/manage/privileges:admin-categories]]' }, - { name: '[[admin/manage/privileges:admin-privileges]]' }, - { name: '[[admin/manage/privileges:admin-admins-mods]]' }, - { name: '[[admin/manage/privileges:admin-users]]' }, - { name: '[[admin/manage/privileges:admin-groups]]' }, - { name: '[[admin/manage/privileges:admin-tags]]' }, - { name: '[[admin/manage/privileges:admin-settings]]' }, -]; - -privsAdmin.userPrivilegeList = [ - 'admin:dashboard', - 'admin:categories', - 'admin:privileges', - 'admin:admins-mods', - 'admin:users', - 'admin:groups', - 'admin:tags', - 'admin:settings', -]; - -privsAdmin.groupPrivilegeList = privsAdmin.userPrivilegeList.map(privilege => `groups:${privilege}`); - -privsAdmin.privilegeList = privsAdmin.userPrivilegeList.concat(privsAdmin.groupPrivilegeList); - -privsAdmin.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.admin.list', privsAdmin.userPrivilegeList.slice()); -privsAdmin.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.admin.groups.list', privsAdmin.groupPrivilegeList.slice()); +/** + * Looking to add a new admin privilege via plugin/theme? Attach a hook to + * `static:privileges.admin.init` and call .set() on the privilege map passed + * in to your listener. + */ +const _privilegeMap = new Map([ + ['admin:dashboard', { label: '[[admin/manage/privileges:admin-dashboard]]' }], + ['admin:categories', { label: '[[admin/manage/privileges:admin-categories]]' }], + ['admin:privileges', { label: '[[admin/manage/privileges:admin-privileges]]' }], + ['admin:admins-mods', { label: '[[admin/manage/privileges:admin-admins-mods]]' }], + ['admin:users', { label: '[[admin/manage/privileges:admin-users]]' }], + ['admin:groups', { label: '[[admin/manage/privileges:admin-groups]]' }], + ['admin:tags', { label: '[[admin/manage/privileges:admin-tags]]' }], + ['admin:settings', { label: '[[admin/manage/privileges:admin-settings]]' }], +]); + +privsAdmin.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.admin.list', Array.from(_privilegeMap.keys())); +privsAdmin.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.admin.groups.list', Array.from(_privilegeMap.keys()).map(privilege => `groups:${privilege}`)); privsAdmin.getPrivilegeList = async () => { const [user, group] = await Promise.all([ privsAdmin.getUserPrivilegeList(), @@ -47,6 +37,12 @@ privsAdmin.getPrivilegeList = async () => { return user.concat(group); }; +privsAdmin.init = async () => { + await plugins.hooks.fire('static:privileges.admin.init', { + privileges: _privilegeMap, + }); +}; + // Mapping for a page route (via direct match or regexp) to a privilege privsAdmin.routeMap = { dashboard: 'admin:dashboard', @@ -118,13 +114,13 @@ privsAdmin.resolve = (path) => { }; privsAdmin.list = async function (uid) { - const privilegeLabels = privsAdmin.privilegeLabels.slice(); - const userPrivilegeList = privsAdmin.userPrivilegeList.slice(); - const groupPrivilegeList = privsAdmin.groupPrivilegeList.slice(); + const privilegeLabels = Array.from(_privilegeMap.values()).map(data => data.label); + const userPrivilegeList = await privsAdmin.getUserPrivilegeList(); + const groupPrivilegeList = await privsAdmin.getGroupPrivilegeList(); // Restrict privileges column to superadmins if (!(await user.isAdministrator(uid))) { - const idx = privsAdmin.userPrivilegeList.indexOf('admin:privileges'); + const idx = Array.from(_privilegeMap.keys()).indexOf('admin:privileges'); privilegeLabels.splice(idx, 1); userPrivilegeList.splice(idx, 1); groupPrivilegeList.splice(idx, 1); @@ -135,10 +131,10 @@ privsAdmin.list = async function (uid) { groups: plugins.hooks.fire('filter:privileges.admin.groups.list_human', privilegeLabels.slice()), }); - const keys = await utils.promiseParallel({ - users: plugins.hooks.fire('filter:privileges.admin.list', userPrivilegeList.slice()), - groups: plugins.hooks.fire('filter:privileges.admin.groups.list', groupPrivilegeList.slice()), - }); + const keys = { + users: userPrivilegeList, + groups: groupPrivilegeList, + }; const payload = await utils.promiseParallel({ labels, diff --git a/src/privileges/categories.js b/src/privileges/categories.js index f38291cb7b..92b0edd849 100644 --- a/src/privileges/categories.js +++ b/src/privileges/categories.js @@ -12,50 +12,32 @@ const utils = require('../utils'); const privsCategories = module.exports; -privsCategories.privilegeLabels = [ - { name: '[[admin/manage/privileges:find-category]]' }, - { name: '[[admin/manage/privileges:access-category]]' }, - { name: '[[admin/manage/privileges:access-topics]]' }, - { name: '[[admin/manage/privileges:create-topics]]' }, - { name: '[[admin/manage/privileges:reply-to-topics]]' }, - { name: '[[admin/manage/privileges:schedule-topics]]' }, - { name: '[[admin/manage/privileges:tag-topics]]' }, - { name: '[[admin/manage/privileges:edit-posts]]' }, - { name: '[[admin/manage/privileges:view-edit-history]]' }, - { name: '[[admin/manage/privileges:delete-posts]]' }, - { name: '[[admin/manage/privileges:upvote-posts]]' }, - { name: '[[admin/manage/privileges:downvote-posts]]' }, - { name: '[[admin/manage/privileges:delete-topics]]' }, - { name: '[[admin/manage/privileges:view_deleted]]' }, - { name: '[[admin/manage/privileges:purge]]' }, - { name: '[[admin/manage/privileges:moderate]]' }, -]; - -privsCategories.userPrivilegeList = [ - 'find', - 'read', - 'topics:read', - 'topics:create', - 'topics:reply', - 'topics:schedule', - 'topics:tag', - 'posts:edit', - 'posts:history', - 'posts:delete', - 'posts:upvote', - 'posts:downvote', - 'topics:delete', - 'posts:view_deleted', - 'purge', - 'moderate', -]; - -privsCategories.groupPrivilegeList = privsCategories.userPrivilegeList.map(privilege => `groups:${privilege}`); - -privsCategories.privilegeList = privsCategories.userPrivilegeList.concat(privsCategories.groupPrivilegeList); - -privsCategories.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.list', privsCategories.userPrivilegeList.slice()); -privsCategories.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.groups.list', privsCategories.groupPrivilegeList.slice()); +/** + * Looking to add a new category privilege via plugin/theme? Attach a hook to + * `static:privileges.category.init` and call .set() on the privilege map passed + * in to your listener. + */ +const _privilegeMap = new Map([ + ['find', { label: '[[admin/manage/privileges:find-category]]' }], + ['read', { label: '[[admin/manage/privileges:access-category]]' }], + ['topics:read', { label: '[[admin/manage/privileges:access-topics]]' }], + ['topics:create', { label: '[[admin/manage/privileges:create-topics]]' }], + ['topics:reply', { label: '[[admin/manage/privileges:reply-to-topics]]' }], + ['topics:schedule', { label: '[[admin/manage/privileges:schedule-topics]]' }], + ['topics:tag', { label: '[[admin/manage/privileges:tag-topics]]' }], + ['posts:edit', { label: '[[admin/manage/privileges:edit-posts]]' }], + ['posts:history', { label: '[[admin/manage/privileges:view-edit-history]]' }], + ['posts:delete', { label: '[[admin/manage/privileges:delete-posts]]' }], + ['posts:upvote', { label: '[[admin/manage/privileges:upvote-posts]]' }], + ['posts:downvote', { label: '[[admin/manage/privileges:downvote-posts]]' }], + ['topics:delete', { label: '[[admin/manage/privileges:delete-topics]]' }], + ['posts:view_deleted', { label: '[[admin/manage/privileges:view_deleted]]' }], + ['purge', { label: '[[admin/manage/privileges:purge]]' }], + ['moderate', { label: '[[admin/manage/privileges:moderate]]' }], +]); + +privsCategories.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.list', Array.from(_privilegeMap.keys())); +privsCategories.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.groups.list', Array.from(_privilegeMap.keys()).map(privilege => `groups:${privilege}`)); privsCategories.getPrivilegeList = async () => { const [user, group] = await Promise.all([ privsCategories.getUserPrivilegeList(), @@ -64,16 +46,23 @@ privsCategories.getPrivilegeList = async () => { return user.concat(group); }; +privsCategories.init = async () => { + await plugins.hooks.fire('static:privileges.categories.init', { + privileges: _privilegeMap, + }); +}; + // Method used in admin/category controller to show all users/groups with privs in that given cid privsCategories.list = async function (cid) { - const labels = await utils.promiseParallel({ - users: plugins.hooks.fire('filter:privileges.list_human', privsCategories.privilegeLabels.slice()), - groups: plugins.hooks.fire('filter:privileges.groups.list_human', privsCategories.privilegeLabels.slice()), + let labels = Array.from(_privilegeMap.values()).map(data => data.label); + labels = await utils.promiseParallel({ + users: plugins.hooks.fire('filter:privileges.list_human', labels.slice()), + groups: plugins.hooks.fire('filter:privileges.groups.list_human', labels.slice()), }); const keys = await utils.promiseParallel({ - users: plugins.hooks.fire('filter:privileges.list', privsCategories.userPrivilegeList.slice()), - groups: plugins.hooks.fire('filter:privileges.groups.list', privsCategories.groupPrivilegeList.slice()), + users: privsCategories.getUserPrivilegeList(), + groups: privsCategories.getGroupPrivilegeList(), }); const payload = await utils.promiseParallel({ @@ -83,8 +72,8 @@ privsCategories.list = async function (cid) { }); payload.keys = keys; - payload.columnCountUserOther = payload.labels.users.length - privsCategories.privilegeLabels.length; - payload.columnCountGroupOther = payload.labels.groups.length - privsCategories.privilegeLabels.length; + payload.columnCountUserOther = payload.labels.users.length - labels.users.length; + payload.columnCountGroupOther = payload.labels.groups.length - labels.groups.length; return payload; }; diff --git a/src/privileges/global.js b/src/privileges/global.js index 9fd4358364..472448d4c4 100644 --- a/src/privileges/global.js +++ b/src/privileges/global.js @@ -11,50 +11,32 @@ const utils = require('../utils'); const privsGlobal = module.exports; -privsGlobal.privilegeLabels = [ - { name: '[[admin/manage/privileges:chat]]' }, - { name: '[[admin/manage/privileges:upload-images]]' }, - { name: '[[admin/manage/privileges:upload-files]]' }, - { name: '[[admin/manage/privileges:signature]]' }, - { name: '[[admin/manage/privileges:invite]]' }, - { name: '[[admin/manage/privileges:allow-group-creation]]' }, - { name: '[[admin/manage/privileges:search-content]]' }, - { name: '[[admin/manage/privileges:search-users]]' }, - { name: '[[admin/manage/privileges:search-tags]]' }, - { name: '[[admin/manage/privileges:view-users]]' }, - { name: '[[admin/manage/privileges:view-tags]]' }, - { name: '[[admin/manage/privileges:view-groups]]' }, - { name: '[[admin/manage/privileges:allow-local-login]]' }, - { name: '[[admin/manage/privileges:ban]]' }, - { name: '[[admin/manage/privileges:mute]]' }, - { name: '[[admin/manage/privileges:view-users-info]]' }, -]; - -privsGlobal.userPrivilegeList = [ - 'chat', - 'upload:post:image', - 'upload:post:file', - 'signature', - 'invite', - 'group:create', - 'search:content', - 'search:users', - 'search:tags', - 'view:users', - 'view:tags', - 'view:groups', - 'local:login', - 'ban', - 'mute', - 'view:users:info', -]; - -privsGlobal.groupPrivilegeList = privsGlobal.userPrivilegeList.map(privilege => `groups:${privilege}`); - -privsGlobal.privilegeList = privsGlobal.userPrivilegeList.concat(privsGlobal.groupPrivilegeList); - -privsGlobal.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.global.list', privsGlobal.userPrivilegeList.slice()); -privsGlobal.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.global.groups.list', privsGlobal.groupPrivilegeList.slice()); +/** + * Looking to add a new global privilege via plugin/theme? Attach a hook to + * `static:privileges.global.init` and call .set() on the privilege map passed + * in to your listener. + */ +const _privilegeMap = new Map([ + ['chat', { label: '[[admin/manage/privileges:chat]]' }], + ['upload:post:image', { label: '[[admin/manage/privileges:upload-images]]' }], + ['upload:post:file', { label: '[[admin/manage/privileges:upload-files]]' }], + ['signature', { label: '[[admin/manage/privileges:signature]]' }], + ['invite', { label: '[[admin/manage/privileges:invite]]' }], + ['group:create', { label: '[[admin/manage/privileges:allow-group-creation]]' }], + ['search:content', { label: '[[admin/manage/privileges:search-content]]' }], + ['search:users', { label: '[[admin/manage/privileges:search-users]]' }], + ['search:tags', { label: '[[admin/manage/privileges:search-tags]]' }], + ['view:users', { label: '[[admin/manage/privileges:view-users]]' }], + ['view:tags', { label: '[[admin/manage/privileges:view-tags]]' }], + ['view:groups', { label: '[[admin/manage/privileges:view-groups]]' }], + ['local:login', { label: '[[admin/manage/privileges:allow-local-login]]' }], + ['ban', { label: '[[admin/manage/privileges:ban]]' }], + ['mute', { label: '[[admin/manage/privileges:mute]]' }], + ['view:users:info', { label: '[[admin/manage/privileges:view-users-info]]' }], +]); + +privsGlobal.getUserPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.global.list', Array.from(_privilegeMap.keys())); +privsGlobal.getGroupPrivilegeList = async () => await plugins.hooks.fire('filter:privileges.global.groups.list', Array.from(_privilegeMap.keys()).map(privilege => `groups:${privilege}`)); privsGlobal.getPrivilegeList = async () => { const [user, group] = await Promise.all([ privsGlobal.getUserPrivilegeList(), @@ -63,11 +45,19 @@ privsGlobal.getPrivilegeList = async () => { return user.concat(group); }; +privsGlobal.init = async () => { + privsGlobal._coreSize = _privilegeMap.size; + await plugins.hooks.fire('static:privileges.global.init', { + privileges: _privilegeMap, + }); +}; + privsGlobal.list = async function () { async function getLabels() { + const labels = Array.from(_privilegeMap.values()).map(data => data.label); return await utils.promiseParallel({ - users: plugins.hooks.fire('filter:privileges.global.list_human', privsGlobal.privilegeLabels.slice()), - groups: plugins.hooks.fire('filter:privileges.global.groups.list_human', privsGlobal.privilegeLabels.slice()), + users: plugins.hooks.fire('filter:privileges.global.list_human', labels.slice()), + groups: plugins.hooks.fire('filter:privileges.global.groups.list_human', labels.slice()), }); } @@ -83,8 +73,8 @@ privsGlobal.list = async function () { }); payload.keys = keys; - payload.columnCountUserOther = keys.users.length - privsGlobal.userPrivilegeList.length; - payload.columnCountGroupOther = keys.groups.length - privsGlobal.groupPrivilegeList.length; + payload.columnCountUserOther = keys.users.length - privsGlobal._coreSize; + payload.columnCountGroupOther = keys.groups.length - privsGlobal._coreSize; return payload; }; diff --git a/src/privileges/index.js b/src/privileges/index.js index 6445b07b97..e399e25c9d 100644 --- a/src/privileges/index.js +++ b/src/privileges/index.js @@ -8,4 +8,10 @@ privileges.topics = require('./topics'); privileges.posts = require('./posts'); privileges.users = require('./users'); +privileges.init = async () => { + await privileges.global.init(); + await privileges.admin.init(); + await privileges.categories.init(); +}; + require('../promisify')(privileges); diff --git a/src/routes/index.js b/src/routes/index.js index 3a7e72cdf6..d0b987d837 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -4,6 +4,7 @@ const nconf = require('nconf'); const winston = require('winston'); const path = require('path'); const express = require('express'); +const chalk = require('chalk'); const meta = require('../meta'); const controllers = require('../controllers'); @@ -171,7 +172,6 @@ function addCoreRoutes(app, router, middleware, mounts) { const statics = [ { route: '/assets', path: path.join(__dirname, '../../build/public') }, { route: '/assets', path: path.join(__dirname, '../../public') }, - { route: '/plugins', path: path.join(__dirname, '../../build/public/plugins') }, ]; const staticOptions = { maxAge: app.enabled('cache') ? 5184000000 : 0, @@ -187,6 +187,10 @@ function addCoreRoutes(app, router, middleware, mounts) { app.use(`${relativePath}/uploads`, (req, res) => { res.redirect(`${relativePath}/assets/uploads${req.path}?${meta.config['cache-buster']}`); }); + app.use(`${relativePath}/plugins`, (req, res) => { + winston.warn(`${chalk.bold.red('[deprecation]')} The \`/plugins\` shorthand prefix is deprecated, prefix with \`/assets/plugins\` instead (path: ${req.path})`); + res.redirect(`${relativePath}/assets/plugins${req.path}${req._parsedUrl.search || ''}`); + }); // Skins meta.css.supportedSkins.forEach((skin) => { diff --git a/src/routes/write/flags.js b/src/routes/write/flags.js index a718f8f3f0..6e9c8e6623 100644 --- a/src/routes/write/flags.js +++ b/src/routes/write/flags.js @@ -14,6 +14,7 @@ module.exports = function () { setupApiRoute(router, 'get', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.get); setupApiRoute(router, 'put', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.update); + setupApiRoute(router, 'delete', '/:flagId', [...middlewares, middleware.assert.flag], controllers.write.flags.delete); setupApiRoute(router, 'post', '/:flagId/notes', [...middlewares, middleware.assert.flag], controllers.write.flags.appendNote); setupApiRoute(router, 'delete', '/:flagId/notes/:datetime', [...middlewares, middleware.assert.flag], controllers.write.flags.deleteNote); diff --git a/src/topics/posts.js b/src/topics/posts.js index a8a10ff60d..fcc4355bdf 100644 --- a/src/topics/posts.js +++ b/src/topics/posts.js @@ -10,7 +10,7 @@ const user = require('../user'); const posts = require('../posts'); const meta = require('../meta'); const plugins = require('../plugins'); -const utils = require('../../public/src/utils'); +const utils = require('../utils'); const backlinkRegex = new RegExp(`(?:${nconf.get('url').replace('/', '\\/')}|\b|\\s)\\/topic\\/(\\d+)(?:\\/\\w+)?`, 'g'); diff --git a/src/translator.js b/src/translator.js index 21d4891332..872349a4aa 100644 --- a/src/translator.js +++ b/src/translator.js @@ -1,3 +1,12 @@ 'use strict'; -module.exports = require('../public/src/modules/translator'); +const winston = require('winston'); + +function warn(msg) { + winston.warn(msg); +} + +module.exports = require('../public/src/modules/translator.common')(require('./utils'), (lang, namespace) => { + const languages = require('./languages'); + return languages.get(lang, namespace); +}, warn); diff --git a/src/user/info.js b/src/user/info.js index 9e37660ac7..a416ea43d5 100644 --- a/src/user/info.js +++ b/src/user/info.js @@ -6,7 +6,7 @@ const validator = require('validator'); const db = require('../database'); const posts = require('../posts'); const topics = require('../topics'); -const utils = require('../../public/src/utils'); +const utils = require('../utils'); module.exports = function (User) { User.getLatestBanInfo = async function (uid) { diff --git a/src/utils.js b/src/utils.js index 8913909584..524730f221 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,17 @@ 'use strict'; -module.exports = require('../public/src/utils'); +process.profile = function (operation, start) { + console.log('%s took %d milliseconds', operation, process.elapsedTimeSince(start)); +}; + +process.elapsedTimeSince = function (start) { + const diff = process.hrtime(start); + return (diff[0] * 1e3) + (diff[1] / 1e6); +}; +const utils = require('../public/src/utils.common'); + +utils.getLanguage = function () { + const meta = require('./meta'); + return meta.config && meta.config.defaultLang ? meta.config.defaultLang : 'en-GB'; +}; +module.exports = utils; diff --git a/src/views/500-embed.tpl b/src/views/500-embed.tpl deleted file mode 100644 index 3bd6857048..0000000000 --- a/src/views/500-embed.tpl +++ /dev/null @@ -1,14 +0,0 @@ - \ No newline at end of file diff --git a/src/views/admin/advanced/cache.tpl b/src/views/admin/advanced/cache.tpl index 46205e5fef..8de6734da0 100644 --- a/src/views/admin/advanced/cache.tpl +++ b/src/views/admin/advanced/cache.tpl @@ -1,4 +1,4 @@ - +
    @@ -118,7 +118,4 @@
    - - + diff --git a/src/views/admin/development/logger.tpl b/src/views/admin/development/logger.tpl index 01c9688718..04e2004154 100644 --- a/src/views/admin/development/logger.tpl +++ b/src/views/admin/development/logger.tpl @@ -1,5 +1,6 @@ +
    -
    +
    [[admin/development/logger:logger-settings]]
    @@ -31,22 +32,7 @@
    - -
    - -
    -
    -
    [[admin/development/logger:control-panel]]
    -
    - -
    -
    - - + diff --git a/src/views/admin/header.tpl b/src/views/admin/header.tpl index ee81bfeb2d..3ed52226fa 100644 --- a/src/views/admin/header.tpl +++ b/src/views/admin/header.tpl @@ -18,7 +18,7 @@ }; - + diff --git a/src/views/admin/partials/privileges/category.tpl b/src/views/admin/partials/privileges/category.tpl index 19a21a9032..6e4dbdf0cf 100644 --- a/src/views/admin/partials/privileges/category.tpl +++ b/src/views/admin/partials/privileges/category.tpl @@ -14,9 +14,9 @@ [[admin/manage/categories:privileges.section-group]] [[admin/manage/privileges:select-clear-all]] - - {privileges.labels.groups.name} - + {{{ each privileges.labels.groups }}} + {@value} + {{{ end }}} @@ -97,9 +97,9 @@ [[admin/manage/categories:privileges.section-user]] [[admin/manage/privileges:select-clear-all]] - - {privileges.labels.users.name} - + {{{ each privileges.labels.users }}} + {@value} + {{{ end }}} diff --git a/src/views/admin/partials/privileges/global.tpl b/src/views/admin/partials/privileges/global.tpl index 99ffa7437e..db656ccf18 100644 --- a/src/views/admin/partials/privileges/global.tpl +++ b/src/views/admin/partials/privileges/global.tpl @@ -16,9 +16,9 @@ [[admin/manage/categories:privileges.section-group]] [[admin/manage/privileges:select-clear-all]] - - {privileges.labels.groups.name} - + {{{ each privileges.labels.groups }}} + {@value} + {{{ end }}} @@ -78,9 +78,9 @@ [[admin/manage/categories:privileges.section-user]] [[admin/manage/privileges:select-clear-all]] - - {privileges.labels.users.name} - + {{{ each privileges.labels.users }}} + {@value} + {{{ end }}} diff --git a/src/views/admin/partials/settings/footer.tpl b/src/views/admin/partials/settings/footer.tpl index af46d0ad59..c8e884c6ab 100644 --- a/src/views/admin/partials/settings/footer.tpl +++ b/src/views/admin/partials/settings/footer.tpl @@ -3,10 +3,3 @@ - - diff --git a/src/views/admin/settings/homepage.tpl b/src/views/admin/settings/homepage.tpl index 625a59aa62..dbf30482d9 100644 --- a/src/views/admin/settings/homepage.tpl +++ b/src/views/admin/settings/homepage.tpl @@ -1,3 +1,4 @@ +
    [[admin/settings/homepage:home-page]]
    @@ -33,12 +34,4 @@
    - - - + diff --git a/src/views/install/index.tpl b/src/views/install/index.tpl index 3b9a9e8ce3..17657b41cd 100644 --- a/src/views/install/index.tpl +++ b/src/views/install/index.tpl @@ -9,7 +9,7 @@ - + diff --git a/src/views/partials/footer/js.tpl b/src/views/partials/footer/js.tpl index a0b8668f4c..df007e7cef 100644 --- a/src/views/partials/footer/js.tpl +++ b/src/views/partials/footer/js.tpl @@ -5,6 +5,8 @@ {{{end}}}