Merge branch 'master' into user-blocking

v1.18.x
Julian Lam
commit dc386b5b23

1
.gitignore vendored

@ -61,7 +61,6 @@ tx.exe
coverage
.nyc_output
build
*.log
test/files/normalise.jpg.png
test/files/normalise-resized.jpg

4
build/.gitignore vendored

@ -0,0 +1,4 @@
*
*/
!export
!.gitignore

@ -0,0 +1,3 @@
.
!.gitignore
!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.

@ -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",

@ -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"

@ -5,6 +5,7 @@
"private-groups.warning": "<strong>Beware!</strong> 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 <em>(Default: disabled)</em>",
"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",

@ -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",

@ -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?",

@ -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",

@ -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."
}

@ -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."
}

@ -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 &amp; 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.<br /><br />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 &amp; Consent page.<br /><br />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&apos;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&apos;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)"
}

@ -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;
});

@ -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) {

@ -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;
});

@ -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;

@ -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)) {

@ -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:banned', onEventBanned);
socket.on('event:alert', app.alert);
socket.on('event:alert', app.alert);
}
function onConnect() {
app.isConnected = true;

@ -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;

@ -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;

@ -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 + ']]';

@ -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);
},

@ -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);

@ -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);
};

@ -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);

@ -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');
}

@ -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,

@ -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);

@ -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;

@ -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('&#44; ') + ']]'));
}
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;

@ -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);
});
};

@ -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 () {};

@ -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) {

@ -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);

@ -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) {

@ -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);

@ -12,20 +12,24 @@ module.exports = function (Plugins) {
'action:flag.create': 'action:flags.create',
'action:flag.update': 'action:flags.update',
};
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
`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 () {};
function register() {
Plugins.loadedHooks[data.hook] = Plugins.loadedHooks[data.hook] || [];
Plugins.loadedHooks[data.hook].push(data);
callback();
}
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();

@ -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);

@ -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);
});

@ -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;

@ -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);
});
};
};

@ -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;

@ -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) {

@ -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);

@ -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);

@ -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);
};

@ -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);
},
};

@ -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);
},
};

@ -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;
}
next(userData.acceptTos ? null : new Error('[[register:terms_of_use_error]]'));
},
});
}
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.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();

@ -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);

@ -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 '';

@ -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([

@ -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);
});
};
};

@ -8,6 +8,7 @@
<thead>
<tr>
<th>[[admin/manage/uploads:filename]]</th>
<!-- IF showPids --><th class="text-right">[[admin/manage/uploads:usage]]</th><!-- END -->
<th class="text-right">[[admin/manage/uploads:size/filecount]]</th>
<th></th>
</tr>
@ -16,17 +17,28 @@
<!-- BEGIN files -->
<tr data-path="{files.path}">
<!-- IF files.isDirectory -->
<td class="col-md-9" role="button">
<td class="col-md-6" role="button">
<i class="fa fa-fw fa-folder-o"></i> <a href="{config.relative_path}/admin/manage/uploads?dir={files.path}">{files.name}</a>
</td>
<!-- ENDIF files.isDirectory -->
<!-- IF files.isFile -->
<td class="col-md-9">
<td class="col-md-6">
<i class="fa fa-fw fa-file-text-o"></i> <a href="{config.relative_path}{files.url}" target="_blank">{files.name}</a>
</td>
<!-- ENDIF files.isFile -->
<!-- IF showPids -->
<td class="col-md-3 text-right">
<!-- BEGIN ../inPids -->
<a target="_blank" href="{config.relative_path}/post/@value"><span class="label label-default">@value</span></a>
<!-- END -->
<!-- IF !../inPids.length -->
<span class="label label-danger">[[admin/manage/uploads:orphaned]]</span>
<!-- END -->
</td>
<!-- END -->
<td class="col-md-2 text-right"><!-- IF files.isFile -->{files.sizeHumanReadable}<!-- ELSE -->[[admin/manage/uploads:filecount, {files.fileCount}]]<!-- ENDIF files.isFile --></td>
<td role="button" class="col-md-1 text-right"><i class="delete fa fa-fw fa-trash-o <!-- IF !files.isFile --> hidden<!-- ENDIF !files.isFile -->"></i></td>

@ -29,6 +29,17 @@
[[admin/settings/group:allow-creation-help]]
</p>
<div class="checkbox">
<label class="mdl-switch mdl-js-switch mdl-js-ripple-effect">
<input class="mdl-switch__input" type="checkbox" data-field="allowMultipleBadges">
<span class="mdl-switch__label"><strong>Allow Multiple Badges</strong></span>
</label>
</div>
<p class="help-block">
[[admin/settings/group:allow-multiple-badges-help]]
</p>
<label>[[admin/settings/group:max-name-length]]</label>
<input class="form-control" type="text" placeholder="255" data-field="maximumGroupNameLength" />
</form>

@ -0,0 +1,23 @@
<div class="form-group">
<p class="lead">[[user:consent.lead]]</p>
<p>[[user:consent.intro]]</p>
<div class="checkbox">
<label>
<input type="checkbox" name="gdpr_agree_data" id="gdpr_agree_data"> <strong>[[register:gdpr_agree_data]]</strong>
</label>
</div>
<p>
[[user:consent.email_intro]]
<!-- IF digestEnabled -->
[[user:consent.digest_frequency, {digestFrequency}]]
<!-- ELSE -->
[[user:consent.digest_off]]
<!-- END -->
</p>
<div class="checkbox">
<label>
<input type="checkbox" name="gdpr_agree_email" id="gdpr_agree_email"> <strong>[[register:gdpr_agree_email]]</strong>
</label>
</div>
</div>

@ -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,

@ -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();

@ -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();
});
});
});
});

@ -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();
});
});

@ -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) {

@ -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();
});
});
});
});

@ -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 () {

@ -1434,6 +1434,7 @@ describe('User', function () {
password: '123456',
'password-confirm': '123456',
email: '<script>alert("ok")<script>reject@me.com',
gdpr_consent: true,
}, function (err) {
assert.ifError(err);
helpers.loginUser('admin', '123456', function (err, jar) {
@ -1465,6 +1466,7 @@ describe('User', function () {
password: '123456',
'password-confirm': '123456',
email: 'accept@me.com',
gdpr_consent: true,
}, function (err) {
assert.ifError(err);
socketAdmin.user.acceptRegistration({ uid: adminUid }, { username: 'acceptme' }, function (err, uid) {

Loading…
Cancel
Save