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']);
+ },
+};