diff --git a/.gitignore b/.gitignore index 2ae33250b3..72399b5f92 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,6 @@ tx.exe coverage .nyc_output -build *.log test/files/normalise.jpg.png test/files/normalise-resized.jpg diff --git a/build/.gitignore b/build/.gitignore new file mode 100644 index 0000000000..9addbff7eb --- /dev/null +++ b/build/.gitignore @@ -0,0 +1,4 @@ +* +*/ +!export +!.gitignore diff --git a/build/export/.gitignore b/build/export/.gitignore new file mode 100644 index 0000000000..f8d55e0e3f --- /dev/null +++ b/build/export/.gitignore @@ -0,0 +1,3 @@ +. +!.gitignore +!README \ No newline at end of file diff --git a/build/export/README b/build/export/README new file mode 100644 index 0000000000..a9015033f4 --- /dev/null +++ b/build/export/README @@ -0,0 +1,5 @@ +This directory contains archives of user uploads that are prepared on-demand +when a user wants to retrieve a copy of their uploaded content. + +You can delete the files in here at will. They will just be regenerated if +requested again. \ No newline at end of file diff --git a/install/package.json b/install/package.json index ed42dbf760..7ba2245a2b 100644 --- a/install/package.json +++ b/install/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "ace-builds": "^1.2.9", + "archiver": "^2.1.1", "async": "2.6.0", "autoprefixer": "7.2.4", "bcryptjs": "2.4.3", @@ -65,7 +66,7 @@ "nconf": "^0.9.1", "nodebb-plugin-composer-default": "6.0.22", "nodebb-plugin-dbsearch": "2.0.16", - "nodebb-plugin-emoji": "^2.2.0", + "nodebb-plugin-emoji": "^2.2.2", "nodebb-plugin-emoji-android": "2.0.0", "nodebb-plugin-markdown": "8.4.2", "nodebb-plugin-mentions": "2.2.6", @@ -73,9 +74,9 @@ "nodebb-plugin-spam-be-gone": "0.5.3", "nodebb-rewards-essentials": "0.0.11", "nodebb-theme-lavender": "5.0.4", - "nodebb-theme-persona": "8.0.11", + "nodebb-theme-persona": "9.0.0", "nodebb-theme-slick": "1.2.1", - "nodebb-theme-vanilla": "9.0.8", + "nodebb-theme-vanilla": "10.0.0", "nodebb-widget-essentials": "4.0.2", "nodemailer": "4.4.1", "passport": "^0.4.0", diff --git a/public/language/en-GB/admin/manage/uploads.json b/public/language/en-GB/admin/manage/uploads.json index 13e69cafa7..21bc8201fc 100644 --- a/public/language/en-GB/admin/manage/uploads.json +++ b/public/language/en-GB/admin/manage/uploads.json @@ -1,6 +1,8 @@ { "upload-file": "Upload File", "filename": "Filename", + "usage": "Post Usage", + "orphaned": "Orphaned", "size/filecount": "Size / Filecount", "confirm-delete": "Do you really want to delete this file?", "filecount": "%1 files" diff --git a/public/language/en-GB/admin/settings/group.json b/public/language/en-GB/admin/settings/group.json index 1ae88c9cf5..fe3e39915b 100644 --- a/public/language/en-GB/admin/settings/group.json +++ b/public/language/en-GB/admin/settings/group.json @@ -5,6 +5,7 @@ "private-groups.warning": "Beware! If this option is disabled and you have private groups, they automatically become public.", "allow-creation": "Allow Group Creation", "allow-creation-help": "If enabled, users can create groups (Default: disabled)", + "allow-multiple-badges-help": "This flag can be used to allow users to select multiple group badges, requires theme support.", "max-name-length": "Maximum Group Name Length", "cover-image": "Group Cover Image", "default-cover": "Default Cover Images", diff --git a/public/language/en-GB/flags.json b/public/language/en-GB/flags.json index d05a5b25a8..35fc87011a 100644 --- a/public/language/en-GB/flags.json +++ b/public/language/en-GB/flags.json @@ -18,6 +18,7 @@ "filter-type": "Flag Type", "filter-type-all": "All Content", "filter-type-post": "Post", + "filter-type-user": "User", "filter-state": "State", "filter-assignee": "Assignee UID", "filter-cid": "Category", diff --git a/public/language/en-GB/global.json b/public/language/en-GB/global.json index 1195d792a0..14d9b13421 100644 --- a/public/language/en-GB/global.json +++ b/public/language/en-GB/global.json @@ -122,6 +122,7 @@ "enter_page_number": "Enter page number", "upload_file": "Upload file", "upload": "Upload", + "uploads": "Uploads", "allowed-file-types": "Allowed file types are %1", "unsaved-changes": "You have unsaved changes. Are you sure you wish to navigate away?", diff --git a/public/language/en-GB/pages.json b/public/language/en-GB/pages.json index 607eac0f2c..6780f5cf5d 100644 --- a/public/language/en-GB/pages.json +++ b/public/language/en-GB/pages.json @@ -56,6 +56,7 @@ "account/downvoted": "Posts downvoted by %1", "account/best": "Best posts made by %1", "account/blocks": "Blocked users for %1", + "account/uploads": "Uploads by %1", "confirm": "Email Confirmed", diff --git a/public/language/en-GB/register.json b/public/language/en-GB/register.json index 81b20421d4..461295ef5f 100644 --- a/public/language/en-GB/register.json +++ b/public/language/en-GB/register.json @@ -19,5 +19,9 @@ "terms_of_use_error": "You must agree to the Terms of Use", "registration-added-to-queue": "Your registration has been added to the approval queue. You will receive an email when it is accepted by an administrator.", "interstitial.intro": "We require some additional information before we can create your account.", - "interstitial.errors-found": "We could not complete your registration:" + "interstitial.errors-found": "We could not complete your registration:", + + "gdpr_agree_data": "I consent to the collection and processing of my personal information on this website.", + "gdpr_agree_email": "I consent to receive digest and notification emails from this website.", + "gdpr_consent_denied": "You must give consent to this site to collect/process your information, and to send you emails." } \ No newline at end of file diff --git a/public/language/en-GB/uploads.json b/public/language/en-GB/uploads.json index 8cf9487901..4aca2bce1e 100644 --- a/public/language/en-GB/uploads.json +++ b/public/language/en-GB/uploads.json @@ -2,5 +2,8 @@ "uploading-file" : "Uploading the file...", "select-file-to-upload": "Select a file to upload!", "upload-success": "File uploaded successfully!", - "maximum-file-size": "Maximum %1 kb" + "maximum-file-size": "Maximum %1 kb", + "no-uploads-found": "No uploads found", + "public-uploads-info": "Uploads are public, all visitors can see them.", + "private-uploads-info": "Uploads are private, only logged in users can see them." } \ No newline at end of file diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index 44db14eba6..c18549d4d5 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -163,5 +163,28 @@ "info.email-history": "Email History", "info.moderation-note": "Moderation Note", "info.moderation-note.success": "Moderation note saved", - "info.moderation-note.add": "Add note" + "info.moderation-note.add": "Add note", + + "consent.title": "Your Rights & Consent", + "consent.lead": "This community forum collects and processes your personal information.", + "consent.intro": "We use this information strictly to personalise your experience in this community, as well as to associate the posts you make to your user account. During the registration step you were asked to provide a username and email address, you can also optionally provide additional information to complete your user profile on this website.

We retain this information for the life of your user account, and you are able to withdraw consent at any time by deleting your account. At any time you may request a copy of your contribution to this website, via your Rights & Consent page.

If you have any questions or concerns, we encourage you to reach out to this forum's administrative team.", + "consent.email_intro": "Occasionally, we may send emails to your registered email address in order to provide updates and/or to notify you of new activity that is pertinent to you. You can customise the frequency of the community digest (including disabling it outright), as well as select which types of notifications to receive via email, via your user settings page.", + "consent.digest_frequency": "By default, this community delivers email digests every %1.", + "consent.digest_off": "Currently, this community does not send out email digests", + "consent.received": "You have provided consent for this website to collect and process your information. No additional action is required.", + "consent.not_received": "You have not provided consent for data collection and processing. At any time this website's administration may elect to delete your account in order to become compliant with the General Data Protection Regulation.", + "consent.give": "Give consent", + + "consent.right_of_access": "You have the Right of Access", + "consent.right_of_access_description": "You have the right to access any data collected by this website upon request. You can retrieve a copy of this data by clicking the appropriate button below.", + "consent.right_to_rectification": "You have the Right to Rectification", + "consent.right_to_rectification_description": "You have the right to change or update any inaccurate data provided to us. Your profile can be updated by editing your profile, and post content can always be edited. If this is not the case, please contact this site's administrative team.", + "consent.right_to_erasure": "You have the Right to Erasure", + "consent.right_to_erasure_description": "At any time, you are able to revoke your consent to data collection and/or processing by deleting your account.", + "consent.right_to_data_portability": "You have the Right to Data Portability", + "consent.right_to_data_portability_description": "You may request from us a machine-readable export of any collected data about you and your account. You can do so by clicking the appropriate button below.", + + "consent.export_profile": "Export Profile (.csv)", + "consent.export_uploads": "Export Uploaded Content (.zip)", + "consent.export_posts": "Export Posts (.csv)" } diff --git a/public/src/client/account/consent.js b/public/src/client/account/consent.js new file mode 100644 index 0000000000..f038c7acea --- /dev/null +++ b/public/src/client/account/consent.js @@ -0,0 +1,22 @@ +'use strict'; + + +define('forum/account/consent', ['forum/account/header'], function (header) { + var Consent = {}; + + Consent.init = function () { + header.init(); + + $('[data-action="consent"]').on('click', function () { + socket.emit('user.gdpr.consent', {}, function (err) { + if (err) { + return app.alertError(err.message); + } + + ajaxify.refresh(); + }); + }); + }; + + return Consent; +}); diff --git a/public/src/client/account/edit.js b/public/src/client/account/edit.js index 8b270a8fd2..9956c659c6 100644 --- a/public/src/client/account/edit.js +++ b/public/src/client/account/edit.js @@ -37,6 +37,8 @@ define('forum/account/edit', ['forum/account/header', 'translator', 'components' aboutme: $('#inputAboutMe').val(), }; + userData.groupTitle = JSON.stringify(Array.isArray(userData.groupTitle) ? userData.groupTitle : [userData.groupTitle]); + $(window).trigger('action:profile.update', userData); socket.emit('user.updateProfile', userData, function (err, data) { diff --git a/public/src/client/account/uploads.js b/public/src/client/account/uploads.js new file mode 100644 index 0000000000..222e30e9a0 --- /dev/null +++ b/public/src/client/account/uploads.js @@ -0,0 +1,24 @@ +'use strict'; + +define('forum/account/uploads', ['forum/account/header'], function (header) { + var AccountUploads = {}; + + AccountUploads.init = function () { + header.init(); + + $('[data-action="delete"]').on('click', function () { + var el = $(this).parents('[data-name]'); + var name = el.attr('data-name'); + + socket.emit('user.deleteUpload', { name: name, uid: ajaxify.data.uid }, function (err) { + if (err) { + return app.alertError(err.message); + } + el.remove(); + }); + return false; + }); + }; + + return AccountUploads; +}); diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index 72a416d04f..f8f44b15cd 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -18,6 +18,7 @@ define('chat', [ module.prepareDOM = function () { var chatsToggleEl = components.get('chat/dropdown'); var chatsListEl = components.get('chat/list'); + var chatsDropdownWrapper = chatsToggleEl.parents('.dropdown'); chatsToggleEl.on('click', function () { if (chatsToggleEl.parent().hasClass('open')) { @@ -27,6 +28,10 @@ define('chat', [ module.loadChatsDropdown(chatsListEl); }); + if (chatsDropdownWrapper.hasClass('open')) { + module.loadChatsDropdown(chatsListEl); + } + chatsListEl.on('click', '[data-roomid]', function (ev) { if ($(ev.target).parents('.user-link').length) { return; diff --git a/public/src/modules/notifications.js b/public/src/modules/notifications.js index c9be4b87d8..4a5f86c625 100644 --- a/public/src/modules/notifications.js +++ b/public/src/modules/notifications.js @@ -10,6 +10,7 @@ define('notifications', ['sounds', 'translator', 'components', 'navigator', 'ben var notifContainer = components.get('notifications'); var notifTrigger = notifContainer.children('a'); var notifList = components.get('notifications/list'); + var notifDropdownWrapper = notifTrigger.parents('.dropdown'); notifTrigger.on('click', function (e) { e.preventDefault(); @@ -20,6 +21,10 @@ define('notifications', ['sounds', 'translator', 'components', 'navigator', 'ben Notifications.loadNotifications(notifList); }); + if (notifDropdownWrapper.hasClass('open')) { + Notifications.loadNotifications(notifList); + } + notifList.on('click', '[data-nid]', function (ev) { var notifEl = $(this); if (scrollToPostIndexIfOnPage(notifEl)) { diff --git a/public/src/sockets.js b/public/src/sockets.js index 1aaf02ca53..0a0c295217 100644 --- a/public/src/sockets.js +++ b/public/src/sockets.js @@ -17,30 +17,36 @@ app.isConnected = false; socket = io(config.websocketAddress, ioParams); - socket.on('connect', onConnect); + if (parseInt(app.user.uid, 10) >= 0) { + addHandlers(); + } - socket.on('reconnecting', onReconnecting); + function addHandlers() { + socket.on('connect', onConnect); - socket.on('disconnect', onDisconnect); + socket.on('reconnecting', onReconnecting); - socket.on('reconnect_failed', function () { - // Wait ten times the reconnection delay and then start over - setTimeout(socket.connect.bind(socket), parseInt(config.reconnectionDelay, 10) * 10); - }); + socket.on('disconnect', onDisconnect); - socket.on('checkSession', function (uid) { - if (parseInt(uid, 10) !== parseInt(app.user.uid, 10)) { - app.handleInvalidSession(); - } - }); + socket.on('reconnect_failed', function () { + // Wait ten times the reconnection delay and then start over + setTimeout(socket.connect.bind(socket), parseInt(config.reconnectionDelay, 10) * 10); + }); - socket.on('setHostname', function (hostname) { - app.upstreamHost = hostname; - }); + socket.on('checkSession', function (uid) { + if (parseInt(uid, 10) !== parseInt(app.user.uid, 10)) { + app.handleInvalidSession(); + } + }); - socket.on('event:banned', onEventBanned); + socket.on('setHostname', function (hostname) { + app.upstreamHost = hostname; + }); - socket.on('event:alert', app.alert); + socket.on('event:banned', onEventBanned); + + socket.on('event:alert', app.alert); + } function onConnect() { app.isConnected = true; diff --git a/src/controllers/accounts.js b/src/controllers/accounts.js index 63ff87a10b..0f31595ef4 100644 --- a/src/controllers/accounts.js +++ b/src/controllers/accounts.js @@ -12,6 +12,8 @@ var accountsController = { chats: require('./accounts/chats'), session: require('./accounts/session'), blocks: require('./accounts/blocks'), + uploads: require('./accounts/uploads'), + consent: require('./accounts/consent'), }; module.exports = accountsController; diff --git a/src/controllers/accounts/consent.js b/src/controllers/accounts/consent.js new file mode 100644 index 0000000000..e5204ce448 --- /dev/null +++ b/src/controllers/accounts/consent.js @@ -0,0 +1,53 @@ +'use strict'; + +var async = require('async'); + +var db = require('../../database'); +var meta = require('../../meta'); +var helpers = require('../helpers'); +var accountHelpers = require('./helpers'); + +var consentController = {}; + +consentController.get = function (req, res, next) { + var userData; + + async.waterfall([ + function (next) { + accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, next); + }, + function (_userData, next) { + userData = _userData; + if (!userData) { + return next(); + } + + // Direct database call is used here because `gdpr_consent` is a protected user field and is automatically scrubbed from standard user data retrieval calls + db.getObjectField('user:' + userData.uid, 'gdpr_consent', function (err, consented) { + if (err) { + return next(err); + } + + userData.gdpr_consent = !!parseInt(consented, 10); + + next(null, userData); + }); + }, + ], function (err, userData) { + if (err) { + return next(err); + } + + userData.digest = { + frequency: meta.config.dailyDigestFreq, + enabled: meta.config.dailyDigestFreq !== 'off', + }; + + userData.title = '[[user:consent.title]]'; + userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: '/user/' + userData.userslug }, { text: '[[user:consent.title]]' }]); + + res.render('account/consent', userData); + }); +}; + +module.exports = consentController; diff --git a/src/controllers/accounts/edit.js b/src/controllers/accounts/edit.js index 08f5edfaf3..af92cdc7bc 100644 --- a/src/controllers/accounts/edit.js +++ b/src/controllers/accounts/edit.js @@ -17,9 +17,17 @@ var editController = module.exports; editController.get = function (req, res, callback) { async.waterfall([ function (next) { - accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, next); + async.parallel({ + userData: function (next) { + accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, next); + }, + canUseSignature: function (next) { + privileges.global.can('signature', req.uid, next); + }, + }, next); }, - function (userData, next) { + function (results, next) { + var userData = results.userData; if (!userData) { return callback(); } @@ -27,18 +35,23 @@ editController.get = function (req, res, callback) { userData.maximumAboutMeLength = parseInt(meta.config.maximumAboutMeLength, 10) || 1000; userData.maximumProfileImageSize = parseInt(meta.config.maximumProfileImageSize, 10); userData.allowProfileImageUploads = parseInt(meta.config.allowProfileImageUploads, 10) === 1; + userData.allowMultipleBadges = parseInt(meta.config.allowMultipleBadges, 10) === 1; userData.allowAccountDelete = parseInt(meta.config.allowAccountDelete, 10) === 1; userData.allowWebsite = !userData.isSelf || parseInt(userData.reputation, 10) >= (parseInt(meta.config['min:rep:website'], 10) || 0); userData.allowAboutMe = !userData.isSelf || parseInt(userData.reputation, 10) >= (parseInt(meta.config['min:rep:aboutme'], 10) || 0); - userData.allowSignature = !userData.isSelf || parseInt(userData.reputation, 10) >= (parseInt(meta.config['min:rep:signature'], 10) || 0); + userData.allowSignature = results.canUseSignature && (!userData.isSelf || parseInt(userData.reputation, 10) >= (parseInt(meta.config['min:rep:signature'], 10) || 0)); userData.profileImageDimension = parseInt(meta.config.profileImageDimension, 10) || 200; userData.defaultAvatar = user.getDefaultAvatar(); userData.groups = userData.groups.filter(function (group) { return group && group.userTitleEnabled && !groups.isPrivilegeGroup(group.name) && group.name !== 'registered-users'; }); + + if (!userData.allowMultipleBadges) { + userData.groupTitle = userData.groupTitleArray[0]; + } userData.groups.forEach(function (group) { - group.selected = group.name === userData.groupTitle; + group.selected = userData.groupTitleArray.includes(group.name); }); userData.title = '[[pages:account/edit, ' + userData.username + ']]'; diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js index 955432c652..4dfc7855b8 100644 --- a/src/controllers/accounts/helpers.js +++ b/src/controllers/accounts/helpers.js @@ -68,6 +68,17 @@ helpers.getUserDataByUserSlug = function (userslug, callerUID, callback) { globalMod: true, admin: true, }, + }, { + id: 'consent', + route: 'consent', + name: '[[user:consent.title]]', + visibility: { + self: true, + other: false, + moderator: false, + globalMod: false, + admin: false, + }, }], }, next); }, diff --git a/src/controllers/accounts/profile.js b/src/controllers/accounts/profile.js index fc9f9cb5b4..5d33e198fc 100644 --- a/src/controllers/accounts/profile.js +++ b/src/controllers/accounts/profile.js @@ -122,8 +122,9 @@ profileController.get = function (req, res, callback) { } ); } - userData.selectedGroup = userData.groups.find(function (group) { - return group && group.name === userData.groupTitle; + + userData.selectedGroup = userData.groups.filter(function (group) { + return group && userData.groupTitleArray.includes(group.name); }); plugins.fireHook('filter:user.account', { userData: userData, uid: req.uid }, next); diff --git a/src/controllers/accounts/uploads.js b/src/controllers/accounts/uploads.js new file mode 100644 index 0000000000..21a5287550 --- /dev/null +++ b/src/controllers/accounts/uploads.js @@ -0,0 +1,57 @@ +'use strict'; + + +var async = require('async'); +var nconf = require('nconf'); + +var db = require('../../database'); +var helpers = require('../helpers'); +var meta = require('../../meta'); +var pagination = require('../../pagination'); +var accountHelpers = require('./helpers'); + +var uploadsController = module.exports; + +uploadsController.get = function (req, res, callback) { + var userData; + + var page = Math.max(1, parseInt(req.query.page, 10) || 1); + var itemsPerPage = 25; + + async.waterfall([ + function (next) { + accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, next); + }, + function (_userData, next) { + userData = _userData; + if (!userData) { + return callback(); + } + + var start = (page - 1) * itemsPerPage; + var stop = start + itemsPerPage - 1; + async.parallel({ + itemCount: function (next) { + db.sortedSetCard('uid:' + userData.uid + ':uploads', next); + }, + uploadNames: function (next) { + db.getSortedSetRevRange('uid:' + userData.uid + ':uploads', start, stop, next); + }, + }, next); + }, + function (results) { + userData.uploads = results.uploadNames.map(function (uploadName) { + return { + name: uploadName, + url: nconf.get('upload_url') + uploadName, + }; + }); + var pageCount = Math.ceil(results.itemCount / itemsPerPage); + userData.pagination = pagination.create(page, pageCount, req.query); + userData.privateUploads = parseInt(meta.config.privateUploads, 10) === 1; + userData.title = '[[pages:account/uploads, ' + userData.username + ']]'; + userData.breadcrumbs = helpers.buildBreadcrumbs([{ text: userData.username, url: '/user/' + userData.userslug }, { text: '[[global:uploads]]' }]); + res.render('account/uploads', userData); + }, + ], callback); +}; diff --git a/src/controllers/admin/uploads.js b/src/controllers/admin/uploads.js index 63ab33a801..6913c1c783 100644 --- a/src/controllers/admin/uploads.js +++ b/src/controllers/admin/uploads.js @@ -8,6 +8,7 @@ var fs = require('fs'); var jimp = require('jimp'); var meta = require('../../meta'); +var posts = require('../../posts'); var file = require('../../file'); var image = require('../../image'); var plugins = require('../../plugins'); @@ -41,23 +42,46 @@ uploadsController.get = function (req, res, next) { filesToData(currentFolder, files, next); }, - function (files) { + function (files, next) { + // Float directories to the top files.sort(function (a, b) { if (a.isDirectory && !b.isDirectory) { return -1; } else if (!a.isDirectory && b.isDirectory) { return 1; + } else if (!a.isDirectory && !b.isDirectory) { + return a.mtime < b.mtime ? -1 : 1; } + return 0; }); - res.render('admin/manage/uploads', { - currentFolder: currentFolder.replace(nconf.get('upload_path'), ''), - files: files, - breadcrumbs: buildBreadcrumbs(currentFolder), - pagination: pagination.create(page, Math.ceil(itemCount / itemsPerPage), req.query), - }); + + // Add post usage info if in /files + if (req.query.dir === '/files') { + posts.uploads.getUsage(files, function (err, usage) { + files.forEach(function (file, idx) { + file.inPids = usage[idx].map(pid => parseInt(pid, 10)); + }); + + next(err, files); + }); + } else { + setImmediate(next, null, files); + } }, - ], next); + ], function (err, files) { + if (err) { + return next(err); + } + + res.render('admin/manage/uploads', { + currentFolder: currentFolder.replace(nconf.get('upload_path'), ''), + showPids: files[0].hasOwnProperty('inPids'), + files: files, + breadcrumbs: buildBreadcrumbs(currentFolder), + pagination: pagination.create(page, Math.ceil(itemCount / itemsPerPage), req.query), + }); + }); }; function buildBreadcrumbs(currentFolder) { @@ -104,6 +128,7 @@ function filesToData(currentDir, files, callback) { sizeHumanReadable: (stat.size / 1024).toFixed(1) + 'KiB', isDirectory: stat.isDirectory(), isFile: stat.isFile(), + mtime: stat.mtimeMs, }); }, ], next); diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index 3ad05c2626..f9eb6f75e2 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -152,7 +152,12 @@ authenticationController.registerComplete = function (req, res, next) { var callbacks = data.interstitials.reduce(function (memo, cur) { if (cur.hasOwnProperty('callback') && typeof cur.callback === 'function') { - memo.push(async.apply(cur.callback, req.session.registration, req.body)); + memo.push(function (next) { + cur.callback(req.session.registration, req.body, function (err) { + // Pass error as second argument so all callbacks are executed + next(null, err); + }); + }); } return memo; @@ -170,9 +175,11 @@ authenticationController.registerComplete = function (req, res, next) { } }; - async.parallel(callbacks, function (err) { - if (err) { - req.flash('error', err.message); + async.parallel(callbacks, function (_blank, err) { + if (err.length) { + req.flash('errors', err.filter(Boolean).map(function (err) { + return err.message; + })); return res.redirect(nconf.get('relative_path') + '/register/complete'); } diff --git a/src/controllers/index.js b/src/controllers/index.js index 550130ce92..292d892767 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -207,7 +207,7 @@ Controllers.registerInterstitial = function (req, res, next) { async.parallel(renders, next); }, function (sections) { - var errors = req.flash('error'); + var errors = req.flash('errors'); res.render('registerComplete', { title: '[[pages:registration-complete]]', errors: errors, diff --git a/src/controllers/mods.js b/src/controllers/mods.js index 69bfd7fbbf..52403a5950 100644 --- a/src/controllers/mods.js +++ b/src/controllers/mods.js @@ -7,15 +7,17 @@ var categories = require('../categories'); var flags = require('../flags'); var analytics = require('../analytics'); var plugins = require('../plugins'); -var adminPostQueueController = require('./admin/postqueue'); +var pagination = require('../pagination'); +var adminPostQueueController = require('./admin/postqueue'); var modsController = module.exports; modsController.flags = {}; modsController.flags.list = function (req, res, next) { var filters; var hasFilter; - var validFilters = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick']; + var validFilters = ['assignee', 'state', 'reporterId', 'type', 'targetUid', 'cid', 'quick', 'page', 'perPage']; + async.waterfall([ function (next) { async.parallel({ @@ -62,6 +64,11 @@ modsController.flags.list = function (req, res, next) { } } + // Pagination doesn't count as a filter + if (Object.keys(filters).length === 2 && filters.hasOwnProperty('page') && filters.hasOwnProperty('perPage')) { + hasFilter = false; + } + async.parallel({ flags: async.apply(flags.list, filters, req.uid), analytics: async.apply(analytics.getDailyStatsForSet, 'analytics:flags', Date.now(), 30), @@ -92,12 +99,13 @@ modsController.flags.list = function (req, res, next) { }, {}); res.render('flags/list', { - flags: data.flags, + flags: data.flags.flags, analytics: data.analytics, categories: data.categories, hasFilter: hasFilter, filters: filters, title: '[[pages:flags]]', + pagination: pagination.create(data.flags.page, data.flags.pageCount, req.query), }); }, ], next); diff --git a/src/controllers/topics.js b/src/controllers/topics.js index 9e89a1e929..40299ceb9b 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -145,6 +145,7 @@ topicsController.get = function (req, res, callback) { topicData.postEditDuration = parseInt(meta.config.postEditDuration, 10) || 0; topicData.postDeleteDuration = parseInt(meta.config.postDeleteDuration, 10) || 0; topicData.scrollToMyPost = settings.scrollToMyPost; + topicData.allowMultipleBadges = parseInt(meta.config.allowMultipleBadges, 10) === 1; topicData.rssFeedUrl = nconf.get('relative_path') + '/topic/' + topicData.tid + '.rss'; if (req.loggedIn) { topicData.rssFeedUrl += '?uid=' + req.uid + '&token=' + rssToken; diff --git a/src/controllers/uploads.js b/src/controllers/uploads.js index 3d225c22fe..487332542e 100644 --- a/src/controllers/uploads.js +++ b/src/controllers/uploads.js @@ -5,13 +5,14 @@ var async = require('async'); var nconf = require('nconf'); var validator = require('validator'); +var db = require('../database'); var meta = require('../meta'); var file = require('../file'); var plugins = require('../plugins'); var image = require('../image'); var privileges = require('../privileges'); -var uploadsController = {}; +var uploadsController = module.exports; uploadsController.upload = function (req, res, filesIterator) { var files = req.files.files; @@ -192,7 +193,7 @@ uploadsController.uploadGroupCover = function (uid, uploadedFile, callback) { file.isFileTypeAllowed(uploadedFile.path, next); }, function (next) { - saveFileToLocal(uploadedFile, next); + saveFileToLocal(uid, uploadedFile, next); }, ], callback); }; @@ -220,27 +221,31 @@ uploadsController.uploadFile = function (uid, uploadedFile, callback) { return callback(new Error('[[error:invalid-file-type, ' + allowed.join(', ') + ']]')); } - saveFileToLocal(uploadedFile, callback); + saveFileToLocal(uid, uploadedFile, callback); }; -function saveFileToLocal(uploadedFile, callback) { +function saveFileToLocal(uid, uploadedFile, callback) { var filename = uploadedFile.name || 'upload'; var extension = path.extname(filename) || ''; filename = Date.now() + '-' + validator.escape(filename.substr(0, filename.length - extension.length)).substr(0, 255) + extension; - + var storedFile; async.waterfall([ function (next) { file.saveFileToLocal(filename, 'files', uploadedFile.path, next); }, function (upload, next) { - var storedFile = { + storedFile = { url: nconf.get('relative_path') + upload.url, path: upload.path, name: uploadedFile.name, }; - plugins.fireHook('filter:uploadStored', { uploadedFile: uploadedFile, storedFile: storedFile }, next); + var fileKey = upload.url.replace(nconf.get('upload_url'), ''); + db.sortedSetAdd('uid:' + uid + ':uploads', Date.now(), fileKey, next); + }, + function (next) { + plugins.fireHook('filter:uploadStored', { uid: uid, uploadedFile: uploadedFile, storedFile: storedFile }, next); }, function (data, next) { next(null, data.storedFile); @@ -254,5 +259,3 @@ function deleteTempFiles(files) { next(); }); } - -module.exports = uploadsController; diff --git a/src/controllers/user.js b/src/controllers/user.js index 3405c1ea1d..515fe7555d 100644 --- a/src/controllers/user.js +++ b/src/controllers/user.js @@ -1,9 +1,18 @@ 'use strict'; var async = require('async'); +var path = require('path'); +var fs = require('fs'); +var winston = require('winston'); +var converter = require('json-2-csv'); +var archiver = require('archiver'); +var db = require('../database'); var user = require('../user'); var meta = require('../meta'); +var posts = require('../posts'); +var batch = require('../batch'); +var events = require('../events'); var accountHelpers = require('./accounts/helpers'); var userController = module.exports; @@ -97,3 +106,135 @@ userController.getUserDataByUID = function (callerUid, uid, callback) { callback(null, results.userData); }); }; + +userController.exportPosts = function (req, res, next) { + async.waterfall([ + function (next) { + var payload = []; + batch.processSortedSet('uid:' + req.params.uid + ':posts', function (pids, next) { + async.map(pids, posts.getPostData, function (err, posts) { + if (err) { + return next(err); + } + + // Convert newlines in content + posts = posts.map(function (post) { + post.content = '"' + post.content.replace(/\n/g, '\\n').replace(/"/g, '\\"') + '"'; + return post; + }); + + payload = payload.concat(posts); + next(); + }); + }, function (err) { + next(err, payload); + }); + }, + async.apply(converter.json2csv), + ], function (err, csv) { + if (err) { + return next(err); + } + + res.set('Content-Type', 'text/csv').set('Content-Disposition', 'attachment; filename="' + req.params.uid + '_posts.csv"').send(csv); + }); +}; + +userController.exportUploads = function (req, res, next) { + const archivePath = path.join(__dirname, '../../build/export', req.params.uid + '_uploads.zip'); + const archive = archiver('zip', { + zlib: { level: 9 }, // Sets the compression level. + }); + const maxAge = 1000 * 60 * 60 * 24; // 1 day + + const rootDirectory = path.join(__dirname, '../../public/uploads/'); + const trimPath = function (path) { + return path.replace(rootDirectory, ''); + }; + let isFresh = false; + const sendFile = function () { + events.log({ + type: 'export:uploads', + uid: req.uid, + targetUid: req.params.uid, + ip: req.ip, + fresh: isFresh, + }); + + res.sendFile(req.params.uid + '_uploads.zip', { + root: path.join(__dirname, '../../build/export'), + headers: { + 'Content-Disposition': 'attachment; filename=' + req.params.uid + '_uploads.zip', + maxAge: maxAge, + }, + }); + }; + + // Check for existing file, if exists and is < 1 day in age, send this instead + try { + fs.accessSync(archivePath, fs.constants.F_OK | fs.constants.R_OK); + isFresh = (Date.now() - fs.statSync(archivePath).mtimeMs) < maxAge; + if (isFresh) { + return sendFile(); + } + } catch (err) { + // File doesn't exist, continue + } + + const output = fs.createWriteStream(archivePath); + output.on('close', sendFile); + + archive.on('warning', function (err) { + switch (err.code) { + case 'ENOENT': + winston.warn('[user/export/uploads] File not found: ' + trimPath(err.path)); + break; + + default: + winston.warn('[user/export/uploads] Unexpected warning: ' + err.message); + break; + } + }); + + archive.on('error', function (err) { + switch (err.code) { + case 'EACCES': + winston.error('[user/export/uploads] File inaccessible: ' + trimPath(err.path)); + break; + + default: + winston.error('[user/export/uploads] Unable to construct archive: ' + err.message); + break; + } + + res.sendStatus(500); + }); + + archive.pipe(output); + winston.info('[user/export/uploads] Collating uploads for uid ' + req.params.uid); + user.collateUploads(req.params.uid, archive, function (err) { + if (err) { + return next(err); + } + + archive.finalize(); + }); +}; + +userController.exportProfile = function (req, res, next) { + async.waterfall([ + async.apply(db.getObjects.bind(db), ['user:1', 'user:1:settings']), + function (objects, next) { + Object.assign(objects[0], objects[1]); + delete objects[0].password; + + converter.json2csv(objects[0], next); + }, + ], function (err, csv) { + if (err) { + return next(err); + } + + res.set('Content-Type', 'text/csv').set('Content-Disposition', 'attachment; filename="' + req.params.uid + '_profile.csv"').send(csv); + }); +}; diff --git a/src/events.js b/src/events.js index c19a948579..cb8798ed70 100644 --- a/src/events.js +++ b/src/events.js @@ -12,6 +12,10 @@ var utils = require('./utils'); var events = module.exports; +/** + * Useful options in data: type, uid, ip, targetUid + * Everything else gets stringified and shown as pretty JSON string + */ events.log = function (data, callback) { callback = callback || function () {}; diff --git a/src/file.js b/src/file.js index e31ae18399..2fbd15dbdf 100644 --- a/src/file.js +++ b/src/file.js @@ -141,8 +141,9 @@ file.exists = function (path, callback) { if (err.code === 'ENOENT') { return callback(null, false); } + return callback(err); } - callback(err, true); + callback(null, true); }); }; @@ -159,14 +160,17 @@ file.existsSync = function (path) { return true; }; -file.delete = function (path) { - if (path) { - fs.unlink(path, function (err) { - if (err) { - winston.error(err); - } - }); +file.delete = function (path, callback) { + callback = callback || function () {}; + if (!path) { + return callback(); } + fs.unlink(path, function (err) { + if (err) { + winston.error(err); + } + callback(); + }); }; file.link = function link(filePath, destPath, relative, callback) { diff --git a/src/flags.js b/src/flags.js index 6a3c51e707..ee5c5fa97f 100644 --- a/src/flags.js +++ b/src/flags.js @@ -51,6 +51,8 @@ Flags.init = function (callback) { cid: function (sets, orSets, key) { prepareSets(sets, orSets, 'flags:byCid:', key); }, + page: function () { /* noop */ }, + perPage: function () { /* noop */ }, quick: function (sets, orSets, key, uid) { switch (key) { case 'mine': @@ -121,14 +123,16 @@ Flags.list = function (filters, uid, callback) { var sets = []; var orSets = []; - if (Object.keys(filters).length > 0) { - for (var type in filters) { - if (filters.hasOwnProperty(type)) { - if (Flags._filters.hasOwnProperty(type)) { - Flags._filters[type](sets, orSets, filters[type], uid); - } else { - winston.warn('[flags/list] No flag filter type found: ' + type); - } + // Default filter + filters.page = filters.hasOwnProperty('page') ? Math.abs(parseInt(filters.page, 10) || 1) : 1; + filters.perPage = filters.hasOwnProperty('perPage') ? Math.abs(parseInt(filters.perPage, 10) || 20) : 20; + + for (var type in filters) { + if (filters.hasOwnProperty(type)) { + if (Flags._filters.hasOwnProperty(type)) { + Flags._filters[type](sets, orSets, filters[type], uid); + } else { + winston.warn('[flags/list] No flag filter type found: ' + type); } } } @@ -165,6 +169,11 @@ Flags.list = function (filters, uid, callback) { } }, function (flagIds, next) { + // Create subset for parsing based on page number (n=20) + const flagsPerPage = Math.abs(parseInt(filters.perPage, 10) || 1); + const pageCount = Math.ceil(flagIds.length / flagsPerPage); + flagIds = flagIds.slice((filters.page - 1) * flagsPerPage, filters.page * flagsPerPage); + async.map(flagIds, function (flagId, next) { async.waterfall([ async.apply(db.getObject, 'flag:' + flagId), @@ -206,13 +215,20 @@ Flags.list = function (filters, uid, callback) { datetimeISO: utils.toISOString(flagObj.datetime), })); }); - }, next); + }, function (err, flags) { + next(err, flags, pageCount); + }); }, - function (flags, next) { + function (flags, pageCount, next) { plugins.fireHook('filter:flags.list', { flags: flags, + page: filters.page, }, function (err, data) { - next(err, data.flags); + next(err, { + flags: data.flags, + page: data.page, + pageCount: pageCount, + }); }); }, ], callback); diff --git a/src/install.js b/src/install.js index 2e86d26e62..8534ef6529 100644 --- a/src/install.js +++ b/src/install.js @@ -361,7 +361,7 @@ function createGlobalModeratorsGroup(next) { function giveGlobalPrivileges(next) { var privileges = require('./privileges'); - privileges.global.give(['chat', 'upload:post:image'], 'registered-users', next); + privileges.global.give(['chat', 'upload:post:image', 'signature'], 'registered-users', next); } function createCategories(next) { diff --git a/src/middleware/header.js b/src/middleware/header.js index 0a325460e4..f845d8b4bb 100644 --- a/src/middleware/header.js +++ b/src/middleware/header.js @@ -251,7 +251,7 @@ module.exports = function (middleware) { data.templateValues.useCustomJS = parseInt(meta.config.useCustomJS, 10) === 1; data.templateValues.customJS = data.templateValues.useCustomJS ? meta.config.customJS : ''; - + data.templateValues.isSpider = req.isSpider(); req.app.render('footer', data.templateValues, next); }, ], callback); diff --git a/src/plugins/hooks.js b/src/plugins/hooks.js index c555424377..9133378ace 100644 --- a/src/plugins/hooks.js +++ b/src/plugins/hooks.js @@ -12,20 +12,24 @@ module.exports = function (Plugins) { 'action:flag.create': 'action:flags.create', 'action:flag.update': 'action:flags.update', }; - /* - `data` is an object consisting of (* is required): - `data.hook`*, the name of the NodeBB hook - `data.method`*, the method called in that plugin - `data.priority`, the relative priority of the method when it is eventually called (default: 10) - */ - Plugins.registerHook = function (id, data, callback) { - callback = callback || function () {}; - function register() { + + Plugins.internals = { + _register: function (data, callback) { Plugins.loadedHooks[data.hook] = Plugins.loadedHooks[data.hook] || []; Plugins.loadedHooks[data.hook].push(data); callback(); - } + }, + }; + + /* + `data` is an object consisting of (* is required): + `data.hook`*, the name of the NodeBB hook + `data.method`*, the method called in that plugin (can be an array of functions) + `data.priority`, the relative priority of the method when it is eventually called (default: 10) + */ + Plugins.registerHook = function (id, data, callback) { + callback = callback || function () {}; if (!data.hook) { winston.warn('[plugins/' + id + '] registerHook called with invalid data.hook', data); @@ -48,7 +52,13 @@ module.exports = function (Plugins) { data.priority = 10; } - if (typeof data.method === 'string' && data.method.length > 0) { + if (Array.isArray(data.method) && data.method.every(method => typeof method === 'function' || typeof method === 'string')) { + // Go go gadget recursion! + async.eachSeries(data.method, function (method, next) { + const singularData = Object.assign({}, data, { method: method }); + Plugins.registerHook(id, singularData, next); + }, callback); + } else if (typeof data.method === 'string' && data.method.length > 0) { method = data.method.split('.').reduce(function (memo, prop) { if (memo && memo[prop]) { return memo[prop]; @@ -60,9 +70,9 @@ module.exports = function (Plugins) { // Write the actual method reference to the hookObj data.method = method; - register(); + Plugins.internals._register(data, callback); } else if (typeof data.method === 'function') { - register(); + Plugins.internals._register(data, callback); } else { winston.warn('[plugins/' + id + '] Hook method mismatch: ' + data.hook + ' => ' + data.method); return callback(); diff --git a/src/posts.js b/src/posts.js index 5c5bd993a4..944a5c5f76 100644 --- a/src/posts.js +++ b/src/posts.js @@ -26,6 +26,7 @@ require('./posts/votes')(Posts); require('./posts/bookmarks')(Posts); require('./posts/queue')(Posts); require('./posts/diffs')(Posts); +require('./posts/uploads')(Posts); Posts.exists = function (pid, callback) { db.isSortedSetMember('posts:pid', pid, callback); diff --git a/src/posts/create.js b/src/posts/create.js index cbfd9de214..dc6f1a9224 100644 --- a/src/posts/create.js +++ b/src/posts/create.js @@ -101,6 +101,7 @@ module.exports = function (Posts) { function (next) { db.incrObjectField('global', 'postCount', next); }, + async.apply(Posts.uploads.sync, postData.pid), ], function (err) { next(err); }); diff --git a/src/posts/edit.js b/src/posts/edit.js index 1f9ffcf5ad..37c92563d3 100644 --- a/src/posts/edit.js +++ b/src/posts/edit.js @@ -73,6 +73,7 @@ module.exports = function (Posts) { Posts.diffs.save(data.pid, oldContent, data.content, next); }, + async.apply(Posts.uploads.sync, data.pid), function (next) { postData.cid = results.topic.cid; postData.topic = results.topic; diff --git a/src/posts/uploads.js b/src/posts/uploads.js new file mode 100644 index 0000000000..496ff9e068 --- /dev/null +++ b/src/posts/uploads.js @@ -0,0 +1,110 @@ +'use strict'; + +var async = require('async'); +var crypto = require('crypto'); +var fs = require('fs'); +var path = require('path'); + +var db = require('../database'); + +module.exports = function (Posts) { + Posts.uploads = {}; + + const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); + const pathPrefix = path.join(__dirname, '../../public/uploads/files'); + + Posts.uploads.sync = function (pid, callback) { + // Scans a post and updates sorted set of uploads + const searchRegex = /\/assets\/uploads\/files\/([^\s")]+\.?[\w]*)/g; + + async.parallel({ + content: async.apply(Posts.getPostField, pid, 'content'), + uploads: async.apply(Posts.uploads.list, pid), + }, function (err, data) { + if (err) { + return callback(err); + } + + // Extract upload file paths from post content + let match = searchRegex.exec(data.content); + const uploads = []; + while (match) { + uploads.push(match[1].replace('-resized', '')); + match = searchRegex.exec(data.content); + } + + // Create add/remove sets + const add = uploads.filter(path => !data.uploads.includes(path)); + const remove = data.uploads.filter(path => !uploads.includes(path)); + + async.parallel([ + async.apply(Posts.uploads.associate, pid, add), + async.apply(Posts.uploads.dissociate, pid, remove), + ], function (err) { + // Strictly return only err + callback(err); + }); + }); + }; + + Posts.uploads.list = function (pid, callback) { + // Returns array of this post's uploads + db.getSortedSetRange('post:' + pid + ':uploads', 0, -1, callback); + }; + + Posts.uploads.isOrphan = function (filePath, callback) { + // Returns bool indicating whether a file is still CURRENTLY included in any posts + db.sortedSetCard('upload:' + md5(filePath) + ':pids', function (err, length) { + callback(err, length === 0); + }); + }; + + Posts.uploads.getUsage = function (filePaths, callback) { + // Given an array of file names, determines which pids they are used in + if (!Array.isArray(filePaths)) { + filePaths = [filePaths]; + } + + const keys = filePaths.map(fileObj => 'upload:' + md5(fileObj.name.replace('-resized', '')) + ':pids'); + async.map(keys, function (key, next) { + db.getSortedSetRange(key, 0, -1, next); + }, callback); + }; + + Posts.uploads.associate = function (pid, filePaths, callback) { + // Adds an upload to a post's sorted set of uploads + const now = Date.now(); + filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths; + const scores = filePaths.map(() => now); + + async.filter(filePaths, function (filePath, next) { + // Only process files that exist + fs.access(path.join(pathPrefix, filePath), fs.constants.F_OK | fs.constants.R_OK, function (err) { + next(null, !err); + }); + }, function (err, filePaths) { + let methods = [async.apply(db.sortedSetAdd.bind(db), 'post:' + pid + ':uploads', scores, filePaths)]; + if (err) { + return callback(err); + } + + methods = methods.concat(filePaths.map(path => async.apply(db.sortedSetAdd.bind(db), 'upload:' + md5(path) + ':pids', now, pid))); + async.parallel(methods, function (err) { + // Strictly return only err + callback(err); + }); + }); + }; + + Posts.uploads.dissociate = function (pid, filePaths, callback) { + // Removes an upload from a post's sorted set of uploads + filePaths = !Array.isArray(filePaths) ? [filePaths] : filePaths; + let methods = [async.apply(db.sortedSetRemove.bind(db), 'post:' + pid + ':uploads', filePaths)]; + methods = methods.concat(filePaths.map(path => async.apply(db.sortedSetRemove.bind(db), 'upload:' + md5(path) + ':pids', pid))); + + async.parallel(methods, function (err) { + // Strictly return only err + callback(err); + }); + }; +}; diff --git a/src/posts/user.js b/src/posts/user.js index 0a5a1d0979..dc61786eda 100644 --- a/src/posts/user.js +++ b/src/posts/user.js @@ -2,36 +2,48 @@ var async = require('async'); var validator = require('validator'); +var _ = require('lodash'); var user = require('../user'); var groups = require('../groups'); var meta = require('../meta'); var plugins = require('../plugins'); +var privileges = require('../privileges'); module.exports = function (Posts) { Posts.getUserInfoForPosts = function (uids, uid, callback) { var groupsMap = {}; var userData; var userSettings; + var canUseSignature; + async.waterfall([ function (next) { async.parallel({ userData: function (next) { - user.getUsersFields(uids, ['uid', 'username', 'fullname', 'userslug', 'reputation', 'postcount', 'picture', 'signature', 'banned', 'status', 'lastonline', 'groupTitle'], next); + user.getUsersFields(uids, [ + 'uid', 'username', 'fullname', 'userslug', + 'reputation', 'postcount', 'picture', 'signature', + 'banned', 'status', 'lastonline', 'groupTitle', + ], next); }, userSettings: function (next) { user.getMultipleUserSettings(uids, next); }, + canUseSignature: function (next) { + privileges.global.can('signature', uid, next); + }, }, next); }, function (results, next) { userData = results.userData; userSettings = results.userSettings; + canUseSignature = results.canUseSignature; var groupTitles = userData.map(function (userData) { - return userData && userData.groupTitle; - }).filter(function (groupTitle, index, array) { - return groupTitle && array.indexOf(groupTitle) === index; + return userData && userData.groupTitleArray; }); + groupTitles = _.uniq(_.flatten(groupTitles)); + groups.getGroupsData(groupTitles, next); }, function (groupsData, next) { @@ -58,6 +70,8 @@ module.exports = function (Posts) { userData.status = user.getStatus(userData); userData.signature = validator.escape(String(userData.signature || '')); userData.fullname = userSettings[index].showfullname ? validator.escape(String(userData.fullname || '')) : undefined; + userData.selectedGroups = []; + if (parseInt(meta.config.hideFullname, 10) === 1) { userData.fullname = undefined; } @@ -67,14 +81,14 @@ module.exports = function (Posts) { async.waterfall([ function (next) { async.parallel({ - isMemberOfGroup: function (next) { - if (!userData.groupTitle || !groupsMap[userData.groupTitle]) { + isMemberOfGroups: function (next) { + if (!Array.isArray(userData.groupTitleArray) || !userData.groupTitleArray.length) { return next(); } - groups.isMember(userData.uid, userData.groupTitle, next); + groups.isMemberOfGroups(userData.uid, userData.groupTitleArray, next); }, signature: function (next) { - if (!userData.signature || parseInt(meta.config.disableSignatures, 10) === 1) { + if (!userData.signature || !canUseSignature || parseInt(meta.config.disableSignatures, 10) === 1) { userData.signature = ''; return next(); } @@ -86,8 +100,12 @@ module.exports = function (Posts) { }, next); }, function (results, next) { - if (results.isMemberOfGroup && userData.groupTitle && groupsMap[userData.groupTitle]) { - userData.selectedGroup = groupsMap[userData.groupTitle]; + if (results.isMemberOfGroups && userData.groupTitleArray) { + userData.groupTitleArray.forEach(function (userGroup, index) { + if (results.isMemberOfGroups[index] && groupsMap[userGroup]) { + userData.selectedGroups.push(groupsMap[userGroup]); + } + }); } userData.custom_profile_info = results.customProfileInfo.profile; diff --git a/src/privileges/global.js b/src/privileges/global.js index f1f88c4fff..3f2955d5be 100644 --- a/src/privileges/global.js +++ b/src/privileges/global.js @@ -16,12 +16,14 @@ module.exports = function (privileges) { { name: 'Chat' }, { name: 'Upload Images' }, { name: 'Upload Files' }, + { name: 'Signature' }, ]; privileges.global.userPrivilegeList = [ 'chat', 'upload:post:image', 'upload:post:file', + 'signature', ]; privileges.global.groupPrivilegeList = privileges.global.userPrivilegeList.map(function (privilege) { diff --git a/src/routes/accounts.js b/src/routes/accounts.js index e0a3acb6c8..c46b48a29e 100644 --- a/src/routes/accounts.js +++ b/src/routes/accounts.js @@ -30,6 +30,8 @@ module.exports = function (app, middleware, controllers) { setupPageRoute(app, '/user/:userslug/edit/password', middleware, accountMiddlewares, controllers.accounts.edit.password); setupPageRoute(app, '/user/:userslug/info', middleware, accountMiddlewares, controllers.accounts.info.get); setupPageRoute(app, '/user/:userslug/settings', middleware, accountMiddlewares, controllers.accounts.settings.get); + setupPageRoute(app, '/user/:userslug/uploads', middleware, accountMiddlewares, controllers.accounts.uploads.get); + setupPageRoute(app, '/user/:userslug/consent', middleware, accountMiddlewares, controllers.accounts.consent.get); app.delete('/api/user/:userslug/session/:uuid', [middleware.exposeUid, middleware.ensureSelfOrGlobalPrivilege], controllers.accounts.session.revoke); diff --git a/src/routes/api.js b/src/routes/api.js index 34f14f5a8c..6d06a2f93e 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -15,6 +15,10 @@ module.exports = function (app, middleware, controllers) { router.get('/user/username/:username', middleware.checkGlobalPrivacySettings, controllers.user.getUserByUsername); router.get('/user/email/:email', middleware.checkGlobalPrivacySettings, controllers.user.getUserByEmail); + router.get('/user/uid/:uid/export/posts', middleware.checkAccountPermissions, controllers.user.exportPosts); + router.get('/user/uid/:uid/export/uploads', middleware.checkAccountPermissions, controllers.user.exportUploads); + router.get('/user/uid/:uid/export/profile', middleware.checkAccountPermissions, controllers.user.exportProfile); + router.get('/:type/pid/:id', controllers.api.getObject); router.get('/:type/tid/:id', controllers.api.getObject); router.get('/:type/cid/:id', controllers.api.getObject); diff --git a/src/socket.io/user.js b/src/socket.io/user.js index 36026a7f28..4f16e82eda 100644 --- a/src/socket.io/user.js +++ b/src/socket.io/user.js @@ -340,3 +340,16 @@ SocketUser.setModerationNote = function (socket, data, callback) { }, ], callback); }; + +SocketUser.deleteUpload = function (socket, data, callback) { + if (!data || !data.name || !data.uid) { + return callback(new Error('[[error:invalid-data]]')); + } + user.deleteUpload(socket.uid, data.uid, data.name, callback); +}; + +SocketUser.gdpr = {}; + +SocketUser.gdpr.consent = function (socket, data, callback) { + user.setUserField(socket.uid, 'gdpr_consent', 1, callback); +}; diff --git a/src/upgrades/1.8.0/give_signature_privileges.js b/src/upgrades/1.8.0/give_signature_privileges.js new file mode 100644 index 0000000000..87d93605f5 --- /dev/null +++ b/src/upgrades/1.8.0/give_signature_privileges.js @@ -0,0 +1,11 @@ +'use strict'; + +var privileges = require('../../privileges'); + +module.exports = { + name: 'Give registered users signature privilege', + timestamp: Date.UTC(2018, 1, 28), + method: function (callback) { + privileges.global.give(['signature'], 'registered-users', callback); + }, +}; diff --git a/src/upgrades/1.9.0/refresh_post_upload_associations.js b/src/upgrades/1.9.0/refresh_post_upload_associations.js new file mode 100644 index 0000000000..e176835940 --- /dev/null +++ b/src/upgrades/1.9.0/refresh_post_upload_associations.js @@ -0,0 +1,21 @@ +'use strict'; + +var async = require('async'); +var posts = require('../../posts'); + +module.exports = { + name: 'Refresh post-upload associations', + timestamp: Date.UTC(2018, 3, 16), + method: function (callback) { + var progress = this.progress; + + require('../../batch').processSortedSet('posts:pid', function (pids, next) { + async.each(pids, function (pid, next) { + posts.uploads.sync(pid, next); + progress.incr(); + }, next); + }, { + progress: this.progress, + }, callback); + }, +}; diff --git a/src/user.js b/src/user.js index 4ded5ca410..634bff8f4a 100644 --- a/src/user.js +++ b/src/user.js @@ -37,6 +37,7 @@ require('./user/password')(User); require('./user/info')(User); require('./user/online')(User); require('./user/blocks')(User); +require('./user/uploads')(User); User.getUidsFromSet = function (set, start, stop, callback) { if (set === 'users:online') { @@ -349,25 +350,50 @@ User.getModeratedCids = function (uid, callback) { User.addInterstitials = function (callback) { plugins.registerHook('core', { hook: 'filter:register.interstitial', - method: function (data, callback) { - if (meta.config.termsOfUse && !data.userData.acceptTos) { - data.interstitials.push({ - template: 'partials/acceptTos', - data: { - termsOfUse: meta.config.termsOfUse, - }, - callback: function (userData, formData, next) { - if (formData['agree-terms'] === 'on') { - userData.acceptTos = true; - } + method: [ + // GDPR information collection/processing consent + email consent + function (data, callback) { + if (!data.userData.gdpr_consent) { + data.interstitials.push({ + template: 'partials/gdpr_consent', + data: { + digestFrequency: meta.config.dailyDigestFreq, + digestEnabled: meta.config.dailyDigestFreq !== 'off', + }, + callback: function (userData, formData, next) { + if (formData.gdpr_agree_data === 'on' && formData.gdpr_agree_email === 'on') { + userData.gdpr_consent = true; + } - next(userData.acceptTos ? null : new Error('[[register:terms_of_use_error]]')); - }, - }); - } + next(userData.gdpr_consent ? null : new Error('[[register:gdpr_consent_denied]]')); + }, + }); + } - callback(null, data); - }, + setImmediate(callback, null, data); + }, + + // Forum Terms of Use + function (data, callback) { + if (meta.config.termsOfUse && !data.userData.acceptTos) { + data.interstitials.push({ + template: 'partials/acceptTos', + data: { + termsOfUse: meta.config.termsOfUse, + }, + callback: function (userData, formData, next) { + if (formData['agree-terms'] === 'on') { + userData.acceptTos = true; + } + + next(userData.acceptTos ? null : new Error('[[register:terms_of_use_error]]')); + }, + }); + } + + setImmediate(callback, null, data); + }, + ], }); callback(); diff --git a/src/user/create.js b/src/user/create.js index f2a9b87f55..d6718e8a4d 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -46,6 +46,7 @@ module.exports = function (User) { lastposttime: 0, banned: 0, status: 'online', + gdpr_consent: data.gdpr_consent === true ? 1 : 0, }; User.uniqueUsername(userData, next); diff --git a/src/user/data.js b/src/user/data.js index db41509aa0..05ebc2f9b2 100644 --- a/src/user/data.js +++ b/src/user/data.js @@ -80,7 +80,7 @@ module.exports = function (User) { fields = fields.filter(function (field) { var isFieldWhitelisted = field && results.whitelist.includes(field); if (!isFieldWhitelisted) { - winston.verbose('[user/getUsersFields] ' + field + ' removed because it is not whitelisted, see `filter:user.whietlistFields`'); + winston.verbose('[user/getUsersFields] ' + field + ' removed because it is not whitelisted, see `filter:user.whitelistFields`'); } return isFieldWhitelisted; }); @@ -135,7 +135,9 @@ module.exports = function (User) { if (!user) { return; } - + if (user.hasOwnProperty('groupTitle')) { + parseGroupTitle(user); + } if (user.hasOwnProperty('username')) { user.username = validator.escape(user.username ? user.username.toString() : ''); } @@ -192,6 +194,20 @@ module.exports = function (User) { plugins.fireHook('filter:users.get', users, callback); } + function parseGroupTitle(user) { + try { + user.groupTitleArray = JSON.parse(user.groupTitle); + } catch (err) { + user.groupTitleArray = [user.groupTitle]; + } + if (!Array.isArray(user.groupTitleArray)) { + user.groupTitleArray = [user.groupTitleArray]; + } + if (parseInt(meta.config.allowMultipleBadges, 10) !== 1) { + user.groupTitleArray = [user.groupTitleArray[0]]; + } + } + User.getDefaultAvatar = function () { if (!meta.config.defaultAvatar) { return ''; diff --git a/src/user/delete.js b/src/user/delete.js index ace9dd969c..6712569634 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -2,6 +2,8 @@ var async = require('async'); var _ = require('lodash'); +var path = require('path'); +var nconf = require('nconf'); var db = require('../database'); var posts = require('../posts'); @@ -10,6 +12,7 @@ var groups = require('../groups'); var messaging = require('../messaging'); var plugins = require('../plugins'); var batch = require('../batch'); +var file = require('../file'); module.exports = function (User) { User.delete = function (callerUid, uid, callback) { @@ -24,6 +27,9 @@ module.exports = function (User) { function (next) { deleteTopics(callerUid, uid, next); }, + function (next) { + deleteUploads(uid, next); + }, function (next) { User.deleteAccount(uid, next); }, @@ -46,6 +52,21 @@ module.exports = function (User) { }, { alwaysStartAt: 0 }, callback); } + function deleteUploads(uid, callback) { + batch.processSortedSet('uid:' + uid + ':uploads', function (uploadNames, next) { + async.waterfall([ + function (next) { + async.each(uploadNames, function (uploadName, next) { + file.delete(path.join(nconf.get('upload_path'), uploadName), next); + }, next); + }, + function (next) { + db.sortedSetRemove('uid:' + uid + ':uploads', uploadNames, next); + }, + ], next); + }, { alwaysStartAt: 0 }, callback); + } + User.deleteAccount = function (uid, callback) { var userData; async.waterfall([ diff --git a/src/user/uploads.js b/src/user/uploads.js new file mode 100644 index 0000000000..95b342ad27 --- /dev/null +++ b/src/user/uploads.js @@ -0,0 +1,50 @@ +'use strict'; + +var async = require('async'); +var path = require('path'); +var nconf = require('nconf'); + +var db = require('../database'); +var file = require('../file'); +var batch = require('../batch'); + +module.exports = function (User) { + User.deleteUpload = function (callerUid, uid, uploadName, callback) { + async.waterfall([ + function (next) { + async.parallel({ + isUsersUpload: function (next) { + db.isSortedSetMember('uid:' + callerUid + ':uploads', uploadName, next); + }, + isAdminOrGlobalMod: function (next) { + User.isAdminOrGlobalMod(callerUid, next); + }, + }, next); + }, + function (results, next) { + if (!results.isAdminOrGlobalMod && !results.isUsersUpload) { + return next(new Error('[[error:no-privileges]]')); + } + + file.delete(path.join(nconf.get('upload_path'), uploadName), next); + }, + function (next) { + db.sortedSetRemove('uid:' + uid + ':uploads', uploadName, next); + }, + ], callback); + }; + + User.collateUploads = function (uid, archive, callback) { + batch.processSortedSet('uid:' + uid + ':uploads', function (files, next) { + files.forEach(function (file) { + archive.file(path.join(nconf.get('upload_path'), file), { + name: path.basename(file), + }); + }); + + setImmediate(next); + }, function (err) { + callback(err); + }); + }; +}; diff --git a/src/views/admin/manage/uploads.tpl b/src/views/admin/manage/uploads.tpl index dbddede2ef..9751504b50 100644 --- a/src/views/admin/manage/uploads.tpl +++ b/src/views/admin/manage/uploads.tpl @@ -8,6 +8,7 @@ [[admin/manage/uploads:filename]] + [[admin/manage/uploads:usage]] [[admin/manage/uploads:size/filecount]] @@ -16,17 +17,28 @@ - + {files.name} - + {files.name} + + + + @value + + + [[admin/manage/uploads:orphaned]] + + + + {files.sizeHumanReadable}[[admin/manage/uploads:filecount, {files.fileCount}]] diff --git a/src/views/admin/settings/group.tpl b/src/views/admin/settings/group.tpl index fd696cb5ad..6f267baf57 100644 --- a/src/views/admin/settings/group.tpl +++ b/src/views/admin/settings/group.tpl @@ -29,6 +29,17 @@ [[admin/settings/group:allow-creation-help]]

+
+ +
+ +

+ [[admin/settings/group:allow-multiple-badges-help]] +

+ diff --git a/src/views/partials/gdpr_consent.tpl b/src/views/partials/gdpr_consent.tpl new file mode 100644 index 0000000000..7ee5b7db4d --- /dev/null +++ b/src/views/partials/gdpr_consent.tpl @@ -0,0 +1,23 @@ +
+

[[user:consent.lead]]

+

[[user:consent.intro]]

+
+ +
+

+ [[user:consent.email_intro]] + + [[user:consent.digest_frequency, {digestFrequency}]] + + [[user:consent.digest_off]] + +

+ +
+ +
+
\ No newline at end of file diff --git a/test/authentication.js b/test/authentication.js index 99512c7af4..b90d585971 100644 --- a/test/authentication.js +++ b/test/authentication.js @@ -56,6 +56,7 @@ describe('authentication', function () { username: username, password: password, 'password-confirm': password, + gdpr_consent: true, }, json: true, jar: jar, @@ -150,6 +151,7 @@ describe('authentication', function () { password: 'adminpwd', 'password-confirm': 'adminpwd', userLang: 'it', + gdpr_consent: true, }, json: true, jar: jar, diff --git a/test/categories.js b/test/categories.js index 90198f91b4..d0d446942b 100644 --- a/test/categories.js +++ b/test/categories.js @@ -668,6 +668,7 @@ describe('Categories', function () { chat: false, 'upload:post:image': false, 'upload:post:file': false, + signature: false, }); done(); @@ -704,6 +705,7 @@ describe('Categories', function () { 'groups:chat': true, 'groups:upload:post:image': true, 'groups:upload:post:file': false, + 'groups:signature': true, }); done(); diff --git a/test/controllers.js b/test/controllers.js index b2bc5973e8..2a8a70d589 100644 --- a/test/controllers.js +++ b/test/controllers.js @@ -1336,13 +1336,17 @@ describe('Controllers', function () { name: 'selectedGroup', }, function (err) { assert.ifError(err); - groups.join('selectedGroup', fooUid, function (err) { + user.create({ username: 'groupie' }, function (err, uid) { assert.ifError(err); - request(nconf.get('url') + '/api/user/foo', { json: true }, function (err, res, body) { + groups.join('selectedGroup', uid, function (err) { assert.ifError(err); - assert.equal(res.statusCode, 200); - assert(body.selectedGroup.name, 'selectedGroup'); - done(); + request(nconf.get('url') + '/api/user/groupie', { json: true }, function (err, res, body) { + assert.ifError(err); + assert.equal(res.statusCode, 200); + assert(Array.isArray(body.selectedGroup)); + assert.equal(body.selectedGroup[0].name, 'selectedGroup'); + done(); + }); }); }); }); diff --git a/test/flags.js b/test/flags.js index 6dbcd5e08c..31672844bf 100644 --- a/test/flags.js +++ b/test/flags.js @@ -155,15 +155,18 @@ describe('Flags', function () { describe('.list()', function () { it('should show a list of flags (with one item)', function (done) { - Flags.list({}, 1, function (err, flags) { + Flags.list({}, 1, function (err, payload) { assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.equal(flags.length, 1); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.equal(payload.flags.length, 1); - Flags.get(flags[0].flagId, function (err, flagData) { + Flags.get(payload.flags[0].flagId, function (err, flagData) { assert.ifError(err); - assert.equal(flags[0].flagId, flagData.flagId); - assert.equal(flags[0].description, flagData.description); + assert.equal(payload.flags[0].flagId, flagData.flagId); + assert.equal(payload.flags[0].description, flagData.description); done(); }); }); @@ -173,10 +176,13 @@ describe('Flags', function () { it('should return a filtered list of flags if said filters are passed in', function (done) { Flags.list({ state: 'open', - }, 1, function (err, flags) { + }, 1, function (err, payload) { assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.strictEqual(1, parseInt(flags[0].flagId, 10)); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(1, parseInt(payload.flags[0].flagId, 10)); done(); }); }); @@ -184,10 +190,13 @@ describe('Flags', function () { it('should return no flags if a filter with no matching flags is used', function (done) { Flags.list({ state: 'rejected', - }, 1, function (err, flags) { + }, 1, function (err, payload) { assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.strictEqual(0, flags.length); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(0, payload.flags.length); done(); }); }); @@ -195,10 +204,13 @@ describe('Flags', function () { it('should return a flag when filtered by cid 1', function (done) { Flags.list({ cid: 1, - }, 1, function (err, flags) { + }, 1, function (err, payload) { assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.strictEqual(1, flags.length); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(1, payload.flags.length); done(); }); }); @@ -206,10 +218,13 @@ describe('Flags', function () { it('shouldn\'t return a flag when filtered by cid 2', function (done) { Flags.list({ cid: 2, - }, 1, function (err, flags) { + }, 1, function (err, payload) { assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.strictEqual(0, flags.length); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(0, payload.flags.length); done(); }); }); @@ -217,10 +232,13 @@ describe('Flags', function () { it('should return a flag when filtered by both cid 1 and 2', function (done) { Flags.list({ cid: [1, 2], - }, 1, function (err, flags) { + }, 1, function (err, payload) { assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.strictEqual(1, flags.length); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(1, payload.flags.length); done(); }); }); @@ -229,10 +247,13 @@ describe('Flags', function () { Flags.list({ cid: [1, 2], state: 'open', - }, 1, function (err, flags) { + }, 1, function (err, payload) { assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.strictEqual(1, flags.length); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(1, payload.flags.length); done(); }); }); @@ -241,10 +262,13 @@ describe('Flags', function () { Flags.list({ cid: [1, 2], state: 'resolved', - }, 1, function (err, flags) { + }, 1, function (err, payload) { assert.ifError(err); - assert.ok(Array.isArray(flags)); - assert.strictEqual(0, flags.length); + assert.ok(payload.hasOwnProperty('flags')); + assert.ok(payload.hasOwnProperty('page')); + assert.ok(payload.hasOwnProperty('pageCount')); + assert.ok(Array.isArray(payload.flags)); + assert.strictEqual(0, payload.flags.length); done(); }); }); diff --git a/test/mocks/databasemock.js b/test/mocks/databasemock.js index ab99b38e4c..6d3f858998 100644 --- a/test/mocks/databasemock.js +++ b/test/mocks/databasemock.js @@ -205,7 +205,7 @@ function setupDefaultConfigs(meta, next) { function giveDefaultGlobalPrivileges(next) { var privileges = require('../../src/privileges'); - privileges.global.give(['chat', 'upload:post:image'], 'registered-users', next); + privileges.global.give(['chat', 'upload:post:image', 'signature'], 'registered-users', next); } function enableDefaultPlugins(callback) { diff --git a/test/posts.js b/test/posts.js index 84114d0a91..f6b2c28261 100644 --- a/test/posts.js +++ b/test/posts.js @@ -5,6 +5,9 @@ var assert = require('assert'); var async = require('async'); var request = require('request'); var nconf = require('nconf'); +var crypto = require('crypto'); +var fs = require('fs'); +var path = require('path'); var db = require('./mocks/databasemock'); var topics = require('../src/topics'); @@ -877,4 +880,225 @@ describe('Post\'s', function () { ], done); }); }); + + describe('upload methods', function () { + var pid; + + before(function (done) { + // Create stub files for testing + ['abracadabra.png', 'shazam.jpg', 'whoa.gif', 'amazeballs.jpg', 'wut.txt', 'test.bmp'] + .forEach(filename => fs.closeSync(fs.openSync(path.join(__dirname, '../public/uploads/files', filename), 'w'))); + + topics.post({ + uid: 1, + cid: 1, + title: 'topic with some images', + content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png) and another [alt text](/assets/uploads/files/shazam.jpg)', + }, function (err, topicPostData) { + assert.ifError(err); + pid = topicPostData.postData.pid; + done(); + }); + }); + + describe('.sync()', function () { + it('should properly add new images to the post\'s zset', function (done) { + posts.uploads.sync(pid, function (err) { + assert.ifError(err); + + db.sortedSetCard('post:' + pid + ':uploads', function (err, length) { + assert.ifError(err); + assert.strictEqual(2, length); + done(); + }); + }); + }); + + it('should remove an image if it is edited out of the post', function (done) { + async.series([ + function (next) { + posts.edit({ + pid: pid, + uid: 1, + content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png)... AND NO MORE!', + }, next); + }, + async.apply(posts.uploads.sync, pid), + ], function (err) { + assert.ifError(err); + db.sortedSetCard('post:' + pid + ':uploads', function (err, length) { + assert.ifError(err); + assert.strictEqual(1, length); + done(); + }); + }); + }); + }); + + describe('.list()', function () { + it('should display the uploaded files for a specific post', function (done) { + posts.uploads.list(pid, function (err, uploads) { + assert.ifError(err); + assert.equal(true, Array.isArray(uploads)); + assert.strictEqual(1, uploads.length); + assert.equal('string', typeof uploads[0]); + done(); + }); + }); + }); + + describe('.isOrphan()', function () { + it('should return false if upload is not an orphan', function (done) { + posts.uploads.isOrphan('abracadabra.png', function (err, isOrphan) { + assert.ifError(err); + assert.equal(false, isOrphan); + done(); + }); + }); + + it('should return true if upload is an orphan', function (done) { + posts.uploads.isOrphan('shazam.jpg', function (err, isOrphan) { + assert.ifError(err); + assert.equal(true, isOrphan); + done(); + }); + }); + }); + + describe('.associate()', function () { + it('should add an image to the post\'s maintained list of uploads', function (done) { + async.waterfall([ + async.apply(posts.uploads.associate, pid, 'whoa.gif'), + async.apply(posts.uploads.list, pid), + ], function (err, uploads) { + assert.ifError(err); + assert.strictEqual(2, uploads.length); + assert.strictEqual(true, uploads.includes('whoa.gif')); + done(); + }); + }); + + it('should allow arrays to be passed in', function (done) { + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['amazeballs.jpg', 'wut.txt']), + async.apply(posts.uploads.list, pid), + ], function (err, uploads) { + assert.ifError(err); + assert.strictEqual(4, uploads.length); + assert.strictEqual(true, uploads.includes('amazeballs.jpg')); + assert.strictEqual(true, uploads.includes('wut.txt')); + done(); + }); + }); + + it('should save a reverse association of md5sum to pid', function (done) { + const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); + + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['test.bmp']), + function (next) { + db.getSortedSetRange('upload:' + md5('test.bmp') + ':pids', 0, -1, next); + }, + ], function (err, pids) { + assert.ifError(err); + assert.strictEqual(true, Array.isArray(pids)); + assert.strictEqual(true, pids.length > 0); + assert.equal(pid, pids[0]); + done(); + }); + }); + + it('should not associate a file that does not exist on the local disk', function (done) { + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['nonexistant.xls']), + async.apply(posts.uploads.list, pid), + ], function (err, uploads) { + assert.ifError(err); + assert.strictEqual(uploads.length, 5); + assert.strictEqual(false, uploads.includes('nonexistant.xls')); + done(); + }); + }); + }); + + describe('.dissociate()', function () { + it('should remove an image from the post\'s maintained list of uploads', function (done) { + async.waterfall([ + async.apply(posts.uploads.dissociate, pid, 'whoa.gif'), + async.apply(posts.uploads.list, pid), + ], function (err, uploads) { + assert.ifError(err); + assert.strictEqual(4, uploads.length); + assert.strictEqual(false, uploads.includes('whoa.gif')); + done(); + }); + }); + + it('should allow arrays to be passed in', function (done) { + async.waterfall([ + async.apply(posts.uploads.dissociate, pid, ['amazeballs.jpg', 'wut.txt']), + async.apply(posts.uploads.list, pid), + ], function (err, uploads) { + assert.ifError(err); + assert.strictEqual(2, uploads.length); + assert.strictEqual(false, uploads.includes('amazeballs.jpg')); + assert.strictEqual(false, uploads.includes('wut.txt')); + done(); + }); + }); + }); + }); + + describe('post uploads management', function () { + let topic; + let reply; + before(function (done) { + topics.post({ + uid: 1, + cid: cid, + title: 'topic to test uploads with', + content: '[abcdef](/assets/uploads/files/abracadabra.png)', + }, function (err, topicPostData) { + assert.ifError(err); + topics.reply({ + uid: 1, + tid: topicPostData.topicData.tid, + timestamp: Date.now(), + content: '[abcdef](/assets/uploads/files/shazam.jpg)', + }, function (err, replyData) { + assert.ifError(err); + topic = topicPostData; + reply = replyData; + done(); + }); + }); + }); + + it('should automatically sync uploads on topic create and reply', function (done) { + db.sortedSetsCard(['post:' + topic.topicData.mainPid + ':uploads', 'post:' + reply.pid + ':uploads'], function (err, lengths) { + assert.ifError(err); + assert.strictEqual(1, lengths[0]); + assert.strictEqual(1, lengths[1]); + done(); + }); + }); + + it('should automatically sync uploads on post edit', function (done) { + async.waterfall([ + async.apply(posts.edit, { + pid: reply.pid, + uid: 1, + content: 'no uploads', + }), + function (postData, next) { + posts.uploads.list(reply.pid, next); + }, + ], function (err, uploads) { + assert.ifError(err); + assert.strictEqual(true, Array.isArray(uploads)); + assert.strictEqual(0, uploads.length); + done(); + }); + }); + }); }); diff --git a/test/uploads.js b/test/uploads.js index db8305d592..77fa0832ee 100644 --- a/test/uploads.js +++ b/test/uploads.js @@ -159,6 +159,41 @@ describe('Upload Controllers', function () { done(); }); }); + + it('should delete users uploads if account is deleted', function (done) { + var jar; + var uid; + var url; + var file = require('../src/file'); + + async.waterfall([ + function (next) { + user.create({ username: 'uploader', password: 'barbar' }, next); + }, + function (_uid, next) { + uid = _uid; + helpers.loginUser('uploader', 'barbar', next); + }, + function (jar, csrf_token, next) { + helpers.uploadFile(nconf.get('url') + '/api/post/upload', path.join(__dirname, '../test/files/test.png'), {}, jar, csrf_token, next); + }, + function (res, body, next) { + assert(body); + assert(body[0].url); + url = body[0].url; + + user.delete(1, uid, next); + }, + function (next) { + var filePath = path.join(nconf.get('upload_path'), url.replace('/assets/uploads', '')); + file.exists(filePath, next); + }, + function (exists, next) { + assert(!exists); + done(); + }, + ], done); + }); }); describe('admin uploads', function () { diff --git a/test/user.js b/test/user.js index 6e9c5a586a..5701787071 100644 --- a/test/user.js +++ b/test/user.js @@ -1434,6 +1434,7 @@ describe('User', function () { password: '123456', 'password-confirm': '123456', email: '