diff --git a/public/openapi/components/schemas/CategoryObject.yaml b/public/openapi/components/schemas/CategoryObject.yaml index 4d6cb0ca4e..0b138542b0 100644 --- a/public/openapi/components/schemas/CategoryObject.yaml +++ b/public/openapi/components/schemas/CategoryObject.yaml @@ -8,6 +8,14 @@ CategoryObject: name: type: string description: The category's name/title + handle: + type: string + description: | + An URL-safe name/handle used to represent the category over federated networks (e.g. ActivityPub). + + This value is separate from the `slug`, which is used specifically in the URL as a human-readable representation. + + The handle is unique across-the-board between users/groups/categories. description: type: string description: A variable-length description of the category (usually displayed underneath the category name) diff --git a/src/categories/create.js b/src/categories/create.js index 92924eb6dd..745c8b9d73 100644 --- a/src/categories/create.js +++ b/src/categories/create.js @@ -5,6 +5,7 @@ const _ = require('lodash'); const db = require('../database'); const plugins = require('../plugins'); +const meta = require('../meta'); const privileges = require('../privileges'); const utils = require('../utils'); const slugify = require('../slugify'); @@ -20,6 +21,7 @@ module.exports = function (Categories) { data.name = String(data.name || `Category ${cid}`); const slug = `${cid}/${slugify(data.name)}`; + const handle = await Categories.generateHandle(slugify(data.name)); const smallestOrder = firstChild.length ? firstChild[0].score - 1 : 1; const order = data.order || smallestOrder; // If no order provided, place it at the top const colours = Categories.assignColours(); @@ -27,6 +29,7 @@ module.exports = function (Categories) { let category = { cid: cid, name: data.name, + handle, description: data.description ? data.description : '', descriptionParsed: data.descriptionParsed ? data.descriptionParsed : '', icon: data.icon ? data.icon : '', @@ -146,6 +149,19 @@ module.exports = function (Categories) { await async.each(children, Categories.create); } + async function generateHandle(slug) { + let taken = await meta.slugTaken(slug); + let suffix; + while (taken) { + suffix = utils.generateUUID().slice(0, 8); + // eslint-disable-next-line no-await-in-loop + taken = await meta.slugTaken(`${slug}-${suffix}`); + } + + return `${slug}${suffix ? `-${suffix}` : ''}`; + } + Categories.generateHandle = generateHandle; // exported for upgrade script (4.0.0) + Categories.assignColours = function () { const backgrounds = ['#AB4642', '#DC9656', '#F7CA88', '#A1B56C', '#86C1B9', '#7CAFC2', '#BA8BAF', '#A16946']; const text = ['#ffffff', '#ffffff', '#333333', '#ffffff', '#333333', '#ffffff', '#ffffff', '#ffffff']; diff --git a/src/categories/index.js b/src/categories/index.js index 54346d5a64..bbdf9f7ece 100644 --- a/src/categories/index.js +++ b/src/categories/index.js @@ -30,6 +30,13 @@ Categories.exists = async function (cids) { ); }; +Categories.existsByHandle = async function (handle) { + if (Array.isArray(handle)) { + return await db.isSortedSetMembers('categoryhandle:cid', handle); + } + return await db.isSortedSetMember('categoryhandle:cid', handle); +}; + Categories.getCategoryById = async function (data) { const categories = await Categories.getCategories([data.cid]); if (!categories[0]) { @@ -67,6 +74,10 @@ Categories.getCategoryById = async function (data) { return result.category; }; +Categories.getCidByHandle = async function (handle) { + return await db.sortedSetScore('categoryhandle:cid', handle); +}; + Categories.getAllCidsFromSet = async function (key) { let cids = cache.get(key); if (cids) { diff --git a/src/controllers/well-known.js b/src/controllers/well-known.js index 2cc91da6a6..93a0839176 100644 --- a/src/controllers/well-known.js +++ b/src/controllers/well-known.js @@ -19,18 +19,21 @@ Controller.webfinger = async (req, res) => { // Get the slug const slug = resource.slice(5, resource.length - (host.length + 1)); - const uid = await user.getUidByUserslug(slug); + const [uid, cid] = await Promise.all([ + user.getUidByUserslug(slug), + categories.getCidByHandle(slug), + ]); let response = { subject: `acct:${slug}@${host}`, }; try { - if (slug.startsWith('cid.')) { - response = await category(req.uid, slug.slice(4), response); - } else if (slug === hostname) { + if (slug === hostname) { response = application(response); } else if (uid) { response = await profile(req.uid, uid, response); + } else if (cid) { + response = await category(req.uid, cid, response); } else { return res.sendStatus(404); } diff --git a/src/groups/create.js b/src/groups/create.js index 74ef56a41f..3b8b5da669 100644 --- a/src/groups/create.js +++ b/src/groups/create.js @@ -18,7 +18,7 @@ module.exports = function (Groups) { Groups.validateGroupName(data.name); - const exists = await meta.userOrGroupExists(data.name); + const exists = await meta.slugTaken(data.name); if (exists) { throw new Error('[[error:group-already-exists]]'); } diff --git a/src/meta/index.js b/src/meta/index.js index 487c53df60..464ce3dea8 100644 --- a/src/meta/index.js +++ b/src/meta/index.js @@ -25,19 +25,20 @@ Meta.blacklist = require('./blacklist'); Meta.languages = require('./languages'); -/* Assorted */ -Meta.userOrGroupExists = async function (slug) { +Meta.slugTaken = async function (slug) { if (!slug) { throw new Error('[[error:invalid-data]]'); } - const user = require('../user'); - const groups = require('../groups'); + + const [user, groups, categories] = [require('../user'), require('../groups'), require('../categories')]; slug = slugify(slug); - const [userExists, groupExists] = await Promise.all([ + + const exists = await Promise.all([ user.existsBySlug(slug), groups.existsBySlug(slug), + categories.existsByHandle(slug), ]); - return userExists || groupExists; + return exists.some(Boolean); }; if (nconf.get('isPrimary')) { diff --git a/src/upgrades/4.0.0/activitypub_setup.js b/src/upgrades/4.0.0/activitypub_setup.js index 6e30020b8a..fdc93f2fdf 100644 --- a/src/upgrades/4.0.0/activitypub_setup.js +++ b/src/upgrades/4.0.0/activitypub_setup.js @@ -1,7 +1,9 @@ 'use strict'; -// const db = require('../../database'); +const db = require('../../database'); const meta = require('../../meta'); +const categories = require('../../categories'); +const slugify = require('../../slugify'); module.exports = { name: 'Setting up default configs/privileges re: ActivityPub', @@ -13,5 +15,20 @@ module.exports = { // Set default privileges for world category const install = require('../../install'); await install.giveWorldPrivileges(); + + // Run through all categories and ensure their slugs are unique (incl. users/groups too) + const cids = await db.getSortedSetMembers('categories:cid'); + const names = await db.getObjectsFields(cids.map(cid => `category:${cid}`), cids.map(() => 'name')); + + const handles = await Promise.all(cids.map(async (cid, idx) => { + const { name } = names[idx]; + const handle = await categories.generateHandle(slugify(name)); + return handle; + })); + + await Promise.all([ + db.setObjectBulk(cids.map((cid, idx) => [`category:${cid}`, { handle: handles[idx] }])), + db.sortedSetAdd('categoryhandle:cid', cids, handles), + ]); }, }; diff --git a/src/user/create.js b/src/user/create.js index 610d614e81..7cdfb3c4cf 100644 --- a/src/user/create.js +++ b/src/user/create.js @@ -184,7 +184,7 @@ module.exports = function (User) { let { username } = userData; while (true) { /* eslint-disable no-await-in-loop */ - const exists = await meta.userOrGroupExists(username); + const exists = await meta.slugTaken(username); if (!exists) { return numTries ? username : null; }