feat: allow groups to specify which cids to show member posts from ()

* feat: allow groups to specify which cids to show member posts from

* docs: fix tests for openapi

* fix: test breakage caused by improper conditional

* feat: server-side checking of memberPostCids for validity

* feat: admin panel template update to select categories to include

* refactor: privilege helpers.isUserAllowedTo

... to helpers.isAllowedTo, allowing group names to be passed in
v1.18.x
Julian Lam committed by GitHub
parent 45922faebc
commit 8518404e22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -40,6 +40,8 @@
"details.member_count": "Member Count", "details.member_count": "Member Count",
"details.creation_date": "Creation Date", "details.creation_date": "Creation Date",
"details.description": "Description", "details.description": "Description",
"details.member-post-cids": "Categories to display posts from",
"details.member-post-cids-help": "<strong>Note</strong>: Selecting no categories will assume all categories are included. Use <code>ctrl</code> and <code>shift</code> to select multiple options.",
"details.badge_preview": "Badge Preview", "details.badge_preview": "Badge Preview",
"details.change_icon": "Change Icon", "details.change_icon": "Change Icon",
"details.change_label_colour": "Change Label Colour", "details.change_label_colour": "Change Label Colour",

@ -42,6 +42,11 @@ GroupFullObject:
textColor: textColor:
type: string type: string
description: A six-character hexadecimal colour code description: A six-character hexadecimal colour code
memberPostCids:
type: array
items:
type: number
example: [1, 2, 3]
icon: icon:
type: string type: string
description: A FontAwesome icon string description: A FontAwesome icon string
@ -56,6 +61,32 @@ GroupFullObject:
type: string type: string
descriptionParsed: descriptionParsed:
type: string type: string
categories:
type: array
items:
type: object
properties:
cid:
type: number
description: A category identifier
name:
type: string
level:
type: string
icon:
type: string
parentCid:
type: number
description: The category identifier for the category that is the immediate
ancestor of the current category
color:
type: string
bgColor:
type: string
selected:
type: boolean
imageClass:
type: string
members: members:
type: array type: array
items: items:
@ -130,4 +161,9 @@ GroupDataObject:
type: string type: string
description: "`createtime` rendered as an ISO 8601 format" description: "`createtime` rendered as an ISO 8601 format"
cover:position: cover:position:
type: string type: string
memberPostCids:
type: array
items:
type: number
example: [1, 2, 3]

@ -64,6 +64,11 @@ get:
type: string type: string
ownerUid: ownerUid:
type: number type: number
memberPostCids:
type: array
items:
type: number
example: [1, 2, 3]
required: required:
- name - name
- description - description

@ -58,6 +58,11 @@ get:
type: string type: string
cover:position: cover:position:
type: string type: string
memberPostCids:
type: array
items:
type: number
example: [1, 2, 3]
members: members:
type: array type: array
items: items:

@ -37,6 +37,11 @@ post:
enum: [0, 1] enum: [0, 1]
createtime: createtime:
type: number type: number
memberPostCids:
type: array
items:
type: number
example: [1, 2, 3]
required: required:
- name - name
responses: responses:

@ -80,6 +80,7 @@ define('admin/manage/group', [
userTitleEnabled: $('#group-userTitleEnabled').is(':checked'), userTitleEnabled: $('#group-userTitleEnabled').is(':checked'),
private: $('#group-private').is(':checked'), private: $('#group-private').is(':checked'),
hidden: $('#group-hidden').is(':checked'), hidden: $('#group-hidden').is(':checked'),
memberPostCids: $('#memberPostCids').val(),
disableJoinRequests: $('#group-disableJoinRequests').is(':checked'), disableJoinRequests: $('#group-disableJoinRequests').is(':checked'),
disableLeave: $('#group-disableLeave').is(':checked'), disableLeave: $('#group-disableLeave').is(':checked'),
}, },

@ -75,6 +75,7 @@ function modifyGroup(group, fields) {
group.icon = validator.escape(String(group.icon || '')); group.icon = validator.escape(String(group.icon || ''));
group.createtimeISO = utils.toISOString(group.createtime); group.createtimeISO = utils.toISOString(group.createtime);
group.private = ([null, undefined].includes(group.private)) ? 1 : group.private; group.private = ([null, undefined].includes(group.private)) ? 1 : group.private;
group.memberPostCids = (group.memberPostCids || '').split(',').map(cid => parseInt(cid, 10)).filter(Boolean);
group['cover:thumb:url'] = group['cover:thumb:url'] || group['cover:url']; group['cover:thumb:url'] = group['cover:thumb:url'] || group['cover:url'];

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const user = require('../user'); const user = require('../user');
const categories = require('../categories');
const db = require('../database'); const db = require('../database');
const plugins = require('../plugins'); const plugins = require('../plugins');
const slugify = require('../slugify'); const slugify = require('../slugify');
@ -119,9 +120,10 @@ Groups.get = async function (groupName, options) {
stop = (parseInt(options.userListCount, 10) || 4) - 1; stop = (parseInt(options.userListCount, 10) || 4) - 1;
} }
const [groupData, members, pending, invited, isMember, isPending, isInvited, isOwner] = await Promise.all([ const [groupData, members, selectCategories, pending, invited, isMember, isPending, isInvited, isOwner] = await Promise.all([
Groups.getGroupData(groupName), Groups.getGroupData(groupName),
Groups.getOwnersAndMembers(groupName, options.uid, 0, stop), Groups.getOwnersAndMembers(groupName, options.uid, 0, stop),
categories.buildForSelect(groupName, 'topics:read', []),
Groups.getUsersFromSet('group:' + groupName + ':pending', ['username', 'userslug', 'picture']), Groups.getUsersFromSet('group:' + groupName + ':pending', ['username', 'userslug', 'picture']),
Groups.getUsersFromSet('group:' + groupName + ':invited', ['username', 'userslug', 'picture']), Groups.getUsersFromSet('group:' + groupName + ':invited', ['username', 'userslug', 'picture']),
Groups.isMember(options.uid, groupName), Groups.isMember(options.uid, groupName),
@ -135,6 +137,10 @@ Groups.get = async function (groupName, options) {
} }
const descriptionParsed = await plugins.fireHook('filter:parse.raw', groupData.description); const descriptionParsed = await plugins.fireHook('filter:parse.raw', groupData.description);
groupData.descriptionParsed = descriptionParsed; groupData.descriptionParsed = descriptionParsed;
groupData.categories = selectCategories.map((category) => {
category.selected = groupData.memberPostCids.includes(category.cid);
return category;
});
groupData.members = members; groupData.members = members;
groupData.membersNextStart = stop + 1; groupData.membersNextStart = stop + 1;
groupData.pending = pending.filter(Boolean); groupData.pending = pending.filter(Boolean);

@ -1,6 +1,7 @@
'use strict'; 'use strict';
const db = require('../database'); const db = require('../database');
const groups = require('.');
const privileges = require('../privileges'); const privileges = require('../privileges');
const posts = require('../posts'); const posts = require('../posts');
@ -13,6 +14,10 @@ module.exports = function (Groups) {
let groupNames = await Groups.getUserGroupMembership('groups:visible:createtime', [postData.uid]); let groupNames = await Groups.getUserGroupMembership('groups:visible:createtime', [postData.uid]);
groupNames = groupNames[0]; groupNames = groupNames[0];
// Only process those groups that have the cid in its memberPostCids setting (or no setting at all)
const groupsCids = await groups.getGroupsFields(groupNames, ['memberPostCids']);
groupNames = groupNames.filter((groupName, idx) => !groupsCids[idx].memberPostCids.length || groupsCids[idx].memberPostCids.includes(postData.cid));
const keys = groupNames.map(groupName => 'group:' + groupName + ':member:pids'); const keys = groupNames.map(groupName => 'group:' + groupName + ':member:pids');
await db.sortedSetsAdd(keys, postData.timestamp, postData.pid); await db.sortedSetsAdd(keys, postData.timestamp, postData.pid);
await Promise.all(groupNames.map(name => truncateMemberPosts(name))); await Promise.all(groupNames.map(name => truncateMemberPosts(name)));

@ -2,6 +2,7 @@
const winston = require('winston'); const winston = require('winston');
const categories = require('../categories');
const plugins = require('../plugins'); const plugins = require('../plugins');
const slugify = require('../slugify'); const slugify = require('../slugify');
const db = require('../database'); const db = require('../database');
@ -18,11 +19,10 @@ module.exports = function (Groups) {
throw new Error('[[error:no-group]]'); throw new Error('[[error:no-group]]');
} }
const result = await plugins.fireHook('filter:group.update', { ({ values } = await plugins.fireHook('filter:group.update', {
groupName: groupName, groupName: groupName,
values: values, values: values,
}); }));
values = result.values;
const payload = { const payload = {
description: values.description || '', description: values.description || '',
@ -66,6 +66,12 @@ module.exports = function (Groups) {
if (values.hasOwnProperty('hidden')) { if (values.hasOwnProperty('hidden')) {
await updateVisibility(groupName, values.hidden); await updateVisibility(groupName, values.hidden);
} }
if (values.hasOwnProperty('memberPostCids')) {
const validCids = await categories.getCidsByPrivilege('categories:cid', groupName, 'topics:read');
payload.memberPostCids = values.memberPostCids.filter(cid => validCids.includes(cid)).join(',') || '';
}
await db.setObject('group:' + groupName, payload); await db.setObject('group:' + groupName, payload);
await Groups.renameGroup(groupName, values.name); await Groups.renameGroup(groupName, values.name);

@ -6,7 +6,7 @@ const utils = require('../utils');
module.exports = function (Plugins) { module.exports = function (Plugins) {
Plugins.deprecatedHooks = { Plugins.deprecatedHooks = {
'filter:privileges:isUserAllowedTo': 'filter:privileges:isAllowedTo',
}; };
Plugins.internals = { Plugins.internals = {

@ -144,7 +144,7 @@ module.exports = function (privileges) {
privileges.admin.get = async function (uid) { privileges.admin.get = async function (uid) {
const [userPrivileges, isAdministrator] = await Promise.all([ const [userPrivileges, isAdministrator] = await Promise.all([
helpers.isUserAllowedTo(privileges.admin.userPrivilegeList, uid, 0), helpers.isAllowedTo(privileges.admin.userPrivilegeList, uid, 0),
user.isAdministrator(uid), user.isAdministrator(uid),
]); ]);
@ -157,7 +157,7 @@ module.exports = function (privileges) {
privileges.admin.can = async function (privilege, uid) { privileges.admin.can = async function (privilege, uid) {
const [isUserAllowedTo, isAdministrator] = await Promise.all([ const [isUserAllowedTo, isAdministrator] = await Promise.all([
helpers.isUserAllowedTo(privilege, uid, [0]), helpers.isAllowedTo(privilege, uid, [0]),
user.isAdministrator(uid), user.isAdministrator(uid),
]); ]);
return isAdministrator || isUserAllowedTo[0]; return isAdministrator || isUserAllowedTo[0];

@ -46,7 +46,7 @@ module.exports = function (privileges) {
const privs = ['topics:create', 'topics:read', 'topics:tag', 'read']; const privs = ['topics:create', 'topics:read', 'topics:tag', 'read'];
const [userPrivileges, isAdministrator, isModerator] = await Promise.all([ const [userPrivileges, isAdministrator, isModerator] = await Promise.all([
helpers.isUserAllowedTo(privs, uid, cid), helpers.isAllowedTo(privs, uid, cid),
user.isAdministrator(uid), user.isAdministrator(uid),
user.isModerator(uid, cid), user.isModerator(uid, cid),
]); ]);
@ -80,7 +80,7 @@ module.exports = function (privileges) {
if (!cid) { if (!cid) {
return false; return false;
} }
const results = await helpers.isUserAllowedTo(privilege, uid, Array.isArray(cid) ? cid : [cid]); const results = await helpers.isAllowedTo(privilege, uid, Array.isArray(cid) ? cid : [cid]);
if (Array.isArray(results) && results.length) { if (Array.isArray(results) && results.length) {
return Array.isArray(cid) ? results : results[0]; return Array.isArray(cid) ? results : results[0];
@ -113,8 +113,8 @@ module.exports = function (privileges) {
privileges.categories.getBase = async function (privilege, cids, uid) { privileges.categories.getBase = async function (privilege, cids, uid) {
return await utils.promiseParallel({ return await utils.promiseParallel({
categories: categories.getCategoriesFields(cids, ['disabled']), categories: categories.getCategoriesFields(cids, ['disabled']),
allowedTo: helpers.isUserAllowedTo(privilege, uid, cids), allowedTo: helpers.isAllowedTo(privilege, uid, cids),
view_deleted: helpers.isUserAllowedTo('posts:view_deleted', uid, cids), view_deleted: helpers.isAllowedTo('posts:view_deleted', uid, cids),
isAdmin: user.isAdministrator(uid), isAdmin: user.isAdministrator(uid),
}); });
}; };

@ -75,7 +75,7 @@ module.exports = function (privileges) {
privileges.global.get = async function (uid) { privileges.global.get = async function (uid) {
const [userPrivileges, isAdministrator] = await Promise.all([ const [userPrivileges, isAdministrator] = await Promise.all([
helpers.isUserAllowedTo(privileges.global.userPrivilegeList, uid, 0), helpers.isAllowedTo(privileges.global.userPrivilegeList, uid, 0),
user.isAdministrator(uid), user.isAdministrator(uid),
]); ]);
@ -88,7 +88,7 @@ module.exports = function (privileges) {
privileges.global.can = async function (privilege, uid) { privileges.global.can = async function (privilege, uid) {
const [isAdministrator, isUserAllowedTo] = await Promise.all([ const [isAdministrator, isUserAllowedTo] = await Promise.all([
user.isAdministrator(uid), user.isAdministrator(uid),
helpers.isUserAllowedTo(privilege, uid, [0]), helpers.isAllowedTo(privilege, uid, [0]),
]); ]);
return isAdministrator || isUserAllowedTo[0]; return isAdministrator || isUserAllowedTo[0];
}; };

@ -26,26 +26,45 @@ helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
return result.allowed; return result.allowed;
}; };
helpers.isUserAllowedTo = async function (privilege, uid, cid) { helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) {
let allowed; let allowed;
if (Array.isArray(privilege) && !Array.isArray(cid)) { if (Array.isArray(privilege) && !Array.isArray(cid)) {
allowed = await isUserAllowedToPrivileges(privilege, uid, cid); allowed = await isAllowedToPrivileges(privilege, uidOrGroupName, cid);
} else if (Array.isArray(cid) && !Array.isArray(privilege)) { } else if (Array.isArray(cid) && !Array.isArray(privilege)) {
allowed = await isUserAllowedToCids(privilege, uid, cid); allowed = await isAllowedToCids(privilege, uidOrGroupName, cid);
} }
if (allowed) { if (allowed) {
const result = await plugins.fireHook('filter:privileges:isUserAllowedTo', { allowed: allowed, privilege: privilege, uid: uid, cid: cid }); ({ allowed } = await plugins.fireHook('filter:privileges:isUserAllowedTo', { allowed: allowed, privilege: privilege, uid: uidOrGroupName, cid: cid }));
return result.allowed; ({ allowed } = await plugins.fireHook('filter:privileges:isAllowedTo', { allowed: allowed, privilege: privilege, uid: uidOrGroupName, cid: cid }));
return allowed;
} }
throw new Error('[[error:invalid-data]]'); throw new Error('[[error:invalid-data]]');
}; };
async function isUserAllowedToCids(privilege, uid, cids) { async function isAllowedToCids(privilege, uidOrGroupName, cids) {
if (!privilege) { if (!privilege) {
return cids.map(() => false); return cids.map(() => false);
} }
if (parseInt(uid, 10) <= 0) {
return await isSystemGroupAllowedToCids(privilege, uid, cids); // Group handling
if (isNaN(parseInt(uidOrGroupName, 10)) && (uidOrGroupName || '').length) {
const groupKeys = [];
cids.forEach(function (cid) {
groupKeys.push('cid:' + cid + ':privileges:groups:' + privilege);
});
const sets = await Promise.all([
groups.isMemberOfGroups(uidOrGroupName, groupKeys),
groups.isMemberOfGroups('registered-users', groupKeys),
]);
return sets[0].reduce((memo, cur, idx) => {
memo.push(cur || sets[1][idx]);
return memo;
}, []);
}
// User handling
if (parseInt(uidOrGroupName, 10) <= 0) {
return await isSystemGroupAllowedToCids(privilege, uidOrGroupName, cids);
} }
const userKeys = []; const userKeys = [];
@ -55,12 +74,29 @@ async function isUserAllowedToCids(privilege, uid, cids) {
groupKeys.push('cid:' + cid + ':privileges:groups:' + privilege); groupKeys.push('cid:' + cid + ':privileges:groups:' + privilege);
}); });
return await checkIfAllowed(uid, userKeys, groupKeys); return await checkIfAllowed(uidOrGroupName, userKeys, groupKeys);
} }
async function isUserAllowedToPrivileges(privileges, uid, cid) { async function isAllowedToPrivileges(privileges, uidOrGroupName, cid) {
if (parseInt(uid, 10) <= 0) { // Group handling
return await isSystemGroupAllowedToPrivileges(privileges, uid, cid); if (isNaN(parseInt(uidOrGroupName, 10)) && (uidOrGroupName || '').length) {
const groupKeys = [];
privileges.forEach(function (privilege) {
groupKeys.push('cid:' + cid + ':privileges:groups:' + privilege);
});
const sets = await Promise.all([
groups.isMemberOfGroups(uidOrGroupName, groupKeys),
groups.isMemberOfGroups('registered-users', groupKeys),
]);
return sets[0].reduce((memo, cur, idx) => {
memo.push(cur || sets[1][idx]);
return memo;
}, []);
}
// User handling
if (parseInt(uidOrGroupName, 10) <= 0) {
return await isSystemGroupAllowedToPrivileges(privileges, uidOrGroupName, cid);
} }
const userKeys = []; const userKeys = [];
@ -70,7 +106,7 @@ async function isUserAllowedToPrivileges(privileges, uid, cid) {
groupKeys.push('cid:' + cid + ':privileges:groups:' + privilege); groupKeys.push('cid:' + cid + ':privileges:groups:' + privilege);
}); });
return await checkIfAllowed(uid, userKeys, groupKeys); return await checkIfAllowed(uidOrGroupName, userKeys, groupKeys);
} }
async function checkIfAllowed(uid, userKeys, groupKeys) { async function checkIfAllowed(uid, userKeys, groupKeys) {

@ -25,11 +25,11 @@ module.exports = function (privileges) {
isAdmin: user.isAdministrator(uid), isAdmin: user.isAdministrator(uid),
isModerator: user.isModerator(uid, uniqueCids), isModerator: user.isModerator(uid, uniqueCids),
isOwner: posts.isOwner(pids, uid), isOwner: posts.isOwner(pids, uid),
'topics:read': helpers.isUserAllowedTo('topics:read', uid, uniqueCids), 'topics:read': helpers.isAllowedTo('topics:read', uid, uniqueCids),
read: helpers.isUserAllowedTo('read', uid, uniqueCids), read: helpers.isAllowedTo('read', uid, uniqueCids),
'posts:edit': helpers.isUserAllowedTo('posts:edit', uid, uniqueCids), 'posts:edit': helpers.isAllowedTo('posts:edit', uid, uniqueCids),
'posts:history': helpers.isUserAllowedTo('posts:history', uid, uniqueCids), 'posts:history': helpers.isAllowedTo('posts:history', uid, uniqueCids),
'posts:view_deleted': helpers.isUserAllowedTo('posts:view_deleted', uid, uniqueCids), 'posts:view_deleted': helpers.isAllowedTo('posts:view_deleted', uid, uniqueCids),
}); });
const isModerator = _.zipObject(uniqueCids, results.isModerator); const isModerator = _.zipObject(uniqueCids, results.isModerator);

@ -23,7 +23,7 @@ module.exports = function (privileges) {
]; ];
const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted']); const topicData = await topics.getTopicFields(tid, ['cid', 'uid', 'locked', 'deleted']);
const [userPrivileges, isAdministrator, isModerator, disabled] = await Promise.all([ const [userPrivileges, isAdministrator, isModerator, disabled] = await Promise.all([
helpers.isUserAllowedTo(privs, uid, topicData.cid), helpers.isAllowedTo(privs, uid, topicData.cid),
user.isAdministrator(uid), user.isAdministrator(uid),
user.isModerator(uid, topicData.cid), user.isModerator(uid, topicData.cid),
categories.getCategoryField(topicData.cid, 'disabled'), categories.getCategoryField(topicData.cid, 'disabled'),
@ -121,7 +121,7 @@ module.exports = function (privileges) {
user.isModerator(uid, topicData.cid), user.isModerator(uid, topicData.cid),
user.isAdministrator(uid), user.isAdministrator(uid),
topics.isOwner(tid, uid), topics.isOwner(tid, uid),
helpers.isUserAllowedTo('topics:delete', uid, [topicData.cid]), helpers.isAllowedTo('topics:delete', uid, [topicData.cid]),
]); ]);
if (isAdministrator) { if (isAdministrator) {

@ -41,7 +41,7 @@ module.exports = function (privileges) {
return await filterIsModerator(cids, uid, cids.map(() => true)); return await filterIsModerator(cids, uid, cids.map(() => true));
} }
const uniqueCids = _.uniq(cids); const uniqueCids = _.uniq(cids);
const isAllowed = await helpers.isUserAllowedTo('moderate', uid, uniqueCids); const isAllowed = await helpers.isAllowedTo('moderate', uid, uniqueCids);
const cidToIsAllowed = _.zipObject(uniqueCids, isAllowed); const cidToIsAllowed = _.zipObject(uniqueCids, isAllowed);
const isModerator = cids.map(cid => cidToIsAllowed[cid]); const isModerator = cids.map(cid => cidToIsAllowed[cid]);

@ -95,6 +95,22 @@
</div> </div>
</fieldset> </fieldset>
<hr />
<div class="form-group">
<label for="memberPostCids">[[groups:details.member-post-cids]]</label>
<select multiple="true" name="memberPostCids" id="memberPostCids" class="form-control" size="15">
{{{each group.categories}}}
<option value="{categories.cid}"{{{ if ../selected }}} selected{{{ end }}}>
{../level}{../name}
</option>
{{{end}}}
</select>
<p class="help-block">[[groups:details.member-post-cids-help]]</p>
</div>
<hr />
<fieldset> <fieldset>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">

Loading…
Cancel
Save