diff --git a/.travis.yml b/.travis.yml index 82838ced67..e89064595e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,13 +5,15 @@ before_install: - "echo 'deb http://downloads-distro.mongodb.org/repo/ubuntu-upstart dist 10gen' | sudo tee /etc/apt/sources.list.d/mongodb.list" - "sudo apt-get update" - "sudo apt-get install mongodb-org-server" - - npm i --production - - node app --setup="{\"url\":\"http://127.0.0.1:4567/\",\"secret\":\"abcdef\",\"database\":\"mongo\",\"mongo:host\":\"127.0.0.1\",\"mongo:port\":27017,\"mongo:username\":\"\",\"mongo:password\":\"\",\"mongo:database\":0,\"redis:host\":\"127.0.0.1\",\"redis:port\":6379,\"redis:password\":\"\",\"redis:database\":0,\"admin:username\":\"admin\",\"admin:email\":\"test@example.org\",\"admin:password\":\"abcdef\",\"admin:password:confirm\":\"abcdef\"}" --ci="{\"host\":\"127.0.0.1\",\"port\":27017,\"database\":0}" -before_script: + - "npm i --production" + - sh -c "if [ '$DB' = 'mongodb' ]; then node app --setup=\"{\\\"url\\\":\\\"http://127.0.0.1:4567/\\\",\\\"secret\\\":\\\"abcdef\\\",\\\"database\\\":\\\"mongo\\\",\\\"mongo:host\\\":\\\"127.0.0.1\\\",\\\"mongo:port\\\":27017,\\\"mongo:username\\\":\\\"\\\",\\\"mongo:password\\\":\\\"\\\",\\\"mongo:database\\\":0,\\\"redis:host\\\":\\\"127.0.0.1\\\",\\\"redis:port\\\":6379,\\\"redis:password\\\":\\\"\\\",\\\"redis:database\\\":0,\\\"admin:username\\\":\\\"admin\\\",\\\"admin:email\\\":\\\"test@example.org\\\",\\\"admin:password\\\":\\\"abcdef\\\",\\\"admin:password:confirm\\\":\\\"abcdef\\\"}\" --ci=\"{\\\"host\\\":\\\"127.0.0.1\\\",\\\"port\\\":27017,\\\"database\\\":0}\"; fi" + - sh -c "if [ '$DB' = 'redis' ]; then node app --setup=\"{\\\"url\\\":\\\"http://127.0.0.1:4567/\\\",\\\"secret\\\":\\\"abcdef\\\",\\\"database\\\":\\\"redis\\\",\\\"mongo:host\\\":\\\"127.0.0.1\\\",\\\"mongo:port\\\":27017,\\\"mongo:username\\\":\\\"\\\",\\\"mongo:password\\\":\\\"\\\",\\\"mongo:database\\\":0,\\\"redis:host\\\":\\\"127.0.0.1\\\",\\\"redis:port\\\":6379,\\\"redis:password\\\":\\\"\\\",\\\"redis:database\\\":0,\\\"admin:username\\\":\\\"admin\\\",\\\"admin:email\\\":\\\"test@example.org\\\",\\\"admin:password\\\":\\\"abcdef\\\",\\\"admin:password:confirm\\\":\\\"abcdef\\\"}\" --ci=\"{\\\"host\\\":\\\"127.0.0.1\\\",\\\"port\\\":6379,\\\"database\\\":0}\"; fi" +before_script: - "until nc -z localhost 27017; do echo Waiting for MongoDB; sleep 1; done" language: node_js env: - - CXX=g++-4.8 + - CXX=g++-4.8 DB=mongodb + - CXX=g++-4.8 DB=redis addons: apt: sources: diff --git a/package.json b/package.json index 1c28647ab4..cb83742b5b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "nodebb", "license": "GPL-3.0", "description": "NodeBB Forum", - "version": "1.1.3-auto.6", + "version": "1.2.0", "homepage": "http://www.nodebb.org", "repository": { "type": "git", @@ -61,8 +61,8 @@ "nodebb-plugin-spam-be-gone": "0.4.10", "nodebb-rewards-essentials": "0.0.9", "nodebb-theme-lavender": "3.0.14", - "nodebb-theme-persona": "4.1.50", - "nodebb-theme-vanilla": "5.1.33", + "nodebb-theme-persona": "4.1.56", + "nodebb-theme-vanilla": "5.1.37", "nodebb-widget-essentials": "2.0.11", "nodemailer": "2.0.0", "nodemailer-sendmail-transport": "1.0.0", @@ -81,7 +81,7 @@ "sitemap": "^1.4.0", "socket.io": "^1.4.8", "socket.io-client": "^1.4.0", - "socket.io-redis": "^1.0.0", + "socket.io-redis": "1.1.1", "socketio-wildcard": "~0.3.0", "string": "^3.0.0", "templates.js": "0.3.4", diff --git a/public/language/en_GB/pages.json b/public/language/en_GB/pages.json index 52339d7830..c9b2014d79 100644 --- a/public/language/en_GB/pages.json +++ b/public/language/en_GB/pages.json @@ -20,6 +20,7 @@ "tags": "Tags", "tag": "Topics tagged under \"%1\"", "register": "Register an account", + "registration-complete": "Registration complete", "login": "Login to your account", "reset": "Reset your account password", "categories": "Categories", diff --git a/public/language/ko/category.json b/public/language/ko/category.json index 5fe7e96cde..0c5f877d53 100644 --- a/public/language/ko/category.json +++ b/public/language/ko/category.json @@ -12,8 +12,8 @@ "ignore": "관심 해제", "watching": "Watching", "ignoring": "Ignoring", - "watching.description": "Show topics in unread", - "ignoring.description": "Do not show topics in unread", + "watching.description": "읽지 않은 주제를 표시합니다", + "ignoring.description": "읽지 않은 주제를 표시하지 않습니다", "watch.message": "이 카테고리에 올라오는 글을 주시하고 있습니다.", "ignore.message": "이 카테고리에 올라오는 글을 무시합니다.", "watched-categories": "관심 카테고리" diff --git a/public/src/admin/manage/users.js b/public/src/admin/manage/users.js index 45abb0e254..d41913ebc0 100644 --- a/public/src/admin/manage/users.js +++ b/public/src/admin/manage/users.js @@ -319,7 +319,7 @@ define('admin/manage/users', ['admin/modules/selectable', 'translator'], functio } templates.parse('admin/manage/users', 'users', data, function(html) { - $('#users-container').html(html); + $('#users-container').html(html).find('.timeago').timeago(); $('.fa-spinner').addClass('hidden'); diff --git a/public/src/app.js b/public/src/app.js index b714a441cd..46f0419709 100644 --- a/public/src/app.js +++ b/public/src/app.js @@ -85,13 +85,19 @@ app.cacheBuster = null; }; app.logout = function() { + $(window).trigger('action:app.logout'); $.ajax(config.relative_path + '/logout', { type: 'POST', headers: { 'x-csrf-token': config.csrf_token }, success: function() { - window.location.href = config.relative_path + '/'; + var payload = { + next: config.relative_path + '/' + }; + + $(window).trigger('action:app.loggedOut', payload); + window.location.href = payload.next; } }); }; @@ -260,7 +266,7 @@ app.cacheBuster = null; } }; - app.openChat = function (roomId) { + app.openChat = function (roomId, uid) { if (!app.user.uid) { return app.alertError('[[error:not-logged-in]]'); } @@ -275,13 +281,14 @@ app.cacheBuster = null; if (chat.modalExists(roomId)) { loadAndCenter(chat.getModal(roomId)); } else { - socket.emit('modules.chats.loadRoom', {roomId: roomId}, function(err, roomData) { + socket.emit('modules.chats.loadRoom', {roomId: roomId, uid: uid || app.user.uid}, function(err, roomData) { if (err) { return app.alertError(err.message); } roomData.users = roomData.users.filter(function(user) { return user && parseInt(user.uid, 10) !== parseInt(app.user.uid, 10); }); + roomData.uid = uid || app.user.uid; chat.createModal(roomData, loadAndCenter); }); } diff --git a/public/src/client/account/edit.js b/public/src/client/account/edit.js index 6cbea282d0..51632481cb 100644 --- a/public/src/client/account/edit.js +++ b/public/src/client/account/edit.js @@ -140,7 +140,7 @@ define('forum/account/edit', ['forum/account/header', 'uploader', 'translator', return app.alertError(err.message); } - updateHeader(type === 'default' ? '' : src); + updateHeader(type === 'default' ? '' : modal.find('.list-group-item.active img').attr('src')); ajaxify.refresh(); }); } diff --git a/public/src/client/chats.js b/public/src/client/chats.js index 2435042289..d6fc4ddb2d 100644 --- a/public/src/client/chats.js +++ b/public/src/client/chats.js @@ -62,12 +62,12 @@ define('forum/chats', [ var roomId = ajaxify.data.roomId; if (app.previousUrl && app.previousUrl.match(/chats/)) { - ajaxify.go('chats', function() { - app.openChat(roomId); + ajaxify.go('user/' + ajaxify.data.userslug + '/chats', function() { + app.openChat(roomId, ajaxify.data.uid); }, true); } else { window.history.go(-1); - app.openChat(roomId); + app.openChat(roomId, ajaxify.data.uid); } $(window).one('action:chat.loaded', function() { @@ -79,7 +79,6 @@ define('forum/chats', [ recentChats.init(); - Chats.addSinceHandler(ajaxify.data.roomId, $('.expanded-chat .chat-content'), $('.expanded-chat [data-since]')); Chats.addRenameHandler(ajaxify.data.roomId, $('[component="chat/room/name"]')); Chats.addScrollHandler(ajaxify.data.roomId, ajaxify.data.uid, $('.chat-content')); }; @@ -96,12 +95,14 @@ define('forum/chats', [ return; } loading = true; - - socket.emit('modules.chats.getMessages', {roomId: roomId, uid: uid, start: $('.chat-content').children('[data-index]').first().attr('data-index')}, function(err, data) { + var start = parseInt($('.chat-content').children('[data-index]').first().attr('data-index'), 10) + 1; + socket.emit('modules.chats.getMessages', {roomId: roomId, uid: uid, start: start}, function(err, data) { if (err) { return app.alertError(err.message); } - + if (!data) { + return; + } messages.parseMessage(data, function(html) { var currentScrollTop = el.scrollTop(); var previousHeight = el[0].scrollHeight; @@ -156,16 +157,6 @@ define('forum/chats', [ }); }; - Chats.addSinceHandler = function(roomId, chatContentEl, sinceEl) { - sinceEl.on('click', function() { - var since = $(this).attr('data-since'); - sinceEl.removeClass('selected'); - $(this).addClass('selected'); - Chats.loadChatSince(roomId, chatContentEl, since); - return false; - }); - }; - Chats.addRenameHandler = function(roomId, inputEl) { var oldName = inputEl.val(); inputEl.on('blur keypress', function(ev) { @@ -290,7 +281,7 @@ define('forum/chats', [ return app.alertError(err.message); } if (parseInt(roomId, 10) === ajaxify.data.roomId) { - ajaxify.go('chats'); + ajaxify.go('user/' + ajaxify.data.userslug + '/chats'); } else { el.remove(); } @@ -301,21 +292,6 @@ define('forum/chats', [ ajaxify.go('user/' + ajaxify.data.userslug + '/chats/' + roomid); }; - Chats.loadChatSince = function(roomId, chatContentEl, since) { - if (!roomId) { - return; - } - socket.emit('modules.chats.get', {roomId: roomId, since: since}, function(err, messageData) { - if (err) { - return app.alertError(err.message); - } - - chatContentEl.find('[component="chat/message"]').remove(); - - messages.appendChatMessage(chatContentEl, messageData); - }); - }; - Chats.addGlobalEventListeners = function() { $(window).on('resize', Chats.resizeMainWindow); $(window).on('mousemove keypress click', function() { diff --git a/public/src/client/chats/search.js b/public/src/client/chats/search.js index 3645db282f..947959f7a4 100644 --- a/public/src/client/chats/search.js +++ b/public/src/client/chats/search.js @@ -75,7 +75,7 @@ define('forum/chats/search', ['components'], function(components) { return app.alertError(err.message); } if (roomId) { - ajaxify.go('chats/' + roomId); + Chats.switchChat(roomId); } else { app.newChat(userObj.uid); } @@ -84,4 +84,4 @@ define('forum/chats/search', ['components'], function(components) { } return search; -}); \ No newline at end of file +}); diff --git a/public/src/modules/chat.js b/public/src/modules/chat.js index 7b87b6e376..6034eacfb0 100644 --- a/public/src/modules/chat.js +++ b/public/src/modules/chat.js @@ -26,8 +26,11 @@ define('chat', [ module.loadChatsDropdown(chatsListEl); }); - chatsListEl.on('click', '[data-roomid]', function() { - var roomId = this.getAttribute('data-roomid'); + chatsListEl.on('click', '[data-roomid]', function(ev) { + if ($(ev.target).parents('.user-link').length) { + return; + } + var roomId = $(this).attr('data-roomid'); if (!ajaxify.currentPage.match(/^chats\//)) { app.openChat(roomId); } else { @@ -80,6 +83,7 @@ define('chat', [ return user && parseInt(user.uid, 10) !== parseInt(app.user.uid, 10); }); roomData.silent = true; + roomData.uid = app.user.uid; module.createModal(roomData, function(modal) { module.toggleNew(modal.attr('UUID'), !isSelf, true); if (!isSelf) { @@ -150,134 +154,117 @@ define('chat', [ return $('#chat-modal-' + roomId).length !== 0; }; - function checkStatus(chatModal) { - socket.emit('user.checkStatus', chatModal.attr('touid'), function(err, status) { - if (err) { - return app.alertError(err.message); + module.createModal = function(data, callback) { + app.parseAndTranslate('chat', data, function(chatModal) { + + var uuid = utils.generateUUID(); + var dragged = false; + + chatModal.attr('id', 'chat-modal-' + data.roomId); + chatModal.attr('roomId', data.roomId); + chatModal.attr('intervalId', 0); + chatModal.attr('UUID', uuid); + chatModal.css('position', 'fixed'); + chatModal.css('zIndex', 100); + chatModal.appendTo($('body')); + chatModal.find('.timeago').timeago(); + module.center(chatModal); + + app.loadJQueryUI(function() { + chatModal.find('.modal-content').resizable({ + handles: 'n, e, s, w, se', + minHeight: 250, + minWidth: 400 + }); + + chatModal.find('.modal-content').on('resize', function(event, ui) { + if (ui.originalSize.height === ui.size.height) { + return; + } + + chatModal.find('.chat-content').css('height', module.calculateChatListHeight(chatModal)); + }); + + chatModal.draggable({ + start:function() { + module.bringModalToTop(chatModal); + }, + stop:function() { + chatModal.find('#chat-message-input').focus(); + }, + distance: 10, + handle: '.modal-header' + }); + }); + + chatModal.find('#chat-close-btn').on('click', function() { + module.close(chatModal); + }); + + function gotoChats() { + var text = components.get('chat/input').val(); + $(window).one('action:ajaxify.end', function() { + components.get('chat/input').val(text); + }); + + ajaxify.go('user/' + app.user.userslug + '/chats/' + chatModal.attr('roomId')); + module.close(chatModal); } - app.updateUserStatus(chatModal.find('[component="user/status"]'), status); - }); - } + chatModal.find('.modal-header').on('dblclick', gotoChats); + chatModal.find('button[data-action="maximize"]').on('click', gotoChats); - module.createModal = function(data, callback) { - templates.parse('chat', data, function(chatTpl) { - translator.translate(chatTpl, function (chatTpl) { + chatModal.on('click', function() { + module.bringModalToTop(chatModal); - var chatModal = $(chatTpl), - uuid = utils.generateUUID(), + if (dragged) { dragged = false; - - chatModal.attr('id', 'chat-modal-' + data.roomId); - chatModal.attr('roomId', data.roomId); - chatModal.attr('intervalId', 0); - chatModal.attr('UUID', uuid); - chatModal.css('position', 'fixed'); - chatModal.css('zIndex', 100); - chatModal.appendTo($('body')); - module.center(chatModal); - - app.loadJQueryUI(function() { - chatModal.find('.modal-content').resizable({ - handles: 'n, e, s, w, se', - minHeight: 250, - minWidth: 400 - }); - - chatModal.find('.modal-content').on('resize', function(event, ui) { - if (ui.originalSize.height === ui.size.height) { - return; - } - - chatModal.find('.chat-content').css('height', module.calculateChatListHeight(chatModal)); - }); - - chatModal.draggable({ - start:function() { - module.bringModalToTop(chatModal); - }, - stop:function() { - chatModal.find('#chat-message-input').focus(); - }, - distance: 10, - handle: '.modal-header' - }); - }); - - chatModal.find('#chat-close-btn').on('click', function() { - module.close(chatModal); - }); - - function gotoChats() { - var text = components.get('chat/input').val(); - $(window).one('action:ajaxify.end', function() { - components.get('chat/input').val(text); - }); - - ajaxify.go('user/' + app.user.userslug + '/chats/' + chatModal.attr('roomId')); - module.close(chatModal); - } - - chatModal.find('.modal-header').on('dblclick', gotoChats); - chatModal.find('button[data-action="maximize"]').on('click', gotoChats); - - chatModal.on('click', function() { - module.bringModalToTop(chatModal); - - if (dragged) { - dragged = false; - } - }); - - chatModal.on('mousemove', function(e) { - if (e.which === 1) { - dragged = true; - } - }); - - chatModal.on('mousemove keypress click', function() { - if (newMessage) { - socket.emit('modules.chats.markRead', data.roomId); - newMessage = false; - } - }); - - Chats.addEditDeleteHandler(chatModal.find('[component="chat/messages"]'), data.roomId); - - chatModal.find('[component="chat/controlsToggle"]').on('click', function() { - var messagesEl = chatModal.find('[component="chat/messages"]'); - - chatModal.find('[component="chat/controls"]').toggle(); - messagesEl.css('height', module.calculateChatListHeight(chatModal)); - }); - - Chats.addSinceHandler(chatModal.attr('roomId'), chatModal.find('.chat-content'), chatModal.find('[data-since]')); - Chats.addRenameHandler(chatModal.attr('roomId'), chatModal.find('[component="chat/room/name"]')); - - Chats.addSendHandlers(chatModal.attr('roomId'), chatModal.find('#chat-message-input'), chatModal.find('#chat-message-send-btn')); - - Chats.createTagsInput(chatModal.find('.users-tag-input'), data); - Chats.createAutoComplete(chatModal.find('[component="chat/input"]')); - - Chats.loadChatSince(chatModal.attr('roomId'), chatModal.find('.chat-content'), 'recent'); - - Chats.addScrollHandler(chatModal.attr('roomId'), app.user.uid, chatModal.find('.chat-content')); - - checkStatus(chatModal); - - taskbar.push('chat', chatModal.attr('UUID'), { - title: data.users.length ? data.users[0].username : '', - roomId: data.roomId, - icon: 'fa-comment', - state: '' - }); - - $(window).trigger('action:chat.loaded', chatModal); - - if (typeof callback === 'function') { - callback(chatModal); } }); + + chatModal.on('mousemove', function(e) { + if (e.which === 1) { + dragged = true; + } + }); + + chatModal.on('mousemove keypress click', function() { + if (newMessage) { + socket.emit('modules.chats.markRead', data.roomId); + newMessage = false; + } + }); + + Chats.addEditDeleteHandler(chatModal.find('[component="chat/messages"]'), data.roomId); + + chatModal.find('[component="chat/controlsToggle"]').on('click', function() { + var messagesEl = chatModal.find('[component="chat/messages"]'); + + chatModal.find('[component="chat/controls"]').toggle(); + messagesEl.css('height', module.calculateChatListHeight(chatModal)); + }); + + Chats.addRenameHandler(chatModal.attr('roomId'), chatModal.find('[component="chat/room/name"]')); + + Chats.addSendHandlers(chatModal.attr('roomId'), chatModal.find('#chat-message-input'), chatModal.find('#chat-message-send-btn')); + + Chats.createTagsInput(chatModal.find('.users-tag-input'), data); + Chats.createAutoComplete(chatModal.find('[component="chat/input"]')); + + Chats.addScrollHandler(chatModal.attr('roomId'), data.uid, chatModal.find('.chat-content')); + + taskbar.push('chat', chatModal.attr('UUID'), { + title: data.users.length ? data.users[0].username : '', + roomId: data.roomId, + icon: 'fa-comment', + state: '' + }); + + $(window).trigger('action:chat.loaded', chatModal); + + if (typeof callback === 'function') { + callback(chatModal); + } }); }; @@ -339,7 +326,7 @@ define('chat', [ }); }; - module.disableMobileBehaviour = function(modalEl) { + module.disableMobileBehaviour = function() { app.toggleNavbar(true); }; @@ -347,7 +334,6 @@ define('chat', [ var totalHeight = modalEl.find('.modal-content').outerHeight() - modalEl.find('.modal-header').outerHeight(); var padding = parseInt(modalEl.find('.modal-body').css('padding-top'), 10) + parseInt(modalEl.find('.modal-body').css('padding-bottom'), 10); var contentMargin = parseInt(modalEl.find('.chat-content').css('margin-top'), 10) + parseInt(modalEl.find('.chat-content').css('margin-bottom'), 10); - var sinceHeight = modalEl.find('.since-bar').outerHeight(true); var inputGroupHeight = modalEl.find('.input-group').outerHeight(); return totalHeight - padding - contentMargin - inputGroupHeight; diff --git a/src/controllers/accounts/chats.js b/src/controllers/accounts/chats.js index 7ff867134c..ddde8efb5e 100644 --- a/src/controllers/accounts/chats.js +++ b/src/controllers/accounts/chats.js @@ -30,10 +30,13 @@ chatsController.get = function(req, res, callback) { if (!uid) { return callback(); } - messaging.getRecentChats(uid, 0, 19, next); + messaging.getRecentChats(req.uid, uid, 0, 19, next); }, function(_recentChats, next) { recentChats = _recentChats; + if (!recentChats) { + return callback(); + } if (!req.params.roomid) { return res.render('chats', { rooms: recentChats.rooms, @@ -48,15 +51,15 @@ chatsController.get = function(req, res, callback) { messaging.isUserInRoom(req.uid, req.params.roomid, next); }, function(inRoom, next) { - if (!inRoom && parseInt(req.uid, 10) === parseInt(uid, 10)) { + if (!inRoom) { return callback(); } async.parallel({ users: async.apply(messaging.getUsersInRoom, req.params.roomid, 0, -1), messages: async.apply(messaging.getMessages, { + callerUid: req.uid, uid: uid, roomId: req.params.roomid, - since: 'recent', isNew: false }), room: async.apply(messaging.getRoomData, req.params.roomid) @@ -74,6 +77,7 @@ chatsController.get = function(req, res, callback) { return user && parseInt(user.uid, 10) && parseInt(user.uid, 10) !== req.uid; }); + room.groupChat = room.hasOwnProperty('groupChat') ? room.groupChat : room.users.length > 2; room.rooms = recentChats.rooms; room.uid = uid; room.userslug = req.params.userslug; diff --git a/src/controllers/accounts/helpers.js b/src/controllers/accounts/helpers.js index 3d9b2a9a1e..7af9cfa3e4 100644 --- a/src/controllers/accounts/helpers.js +++ b/src/controllers/accounts/helpers.js @@ -87,6 +87,10 @@ helpers.getUserDataByUserSlug = function(userslug, callerUID, callback) { userData.ips = results.ips; } + if (!isAdmin && !isGlobalModerator) { + userData.moderationNote = undefined; + } + userData.uid = userData.uid; userData.yourid = callerUID; userData.theirid = userData.uid; @@ -120,6 +124,7 @@ helpers.getUserDataByUserSlug = function(userslug, callerUID, callback) { userData.signature = validator.escape(String(userData.signature || '')); userData.aboutme = validator.escape(String(userData.aboutme || '')); userData.birthday = validator.escape(String(userData.birthday || '')); + userData.moderationNote = validator.escape(String(userData.moderationNote || '')); userData['cover:url'] = userData['cover:url'] || require('../../coverPhoto').getDefaultProfileCover(userData.uid); userData['cover:position'] = userData['cover:position'] || '50% 50%'; diff --git a/src/controllers/accounts/info.js b/src/controllers/accounts/info.js index fad9cd4c24..63dea030ec 100644 --- a/src/controllers/accounts/info.js +++ b/src/controllers/accounts/info.js @@ -22,8 +22,8 @@ infoController.get = function(req, res, callback) { async.parallel({ history: async.apply(user.getModerationHistory, userData.uid), sessions: async.apply(user.auth.getSessions, userData.uid, req.sessionID), - usernames: async.apply(user.getUsernameHistory, userData.uid), - emails: async.apply(user.getEmailHistory, userData.uid) + usernames: async.apply(user.getHistory, 'user:' + userData.uid + ':usernames'), + emails: async.apply(user.getHistory, 'user:' + userData.uid + ':emails') }, next); } ], function(err, data) { diff --git a/src/controllers/admin/flags.js b/src/controllers/admin/flags.js index bed5c57767..b5edc7b5be 100644 --- a/src/controllers/admin/flags.js +++ b/src/controllers/admin/flags.js @@ -56,7 +56,7 @@ flagsController.get = function(req, res, next) { assignees: results.assignees, analytics: results.analytics, categories: results.categories, - byUsername: validator(String(byUsername)), + byUsername: validator.escape(String(byUsername)), sortByCount: sortBy === 'count', sortByTime: sortBy === 'time', pagination: pagination.create(page, pageCount, req.query), diff --git a/src/controllers/index.js b/src/controllers/index.js index 6142c48240..103dbf70f3 100644 --- a/src/controllers/index.js +++ b/src/controllers/index.js @@ -221,6 +221,7 @@ Controllers.registerInterstitial = function(req, res, next) { } res.render('registerComplete', { + title: '[[pages:registration-complete]]', errors: errors, sections: sections }); diff --git a/src/controllers/topics.js b/src/controllers/topics.js index d017f1d5c8..2200929ab6 100644 --- a/src/controllers/topics.js +++ b/src/controllers/topics.js @@ -58,6 +58,9 @@ topicsController.get = function(req, res, callback) { if (req.params.post_index){ url += '/'+req.params.post_index; } + if (currentPage > 1) { + url += '?page=' + currentPage; + } return helpers.redirect(res, url); } diff --git a/src/database/mongo/sorted.js b/src/database/mongo/sorted.js index eb6505aa47..0b7ce568be 100644 --- a/src/database/mongo/sorted.js +++ b/src/database/mongo/sorted.js @@ -18,6 +18,9 @@ module.exports = function(db, module) { value = helpers.valueToString(value); db.collection('objects').update({_key: key, value: value}, {$set: {score: parseInt(score, 10)}}, {upsert:true, w: 1}, function(err) { + if (err && err.message.startsWith('E11000 duplicate key error')) { + return module.sortedSetAdd(key, score, value, callback); + } callback(err); }); }; diff --git a/src/database/redis.js b/src/database/redis.js index 8af568b5d9..214c9ef4a1 100644 --- a/src/database/redis.js +++ b/src/database/redis.js @@ -65,15 +65,18 @@ }; module.connect = function(options) { - var redis_socket_or_host = nconf.get('redis:host'), - cxn, dbIdx; - - options = options || {}; + var redis_socket_or_host = nconf.get('redis:host'); + var cxn; if (!redis) { redis = require('redis'); } + options = options || {}; + if (nconf.get('redis:password')) { + options.auth_pass = nconf.get('redis:password'); + } + if (redis_socket_or_host && redis_socket_or_host.indexOf('/') >= 0) { /* If redis.host contains a path name character, use the unix dom sock connection. ie, /tmp/redis.sock */ cxn = redis.createClient(nconf.get('redis:host'), options); @@ -91,7 +94,7 @@ cxn.auth(nconf.get('redis:password')); } - dbIdx = parseInt(nconf.get('redis:database'), 10); + var dbIdx = parseInt(nconf.get('redis:database'), 10); if (dbIdx) { cxn.select(dbIdx, function(error) { if(error) { diff --git a/src/database/redis/sets.js b/src/database/redis/sets.js index 9443ed928f..1eaab00cc5 100644 --- a/src/database/redis/sets.js +++ b/src/database/redis/sets.js @@ -5,6 +5,12 @@ module.exports = function(redisClient, module) { module.setAdd = function(key, value, callback) { callback = callback || function() {}; + if (!Array.isArray(value)) { + value = [value]; + } + if (!value.length) { + return callback(); + } redisClient.sadd(key, value, function(err, res) { callback(err); }); diff --git a/src/events.js b/src/events.js index 9ef6a05385..9767f9093a 100644 --- a/src/events.js +++ b/src/events.js @@ -1,13 +1,13 @@ 'use strict'; -var async = require('async'), - - db = require('./database'), - batch = require('./batch'), - user = require('./user'), - utils = require('../public/src/utils'); - +var async = require('async'); +var validator = require('validator'); + +var db = require('./database'); +var batch = require('./batch'); +var user = require('./user'); +var utils = require('../public/src/utils'); (function(events) { events.log = function(data, callback) { @@ -54,6 +54,11 @@ var async = require('async'), }, function(eventsData, next) { eventsData.forEach(function(event) { + Object.keys(event).forEach(function(key) { + if (typeof event[key] === 'string') { + event[key] = validator.escape(String(event[key] || '')); + } + }); var e = utils.merge(event); e.eid = e.uid = e.type = e.ip = e.user = undefined; event.jsonString = JSON.stringify(e, null, 4); @@ -123,7 +128,7 @@ var async = require('async'), callback = callback || function() {}; batch.processSortedSet('events:time', function(eids, next) { - events.deleteEvents(eids, callback); + events.deleteEvents(eids, next); }, {alwaysStartAt: 0}, callback); }; diff --git a/src/groups/membership.js b/src/groups/membership.js index 17e70a12dc..14f34f0084 100644 --- a/src/groups/membership.js +++ b/src/groups/membership.js @@ -189,9 +189,9 @@ module.exports = function(Groups) { if (!checks.exists) { return next(new Error('[[error:no-group]]')); } else if (checks.isMember) { - return next(new Error('[[error:group-already-member]]')); + return callback(); } else if (type === 'invite' && checks.isInvited) { - return next(new Error('[[error:group-already-invited]]')); + return callback(); } else if (type === 'request' && checks.isPending) { return next(new Error('[[error:group-already-requested]]')); } diff --git a/src/messaging.js b/src/messaging.js index 2fe69e50fe..ae554f7359 100644 --- a/src/messaging.js +++ b/src/messaging.js @@ -1,18 +1,17 @@ 'use strict'; -var async = require('async'), - winston = require('winston'), - S = require('string'), +var async = require('async'); +var winston = require('winston'); +var S = require('string'); - - db = require('./database'), - user = require('./user'), - plugins = require('./plugins'), - meta = require('./meta'), - utils = require('../public/src/utils'), - notifications = require('./notifications'), - userNotifications = require('./user/notifications'); +var db = require('./database'); +var user = require('./user'); +var plugins = require('./plugins'); +var meta = require('./meta'); +var utils = require('../public/src/utils'); +var notifications = require('./notifications'); +var userNotifications = require('./user/notifications'); (function(Messaging) { @@ -23,13 +22,6 @@ var async = require('async'), require('./messaging/unread')(Messaging); require('./messaging/notifications')(Messaging); - var terms = { - day: 86400000, - week: 604800000, - month: 2592000000, - threemonths: 7776000000 - }; - Messaging.getMessageField = function(mid, field, callback) { Messaging.getMessageFields(mid, [field], function(err, fields) { callback(err, fields ? fields[field] : null); @@ -51,46 +43,42 @@ var async = require('async'), Messaging.getMessages = function(params, callback) { var uid = params.uid; var roomId = params.roomId; - var since = params.since; - var isNew = params.isNew; + var isNew = params.isNew || false; var start = params.hasOwnProperty('start') ? params.start : 0; - var count = params.count || 250; + var stop = parseInt(start, 10) + ((params.count || 50) - 1); var markRead = params.markRead || true; - var min = params.count ? 0 : Date.now() - (terms[since] || terms.day); - - if (since === 'recent') { - count = 50; - min = 0; - } - - db.getSortedSetRevRangeByScore('uid:' + uid + ':chat:room:' + roomId + ':mids', start, count, '+inf', min, function(err, mids) { - if (err) { - return callback(err); - } - - if (!Array.isArray(mids) || !mids.length) { - return callback(null, []); - } - var indices = {}; - mids.forEach(function(mid, index) { - indices[mid] = start + index; - }); - - mids.reverse(); - - Messaging.getMessagesData(mids, uid, roomId, isNew, function(err, messageData) { - if (err) { - return callback(err); + var indices = {}; + async.waterfall([ + function(next) { + canGetMessages(params.callerUid, params.uid, next); + }, + function(canGet, next) { + if (!canGet) { + return callback(null, null); + } + db.getSortedSetRevRange('uid:' + uid + ':chat:room:' + roomId + ':mids', start, stop, next); + }, + function(mids, next) { + if (!Array.isArray(mids) || !mids.length) { + return callback(null, []); } - for(var i=0; i 2; room.unread = results.unread[index]; room.teaser = results.teasers[index]; @@ -306,12 +309,23 @@ var async = require('async'), }).join(', '); }); - callback(null, {rooms: results.roomData, nextStart: stop + 1}); - }); - }); + next(null, {rooms: results.roomData, nextStart: stop + 1}); + } + ], callback); }; + function canGetRecentChats(callerUid, uid, callback) { + plugins.fireHook('filter:messaging.canGetRecentChats', { + callerUid: callerUid, + uid: uid, + canGet: parseInt(callerUid, 10) === parseInt(uid, 10) + }, function(err, data) { + callback(err, data ? data.canGet : false); + }); + } + Messaging.getTeaser = function (uid, roomId, callback) { + var teaser; async.waterfall([ function (next) { db.getSortedSetRevRange('uid:' + uid + ':chat:room:' + roomId + ':mids', 0, 0, next); @@ -320,14 +334,22 @@ var async = require('async'), if (!mids || !mids.length) { return next(null, null); } - Messaging.getMessageFields(mids[0], ['content', 'timestamp'], next); + Messaging.getMessageFields(mids[0], ['fromuid', 'content', 'timestamp'], next); }, - function (teaser, next) { - if (teaser && teaser.content) { + function (_teaser, next) { + teaser = _teaser; + if (!teaser) { + return callback(); + } + if (teaser.content) { teaser.content = S(teaser.content).stripTags().decodeHTMLEntities().s; - teaser.timestampISO = utils.toISOString(teaser.timestamp); - } + } + teaser.timestampISO = utils.toISOString(teaser.timestamp); + user.getUserFields(teaser.fromuid, ['uid', 'username', 'userslug', 'picture', 'status', 'lastonline'] , next); + }, + function(user, next) { + teaser.user = user; next(null, teaser); } ], callback); diff --git a/src/messaging/rooms.js b/src/messaging/rooms.js index 54423a2673..92a5815df6 100644 --- a/src/messaging/rooms.js +++ b/src/messaging/rooms.js @@ -5,6 +5,7 @@ var validator = require('validator'); var db = require('../database'); var user = require('../user'); +var plugins = require('../plugins'); module.exports = function(Messaging) { @@ -13,10 +14,7 @@ module.exports = function(Messaging) { if (err || !data) { return callback(err || new Error('[[error:no-chat-room]]')); } - data.roomName = data.roomName || '[[modules:chat.roomname, ' + roomId + ']]'; - if (data.roomName) { - data.roomName = validator.escape(String(data.roomName)); - } + modifyRoomData([data]); callback(null, data); }); }; @@ -29,16 +27,23 @@ module.exports = function(Messaging) { if (err) { return callback(err); } - roomData.forEach(function(data) { - if (data) { - data.roomName = data.roomName || '[[modules:chat.roomname, ' + data.roomId + ']]'; - data.roomName = validator.escape(String(data.roomName)); - } - }); + modifyRoomData(roomData); callback(null, roomData); }); }; + function modifyRoomData(rooms) { + rooms.forEach(function(data) { + if (data) { + data.roomName = data.roomName || '[[modules:chat.roomname, ' + data.roomId + ']]'; + data.roomName = validator.escape(String(data.roomName)); + if (data.hasOwnProperty('groupChat')) { + data.groupChat = parseInt(data.groupChat, 10) === 1; + } + } + }); + } + Messaging.newRoom = function(uid, toUids, callback) { var roomId; var now = Date.now(); @@ -70,7 +75,17 @@ module.exports = function(Messaging) { }; Messaging.isUserInRoom = function(uid, roomId, callback) { - db.isSortedSetMember('chat:room:' + roomId + ':uids', uid, callback); + async.waterfall([ + function(next) { + db.isSortedSetMember('chat:room:' + roomId + ':uids', uid, next); + }, + function(inRoom, next) { + plugins.fireHook('filter:messaging.isUserInRoom', {uid: uid, roomId: roomId, inRoom: inRoom}, next); + }, + function(data, next) { + next(null, data.inRoom); + } + ], callback); }; Messaging.roomExists = function(roomId, callback) { @@ -105,6 +120,18 @@ module.exports = function(Messaging) { return now; }); db.sortedSetAdd('chat:room:' + roomId + ':uids', timestamps, uids, next); + }, + function(next) { + async.parallel({ + userCount: async.apply(db.sortedSetCard, 'chat:room:' + roomId + ':uids'), + roomData: async.apply(db.getObject, 'chat:room:' + roomId) + }, next); + }, + function(results, next) { + if (!results.roomData.hasOwnProperty('groupChat') && results.userCount > 2) { + return db.setObjectField('chat:room:' + roomId, 'groupChat', 1, next); + } + next(); } ], callback); }; diff --git a/src/middleware/header.js b/src/middleware/header.js index 26e9795019..57c532f83e 100644 --- a/src/middleware/header.js +++ b/src/middleware/header.js @@ -120,7 +120,7 @@ module.exports = function(middleware) { results.user.isAdmin = results.isAdmin; results.user.isGlobalMod = results.isGlobalMod; results.user.uid = parseInt(results.user.uid, 10); - results.user.email = String(results.user.email).replace(/\\/g, '\\\\'); + results.user.email = String(results.user.email).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); results.user['email:confirmed'] = parseInt(results.user['email:confirmed'], 10) === 1; results.user.isEmailConfirmSent = !!results.isEmailConfirmSent; diff --git a/src/routes/accounts.js b/src/routes/accounts.js index 9d17b8f86a..27db7a308a 100644 --- a/src/routes/accounts.js +++ b/src/routes/accounts.js @@ -31,6 +31,6 @@ module.exports = function (app, middleware, controllers) { app.delete('/api/user/:userslug/session/:uuid', [middleware.requireUser], controllers.accounts.session.revoke); setupPageRoute(app, '/notifications', middleware, [middleware.authenticate], controllers.accounts.notifications.get); - setupPageRoute(app, '/user/:userslug/chats/:roomid?', middleware, accountMiddlewares, controllers.accounts.chats.get); + setupPageRoute(app, '/user/:userslug/chats/:roomid?', middleware, middlewares, controllers.accounts.chats.get); setupPageRoute(app, '/chats/:roomid?', middleware, [], controllers.accounts.chats.redirectToChat); }; diff --git a/src/routes/index.js b/src/routes/index.js index 47fad11e6c..b4248120c9 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -142,7 +142,7 @@ module.exports = function(app, middleware, hotswapIds) { } app.use(middleware.privateUploads); - app.use('/language/:code', middleware.processLanguages); + app.use(relativePath + '/language/:code', middleware.processLanguages); app.use(relativePath, express.static(path.join(__dirname, '../../', 'public'), { maxAge: app.enabled('cache') ? 5184000000 : 0 })); diff --git a/src/socket.io/admin/user.js b/src/socket.io/admin/user.js index 42dcd23266..5ebe40665b 100644 --- a/src/socket.io/admin/user.js +++ b/src/socket.io/admin/user.js @@ -1,7 +1,8 @@ "use strict"; - var async = require('async'); +var validator = require('validator'); + var db = require('../../database'); var groups = require('../../groups'); var user = require('../../user'); @@ -204,7 +205,7 @@ User.search = function(socket, data, callback) { userData.forEach(function(user, index) { if (user && userInfo[index]) { - user.email = userInfo[index].email || ''; + user.email = validator.escape(String(userInfo[index].email || '')); user.flags = userInfo[index].flags || 0; } }); diff --git a/src/socket.io/groups.js b/src/socket.io/groups.js index 41de928b51..a8eb6b7ae9 100644 --- a/src/socket.io/groups.js +++ b/src/socket.io/groups.js @@ -152,7 +152,7 @@ SocketGroups.issueMassInvite = isOwner(function(socket, data, callback) { }); async.eachSeries(uids, function(uid, next) { - groups.invite(data.groupName, uid, callback); + groups.invite(data.groupName, uid, next); }, callback); }); }); diff --git a/src/socket.io/index.js b/src/socket.io/index.js index 5ee6050571..d3393ea2d2 100644 --- a/src/socket.io/index.js +++ b/src/socket.io/index.js @@ -177,9 +177,8 @@ var ratelimit = require('../middleware/ratelimit'); if (nconf.get('redis')) { var redisAdapter = require('socket.io-redis'); var redis = require('../database/redis'); - var pub = redis.connect({return_buffers: true}); + var pub = redis.connect(); var sub = redis.connect({return_buffers: true}); - io.adapter(redisAdapter({pubClient: pub, subClient: sub})); } else if (nconf.get('isCluster') === 'true') { winston.warn('[socket.io] Clustering detected, you are advised to configure Redis as a websocket store.'); diff --git a/src/socket.io/modules.js b/src/socket.io/modules.js index 3a1e711b45..d2097ff800 100644 --- a/src/socket.io/modules.js +++ b/src/socket.io/modules.js @@ -12,26 +12,13 @@ var server = require('./'); var user = require('../user'); var SocketModules = { - chats: {}, - sounds: {}, - settings: {} - }; + chats: {}, + sounds: {}, + settings: {} +}; /* Chat */ -SocketModules.chats.get = function(socket, data, callback) { - if(!data || !data.roomId) { - return callback(new Error('[[error:invalid-data]]')); - } - - Messaging.getMessages({ - uid: socket.uid, - roomId: data.roomId, - since: data.since, - isNew: false - }, callback); -}; - SocketModules.chats.getRaw = function(socket, data, callback) { if (!data || !data.hasOwnProperty('mid')) { return callback(new Error('[[error:invalid-data]]')); @@ -68,7 +55,7 @@ SocketModules.chats.newRoom = function(socket, data, callback) { }; SocketModules.chats.send = function(socket, data, callback) { - if (!data || !data.roomId) { + if (!data || !data.roomId || !socket.uid) { return callback(new Error('[[error:invalid-data]]')); } @@ -128,11 +115,19 @@ SocketModules.chats.loadRoom = function(socket, data, callback) { async.parallel({ roomData: async.apply(Messaging.getRoomData, data.roomId), - users: async.apply(Messaging.getUsersInRoom, data.roomId, 0, -1) + users: async.apply(Messaging.getUsersInRoom, data.roomId, 0, -1), + messages: async.apply(Messaging.getMessages, { + callerUid: socket.uid, + uid: data.uid || socket.uid, + roomId: data.roomId, + isNew: false + }), }, next); }, function (results, next) { results.roomData.users = results.users; + results.roomData.messages = results.messages; + results.roomData.groupChat = results.roomData.hasOwnProperty('groupChat') ? results.roomData.groupChat : results.users.length > 2; results.roomData.isOwner = parseInt(results.roomData.owner, 10) === socket.uid; results.roomData.maximumUsersInChatRoom = parseInt(meta.config.maximumUsersInChatRoom, 10) || 0; results.roomData.showUserInput = !results.roomData.maximumUsersInChatRoom || results.roomData.maximumUsersInChatRoom > 2; @@ -231,6 +226,9 @@ SocketModules.chats.canMessage = function(socket, roomId, callback) { }; SocketModules.chats.markRead = function(socket, roomId, callback) { + if (!socket.uid) { + return callback(new Error('[[error:invalid-data]]')); + } async.parallel({ usersInRoom: async.apply(Messaging.getUidsInRoom, roomId, 0, -1), markRead: async.apply(Messaging.markRead, socket.uid, roomId) @@ -292,21 +290,12 @@ SocketModules.chats.renameRoom = function(socket, data, callback) { }; SocketModules.chats.getRecentChats = function(socket, data, callback) { - if (!data || !utils.isNumber(data.after)) { + if (!data || !utils.isNumber(data.after) || !utils.isNumber(data.uid)) { return callback(new Error('[[error:invalid-data]]')); } var start = parseInt(data.after, 10); var stop = start + 9; - if (socket.uid === parseInt(data.uid, 10)) { - return Messaging.getRecentChats(socket.uid, start, stop, callback); - } - - user.isAdminOrGlobalMod(socket.uid, function(err, isAdminOrGlobalMod) { - if (err || !isAdminOrGlobalMod) { - return callback(err || new Error('[[error:no-privileges]]')); - } - Messaging.getRecentChats(data.uid, start, stop, callback); - }); + Messaging.getRecentChats(socket.uid, data.uid, start, stop, callback); }; SocketModules.chats.hasPrivateChat = function(socket, uid, callback) { @@ -320,22 +309,21 @@ SocketModules.chats.getMessages = function(socket, data, callback) { if (!socket.uid || !data.uid || !data.roomId) { return callback(new Error('[[error:invalid-data]]')); } + var params = { + callerUid: socket.uid, uid: data.uid, roomId: data.roomId, - start: parseInt(data.start, 10) + 1, + start: parseInt(data.start, 10) || 0, count: 50, markRead: false }; - if (socket.uid === parseInt(data.uid, 10)) { - return Messaging.getMessages(params, callback); + + if (data.hasOwnProperty('markRead')) { + params.markRead = data.markRead; } - user.isAdminOrGlobalMod(socket.uid, function(err, isAdminOrGlobalMod) { - if (err || !isAdminOrGlobalMod) { - return callback(err || new Error('[[error:no-privileges]]')); - } - Messaging.getMessages(params, callback); - }); + + Messaging.getMessages(params, callback); }; /* Sounds */ diff --git a/src/topics.js b/src/topics.js index 3248d8f567..9fb8f827a8 100644 --- a/src/topics.js +++ b/src/topics.js @@ -345,7 +345,7 @@ var social = require('./social'); } }; - Topics.getTopicBookmarks = function( tid, callback ){ + Topics.getTopicBookmarks = function(tid, callback) { db.getSortedSetRangeWithScores(['tid:' + tid + ':bookmarks'], 0, -1, callback); }; @@ -372,49 +372,26 @@ var social = require('./social'); }; }); - async.map(uidData, function(data, mapCallback) { - posts.getPostIndices(forkedPosts, data.uid, function(err, indices) { + async.eachLimit(uidData, 50, function(data, next) { + posts.getPostIndices(forkedPosts, data.uid, function(err, postIndices) { if (err) { - return callback(err); + return next(err); } - data.postIndices = indices; - mapCallback(null, data); - }); - }, function(err, results) { - if (err) { - return callback(err); - } - async.map(results, function(data, mapCallback) { - var uid = data.uid; + var bookmark = data.bookmark; bookmark = bookmark < maxIndex ? bookmark : maxIndex; - var postIndices = data.postIndices; - for (var i = 0; i < postIndices.length && postIndices[i] < data.bookmark; ++i ){ + for (var i = 0; i < postIndices.length && postIndices[i] < data.bookmark; ++i) { --bookmark; } if (parseInt(bookmark, 10) !== parseInt(data.bookmark, 10)) { - mapCallback( null, { uid: uid, bookmark: bookmark } ); + Topics.setUserBookmark(tid, data.uid, bookmark, next); } else { - mapCallback( null, null ); + next(); } - }, function(err, results) { - if (err) { - return callback(err); - } - - async.map(results, function(ui, cb) { - if( ui && ui.bookmark) { - Topics.setUserBookmark(tid, ui.uid, ui.bookmark, cb); - } else { - return cb(null, null); - } - }, function(err) { - next(err); - }); }); - }); + }, next); } ], function(err){ callback(err); diff --git a/src/user/info.js b/src/user/info.js index 49d03ed90c..8f2e4d3a83 100644 --- a/src/user/info.js +++ b/src/user/info.js @@ -61,24 +61,15 @@ module.exports = function(User) { }); }; - User.getEmailHistory = function(uid, callback) { - db.getSortedSetRevRangeWithScores('user:' + uid + ':emails', 0, -1, function(err, data) { - callback(err, data.map(function(set) { + User.getHistory = function(set, callback) { + db.getSortedSetRevRangeWithScores(set, 0, -1, function(err, data) { + if (err) { + return callback(err); + } + callback(null, data.map(function(set) { set.timestamp = set.score; set.timestampISO = new Date(set.score).toISOString(); - set.value = set.value.split(':')[0]; - delete set.score; - return set; - })); - }); - }; - - User.getUsernameHistory = function(uid, callback) { - db.getSortedSetRevRangeWithScores('user:' + uid + ':usernames', 0, -1, function(err, data) { - callback(err, data.map(function(set) { - set.timestamp = set.score; - set.timestampISO = new Date(set.score).toISOString(); - set.value = set.value.split(':')[0]; + set.value = validator.escape(String(set.value.split(':')[0])); delete set.score; return set; })); diff --git a/src/views/admin/manage/users.tpl b/src/views/admin/manage/users.tpl index 2911403f6d..02ba3ad45c 100644 --- a/src/views/admin/manage/users.tpl +++ b/src/views/admin/manage/users.tpl @@ -79,7 +79,7 @@ {users.username} ({users.uid})
- {users.email} + {users.email}
joined