diff --git a/public/language/en-GB/activitypub.json b/public/language/en-GB/activitypub.json index eb90e62c35..36212751ae 100644 --- a/public/language/en-GB/activitypub.json +++ b/public/language/en-GB/activitypub.json @@ -1,4 +1,5 @@ { + "category.name": "World", "no-topics": "This forum doesn't know of any other topics yet.", "topic-event-announce-ago": "%1 shared this post %3", diff --git a/public/src/admin/manage/privileges.js b/public/src/admin/manage/privileges.js index 1c4f829f3f..b124956331 100644 --- a/public/src/admin/manage/privileges.js +++ b/public/src/admin/manage/privileges.js @@ -228,7 +228,7 @@ define('admin/manage/privileges', [ applyPrivileges(bannedUsersPrivs, getBannedUsersInputSelector); // For rest that inherits from registered-users - const getRegisteredUsersInputSelector = (privs, i) => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege="${privs[i]}"] input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege="${privs[i]}"] input`; + const getRegisteredUsersInputSelector = (privs, i) => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"],[data-group-name="fediverse"]) td[data-privilege="${privs[i]}"] input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege="${privs[i]}"] input`; const registeredUsersPrivs = getPrivilegesFromRow('registered-users'); applyPrivileges(registeredUsersPrivs, getRegisteredUsersInputSelector); }; @@ -240,7 +240,7 @@ define('admin/manage/privileges', [ inputSelectorFn = () => `.privilege-table tr[data-banned] td[data-privilege]:nth-child(${columnNo}) input`; break; default: - inputSelectorFn = () => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"]) td[data-privilege]:nth-child(${columnNo}) input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege]:nth-child(${columnNo}) input`; + inputSelectorFn = () => `.privilege-table tr[data-group-name]:not([data-group-name="registered-users"],[data-group-name="banned-users"],[data-group-name="guests"],[data-group-name="spiders"],[data-group-name="fediverse"]) td[data-privilege]:nth-child(${columnNo}) input, .privilege-table tr[data-uid]:not([data-banned]) td[data-privilege]:nth-child(${columnNo}) input`; } const sourceChecked = getPrivilegeFromColumn(sourceGroupName, columnNo); diff --git a/public/src/modules/helpers.common.js b/public/src/modules/helpers.common.js index b28f293177..908bfe4f90 100644 --- a/public/src/modules/helpers.common.js +++ b/public/src/modules/helpers.common.js @@ -189,16 +189,18 @@ module.exports = function (utils, Benchpress, relative_path) { return states.map(function (priv) { const guestDisabled = ['groups:moderate', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:local:login', 'groups:group:create']; const spidersEnabled = ['groups:find', 'groups:read', 'groups:topics:read', 'groups:view:users', 'groups:view:tags', 'groups:view:groups']; + const fediverseEnabled = ['groups:view:users', 'groups:find', 'groups:read', 'groups:topics:read', 'groups:topics:create', 'groups:topics:reply', 'groups:topics:tag', 'groups:posts:edit', 'groups:posts:history', 'groups:posts:delete', 'groups:posts:upvote', 'groups:posts:downvote', 'groups:topics:delete']; const globalModDisabled = ['groups:moderate']; const disabled = (member === 'guests' && (guestDisabled.includes(priv.name) || priv.name.startsWith('groups:admin:'))) || (member === 'spiders' && !spidersEnabled.includes(priv.name)) || + (member === 'fediverse' && !fediverseEnabled.includes(priv.name)) || (member === 'Global Moderators' && globalModDisabled.includes(priv.name)); return `
- +
`; diff --git a/src/activitypub/inbox.js b/src/activitypub/inbox.js index 30c0fc5fe4..76c322fd1c 100644 --- a/src/activitypub/inbox.js +++ b/src/activitypub/inbox.js @@ -4,6 +4,7 @@ const winston = require('winston'); const nconf = require('nconf'); const db = require('../database'); +const privileges = require('../privileges'); const user = require('../user'); const posts = require('../posts'); const topics = require('../topics'); @@ -17,20 +18,14 @@ const inbox = module.exports; inbox.create = async (req) => { const { object } = req.body; - const postData = await activitypub.mocks.post(object); // Temporary, reject non-public notes. - if (![...postData._activitypub.to, ...postData._activitypub.cc].includes(activitypub._constants.publicAddress)) { + if (![...object.to, ...object.cc].includes(activitypub._constants.publicAddress)) { throw new Error('[[error:activitypub.not-implemented]]'); } - if (postData) { - await activitypub.notes.assert(0, [postData]); - const tid = await activitypub.notes.assertTopic(0, postData.pid); - winston.info(`[activitypub/inbox] Parsing note ${postData.pid} into topic ${tid}`); - } else { - winston.warn('[activitypub/inbox] Received object was not a note'); - } + const tid = await activitypub.notes.assertTopic(0, object.id); + winston.info(`[activitypub/inbox] Parsing note ${object.id} into topic ${tid}`); }; inbox.update = async (req) => { @@ -43,6 +38,18 @@ inbox.update = async (req) => { throw new Error('[[error:activitypub.origin-mismatch]]'); } + const [exists, allowed] = await Promise.all([ + posts.exists(object.id), + privileges.posts.can('posts:edit', object.id, activitypub._constants.uid), + ]); + if (!exists || !allowed) { + winston.info(`[activitypub/inbox.update] ${object.id} not allowed to be edited.`); + return activitypub.send('uid', 0, actor, { + type: 'Reject', + object, + }); + } + switch (object.type) { case 'Note': { const postData = await activitypub.mocks.post(object); @@ -70,6 +77,15 @@ inbox.like = async (req) => { throw new Error('[[error:activitypub.invalid-id]]'); } + const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid); + if (!allowed) { + winston.info(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`); + return activitypub.send('uid', 0, actor, { + type: 'Reject', + object, + }); + } + winston.info(`[activitypub/inbox/like] id ${id} via ${actor}`); await posts.upvote(id, actor); @@ -172,17 +188,29 @@ inbox.follow = async (req) => { }, }); } else if (type === 'category') { - const exists = await categories.exists(id); + const [exists, allowed] = await Promise.all([ + categories.exists(id), + privileges.categories.can('read', id, 'activitypub._constants.uid'), + ]); if (!exists) { throw new Error('[[error:invalid-cid]]'); } + if (!allowed) { + return activitypub.send('uid', 0, req.body.actor, { + type: 'Reject', + object: { + type: 'Follow', + actor: req.body.actor, + }, + }); + } const watchState = await categories.getWatchState([id], req.body.actor); if (watchState[0] !== categories.watchStates.tracking) { await user.setCategoryWatchState(req.body.actor, id, categories.watchStates.tracking); } - await activitypub.send('cid', id, req.body.actor, { + activitypub.send('cid', id, req.body.actor, { type: 'Accept', object: { type: 'Follow', @@ -275,6 +303,16 @@ inbox.undo = async (req) => { throw new Error('[[error:invalid-pid]]'); } + const allowed = await privileges.posts.can('posts:upvote', id, activitypub._constants.uid); + if (!allowed) { + winston.info(`[activitypub/inbox.like] ${id} not allowed to be upvoted.`); + activitypub.send('uid', 0, actor, { + type: 'Reject', + object, + }); + break; + } + await posts.unvote(id, actor); break; } diff --git a/src/activitypub/index.js b/src/activitypub/index.js index 4f2235100a..5d4922d7f8 100644 --- a/src/activitypub/index.js +++ b/src/activitypub/index.js @@ -14,6 +14,7 @@ const requestCache = ttl({ ttl: 1000 * 60 * 5 }); // 5 minutes const ActivityPub = module.exports; ActivityPub._constants = Object.freeze({ + uid: -2, publicAddress: 'https://www.w3.org/ns/activitystreams#Public', }); ActivityPub._cache = requestCache; @@ -163,7 +164,6 @@ ActivityPub.verify = async (req) => { return memo; }, []).join('\n'); - // Verify the signature string via public key try { // Retrieve public key from remote instance @@ -188,24 +188,29 @@ ActivityPub.get = async (type, id, uri) => { const keyData = await ActivityPub.getPrivateKey(type, id); const headers = id >= 0 ? await ActivityPub.sign(keyData, uri) : {}; winston.verbose(`[activitypub/get] ${uri}`); - const { response, body } = await request.get(uri, { - headers: { - ...headers, - Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', - }, - }); + try { + const { response, body } = await request.get(uri, { + headers: { + ...headers, + Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + }, + }); - if (!String(response.statusCode).startsWith('2')) { - winston.error(`[activitypub/get] Received ${response.statusCode} when querying ${uri}`); - if (body.hasOwnProperty('error')) { - winston.error(`[activitypub/get] Error received: ${body.error}`); + if (!String(response.statusCode).startsWith('2')) { + winston.error(`[activitypub/get] Received ${response.statusCode} when querying ${uri}`); + if (body.hasOwnProperty('error')) { + winston.error(`[activitypub/get] Error received: ${body.error}`); + } + + throw new Error(`[[error:activitypub.get-failed]]`); } + requestCache.set(cacheKey, body); + return body; + } catch (e) { + // Handle things like non-json body, etc. throw new Error(`[[error:activitypub.get-failed]]`); } - - requestCache.set(cacheKey, body); - return body; }; ActivityPub.send = async (type, id, targets, payload) => { @@ -218,7 +223,7 @@ ActivityPub.send = async (type, id, targets, payload) => { let actor; switch (type) { case 'uid': { - actor = `${nconf.get('url')}/uid/${id}`; + actor = `${nconf.get('url')}${id > 0 ? `/uid/${id}` : '/actor'}`; break; } diff --git a/src/activitypub/notes.js b/src/activitypub/notes.js index be89ad1b3f..1b21de5b77 100644 --- a/src/activitypub/notes.js +++ b/src/activitypub/notes.js @@ -4,6 +4,7 @@ const winston = require('winston'); const crypto = require('crypto'); const db = require('../database'); +const privileges = require('../privileges'); const user = require('../user'); const topics = require('../topics'); const posts = require('../posts'); @@ -205,8 +206,17 @@ Notes.assertTopic = async (uid, id) => { return tid; } + const cid = tid ? await topics.getTopicField(tid, 'cid') : -1; + + // Privilege check for local categories + const privilege = `topics:${tid ? 'reply' : 'create'}`; + const allowed = await privileges.categories.can(privilege, cid, activitypub._constants.uid); + console.log(privilege, cid, allowed); + if (!allowed) { + return null; + } + tid = tid || utils.generateUUID(); - const cid = await topics.getTopicField(tid, 'cid'); let title = name || utils.decodeHTMLEntities(utils.stripHTMLTags(content)); if (title.length > 64) { @@ -229,7 +239,7 @@ Notes.assertTopic = async (uid, id) => { db.setObject(`topic:${tid}`, { tid, uid: authorId, - cid: cid || -1, + cid: cid, mainPid, title, slug: `${tid}/${slugify(title)}`, diff --git a/src/categories/create.js b/src/categories/create.js index c4aa403425..92924eb6dd 100644 --- a/src/categories/create.js +++ b/src/categories/create.js @@ -91,7 +91,7 @@ module.exports = function (Categories) { ['categories:name', 0, `${data.name.slice(0, 200).toLowerCase()}:${category.cid}`], ]); - await privileges.categories.give(result.defaultPrivileges, category.cid, 'registered-users'); + await privileges.categories.give(result.defaultPrivileges, category.cid, ['registered-users', 'fediverse']); await privileges.categories.give(result.modPrivileges, category.cid, ['administrators', 'Global Moderators']); await privileges.categories.give(result.guestPrivileges, category.cid, ['guests', 'spiders']); diff --git a/src/controllers/activitypub/actors.js b/src/controllers/activitypub/actors.js index 2d7e1cb751..adb1406c81 100644 --- a/src/controllers/activitypub/actors.js +++ b/src/controllers/activitypub/actors.js @@ -3,6 +3,7 @@ const nconf = require('nconf'); const meta = require('../../meta'); +const privileges = require('../../privileges'); const posts = require('../../posts'); const topics = require('../../topics'); const categories = require('../../categories'); @@ -50,9 +51,10 @@ Actors.userBySlug = async function (req, res) { Actors.note = async function (req, res, next) { // technically a note isn't an actor, but it is here purely for organizational purposes. // but also, wouldn't it be wild if you could follow a note? lol. + const allowed = await privileges.posts.can('topics:read', req.params.pid, activitypub._constants.uid); const post = (await posts.getPostSummaryByPids([req.params.pid], req.uid, { stripTags: false })).pop(); - if (!post) { - return next('route'); + if (!allowed || !post) { + return res.sendStatus(404); } const payload = await activitypub.mocks.note(post); @@ -61,10 +63,11 @@ Actors.note = async function (req, res, next) { Actors.topic = async function (req, res, next) { // When queried, a topic more or less returns the main pid's note representation + const allowed = await privileges.topics.can('topics:read', req.params.tid, activitypub._constants.uid); const { mainPid, slug } = await topics.getTopicFields(req.params.tid, ['mainPid', 'slug']); const post = (await posts.getPostSummaryByPids([mainPid], req.uid, { stripTags: false })).pop(); - if (!post) { - return next('route'); + if (!allowed || !post) { + return res.sendStatus(404); } const payload = await activitypub.mocks.note(post); @@ -77,8 +80,11 @@ Actors.topic = async function (req, res, next) { }; Actors.category = async function (req, res, next) { - const exists = await categories.exists(req.params.cid); - if (!exists) { + const [exists, allowed] = await Promise.all([ + categories.exists(req.params.cid), + privileges.categories.can('find', req.params.cid, activitypub._constants.uid), + ]); + if (!exists || !allowed) { return next('route'); } diff --git a/src/controllers/activitypub/index.js b/src/controllers/activitypub/index.js index 72165ad7f0..e21e81633a 100644 --- a/src/controllers/activitypub/index.js +++ b/src/controllers/activitypub/index.js @@ -1,6 +1,7 @@ 'use strict'; const nconf = require('nconf'); +const winston = require('winston'); const user = require('../../user'); const activitypub = require('../../activitypub'); @@ -115,6 +116,7 @@ Controller.postInbox = async (req, res) => { // Note: underlying methods are internal use only, hence no exposure via src/api const method = String(req.body.type).toLowerCase(); if (!activitypub.inbox.hasOwnProperty(method)) { + winston.warn(`[activitypub/inbox] Received Activity of type ${method} but unable to handle. Ignoring.`); return res.sendStatus(501); } diff --git a/src/controllers/admin/privileges.js b/src/controllers/admin/privileges.js index 28833b5562..4703f6552f 100644 --- a/src/controllers/admin/privileges.js +++ b/src/controllers/admin/privileges.js @@ -2,6 +2,7 @@ const categories = require('../../categories'); const privileges = require('../../privileges'); +const utils = require('../../utils'); const privilegesController = module.exports; @@ -10,10 +11,10 @@ privilegesController.get = async function (req, res) { const isAdminPriv = req.params.cid === 'admin'; let privilegesData; - if (cid > 0) { - privilegesData = await privileges.categories.list(cid); - } else if (cid === 0) { + if (cid === 0) { privilegesData = await (isAdminPriv ? privileges.admin.list(req.uid) : privileges.global.list()); + } else if (utils.isNumber(cid)) { + privilegesData = await privileges.categories.list(cid); } const categoriesData = [{ @@ -24,6 +25,12 @@ privilegesController.get = async function (req, res) { cid: 'admin', name: '[[admin/manage/privileges:admin]]', icon: 'fa-lock', + }, { + cid: -1, + name: '[[activitypub:category.name]]', + icon: 'fa-globe', + bgColor: '#0000ff', + color: '#ffffff', }]; let selectedCategory; diff --git a/src/groups/index.js b/src/groups/index.js index ec92f05fb1..60b859f90e 100644 --- a/src/groups/index.js +++ b/src/groups/index.js @@ -24,7 +24,7 @@ require('./cache')(Groups); Groups.BANNED_USERS = 'banned-users'; -Groups.ephemeralGroups = ['guests', 'spiders']; +Groups.ephemeralGroups = ['guests', 'spiders', 'fediverse']; Groups.systemGroups = [ 'registered-users', @@ -55,7 +55,7 @@ Groups.removeEphemeralGroups = function (groups) { return groups; }; -const isPrivilegeGroupRegex = /^cid:(?:\d+|admin):privileges:[\w\-:]+$/; +const isPrivilegeGroupRegex = /^cid:(?:-?\d+|admin):privileges:[\w\-:]+$/; Groups.isPrivilegeGroup = function (groupName) { return isPrivilegeGroupRegex.test(groupName); }; diff --git a/src/install.js b/src/install.js index 89b40d7b39..20c429a521 100644 --- a/src/install.js +++ b/src/install.js @@ -436,6 +436,37 @@ async function giveGlobalPrivileges() { ]), 'Global Moderators'); await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'guests'); await privileges.global.give(['groups:view:users', 'groups:view:tags', 'groups:view:groups'], 'spiders'); + await privileges.global.give(['groups:view:users'], 'fediverse'); +} + +async function giveWorldPrivileges() { + // should match privilege assignment logic in src/categories/create.js EXCEPT commented one liner below + const privileges = require('./privileges'); + const defaultPrivileges = [ + 'groups:find', + 'groups:read', + 'groups:topics:read', + 'groups:topics:create', + 'groups:topics:reply', + 'groups:topics:tag', + 'groups:posts:edit', + 'groups:posts:history', + 'groups:posts:delete', + 'groups:posts:upvote', + 'groups:posts:downvote', + 'groups:topics:delete', + ]; + const modPrivileges = defaultPrivileges.concat([ + 'groups:topics:schedule', + 'groups:posts:view_deleted', + 'groups:purge', + ]); + const guestPrivileges = ['groups:find', 'groups:read', 'groups:topics:read']; + + await privileges.categories.give(defaultPrivileges, -1, ['registered-users']); + await privileges.categories.give(defaultPrivileges.slice(3), -1, ['fediverse']); // different priv set for fediverse + await privileges.categories.give(modPrivileges, -1, ['administrators', 'Global Moderators']); + await privileges.categories.give(guestPrivileges, -1, ['guests', 'spiders']); } async function createCategories() { @@ -588,6 +619,7 @@ install.setup = async function () { const adminInfo = await createAdministrator(); await createGlobalModeratorsGroup(); await giveGlobalPrivileges(); + await giveWorldPrivileges(); await createMenuItems(); await createWelcomePost(); await enableDefaultPlugins(); diff --git a/src/middleware/activitypub.js b/src/middleware/activitypub.js index 7a49987c06..de96e33938 100644 --- a/src/middleware/activitypub.js +++ b/src/middleware/activitypub.js @@ -48,7 +48,7 @@ middleware.validate = async function (req, res, next) { const { actor, object } = req.body; // Origin checking - if (typeof object !== 'string') { + if (typeof object !== 'string' && object.hasOwnProperty('id')) { const actorHostname = new URL(actor).hostname; const objectHostname = new URL(object.id).hostname; if (actorHostname !== objectHostname) { diff --git a/src/privileges/categories.js b/src/privileges/categories.js index bdfba7117d..6061b28abe 100644 --- a/src/privileges/categories.js +++ b/src/privileges/categories.js @@ -154,11 +154,6 @@ privsCategories.can = async function (privilege, cid, uid) { return false; } - // temporary - if (cid === -1) { - return true; - } - const [disabled, isAdmin, isAllowed] = await Promise.all([ categories.getCategoryField(cid, 'disabled'), user.isAdministrator(uid), diff --git a/src/privileges/helpers.js b/src/privileges/helpers.js index 58df456ea9..c858eebe60 100644 --- a/src/privileges/helpers.js +++ b/src/privileges/helpers.js @@ -15,6 +15,7 @@ const helpers = module.exports; const uidToSystemGroup = { 0: 'guests', '-1': 'spiders', + '-2': 'fediverse', }; helpers.isUsersAllowedTo = async function (privilege, uids, cid) { diff --git a/src/upgrades/4.0.0/assign_world_privileges.js b/src/upgrades/4.0.0/assign_world_privileges.js new file mode 100644 index 0000000000..d8d4555d6e --- /dev/null +++ b/src/upgrades/4.0.0/assign_world_privileges.js @@ -0,0 +1,38 @@ +'use strict'; + +// const db = require('../../database'); + +module.exports = { + name: 'Assigning default privileges to "World" pseudo-category', + timestamp: Date.UTC(2024, 1, 22), + method: async () => { + const privileges = require('../../privileges'); + + // should match privilege assignment logic in src/categories/create.js EXCEPT commented one liner below + const defaultPrivileges = [ + 'groups:find', + 'groups:read', + 'groups:topics:read', + 'groups:topics:create', + 'groups:topics:reply', + 'groups:topics:tag', + 'groups:posts:edit', + 'groups:posts:history', + 'groups:posts:delete', + 'groups:posts:upvote', + 'groups:posts:downvote', + 'groups:topics:delete', + ]; + const modPrivileges = defaultPrivileges.concat([ + 'groups:topics:schedule', + 'groups:posts:view_deleted', + 'groups:purge', + ]); + const guestPrivileges = ['groups:find', 'groups:read', 'groups:topics:read']; + + await privileges.categories.give(defaultPrivileges, -1, ['registered-users']); + await privileges.categories.give(defaultPrivileges.slice(3), -1, ['fediverse']); // different priv set for fediverse + await privileges.categories.give(modPrivileges, -1, ['administrators', 'Global Moderators']); + await privileges.categories.give(guestPrivileges, -1, ['guests', 'spiders']); + }, +};