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

* 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 4 years ago 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