dev: add threads API with create and edit

This commit is contained in:
KernelDeimos
2025-02-28 15:24:21 -05:00
parent 02ddfcc567
commit 90d9b41ec3
5 changed files with 325 additions and 0 deletions

View File

@@ -364,6 +364,9 @@ const install = async ({ services, app, useapi, modapi }) => {
const { RequestMeasureService } = require('./services/RequestMeasureService');
services.registerService('request-measure', RequestMeasureService);
const { ThreadService } = require('./services/ThreadService');
services.registerService('thread', ThreadService);
}
const install_legacy = async ({ services }) => {

View File

@@ -479,6 +479,11 @@ module.exports = class APIError {
status: 400,
message: 'Incorrect or missing anti-CSRF token.',
},
'not_yet_supported': {
status: 400,
message: ({ message }) => message,
}
};
/**

View File

@@ -0,0 +1,301 @@
const { PermissionImplicator, PermissionUtil } = require("./auth/PermissionService");
const BaseService = require("./BaseService")
const APIError = require("../api/APIError");
const { is_valid_uuid } = require("../helpers");
const { Context } = require("../util/context");
const { DB_WRITE } = require("./database/consts");
const configurable_auth = require("../middleware/configurable_auth");
const { Endpoint } = require("../util/expressutil");
const { whatis } = require("../util/langutil");
const { UserActorType } = require("./auth/Actor");
class ThreadService extends BaseService {
static MODULES = {
uuidv4: require('uuid').v4,
};
async _init () {
this.db = this.services.get('database').get(DB_WRITE, 'service:thread');
this.thread_body_max_size = 4 * 1024; // 4KiB
const svc_permission = this.services.get('permission');
svc_permission.register_implicator(PermissionImplicator.create({
id: 'is-thread-owner',
matcher: permission => {
return permission.startsWith('thread:');
},
checker: async ({ actor, permission }) => {
if ( !(actor.type instanceof UserActorType) ) {
return undefined;
}
const [_, uid] = PermissionUtil.split(permission);
const thread = await this.get_thread({ uid });
if (
thread.owner_user_id === actor.type.user.id &&
thread.parent_uid === null
) {
return {};
}
return undefined;
}
}));
const NO_RECURSE_PERMISSIONS = ['children-of', 'own-children-of'];
svc_permission.register_implicator(PermissionImplicator.create({
id: 'children-of',
matcher: permission => {
if ( ! permission.startsWith('thread:') ) return;
const [_, uid, ...rest] = PermissionUtil.split(permission);
if ( rest.length > 0 && NO_RECURSE_PERMISSIONS.includes(rest[0]) ) {
return undefined;
}
return true;
},
checker: async ({ actor, permission }) => {
const [_, uid, ...rest] = PermissionUtil.split(permission);
const thread = await this.get_thread({ uid });
const parent_uid = thread.parent_uid;
if ( parent_uid === null ) {
return undefined;
}
const svc_permission = this.services.get('permission');
const reading = await svc_permission.scan(
actor,
PermissionUtil.join('thread', parent_uid, 'children-of', ...rest),
);
const options = PermissionUtil.reading_to_options(reading);
if ( options.length <= 0 ) {
return undefined;
}
return {};
}
}));
svc_permission.register_implicator(PermissionImplicator.create({
id: 'own-children-of',
matcher: permission => {
if ( ! permission.startsWith('thread:') ) return;
const [_, uid, ...rest] = PermissionUtil.split(permission);
debugger;
if ( rest.length > 0 && NO_RECURSE_PERMISSIONS.includes(rest[0]) ) {
return undefined;
}
return true;
},
checker: async ({ actor, permission }) => {
const [_, uid, ...rest] = PermissionUtil.split(permission);
const thread = await this.get_thread({ uid });
const parent_uid = thread.parent_uid;
if ( parent_uid === null ) {
return undefined;
}
console.log('own children implicator', {
permission
});
if ( thread.owner_user_id !== actor.type.user.id ) {
return undefined;
}
const svc_permission = this.services.get('permission');
const reading = await svc_permission.scan(
actor,
PermissionUtil.join('thread', parent_uid, 'own-children-of', ...rest),
);
const options = PermissionUtil.reading_to_options(reading);
if ( options.length <= 0 ) {
return undefined;
}
return {};
}
}));
}
async ['__on_install.routes'] (_, { app }) {
const r_threads = (() => {
const require = this.require;
const express = require('express');
return express.Router();
})();
app.use('/threads', r_threads);
this.install_threads_endpoints_({ router: r_threads });
}
install_threads_endpoints_ ({ router }) {
Endpoint({
route: '/create',
methods: ['POST'],
mw: [configurable_auth()],
handler: async (req, res) => {
const actor = Context.get('actor');
const text = req.body.text;
if ( whatis(text) !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'text',
expected: 'string',
got: whatis(text),
});
}
if ( text.length > this.thread_body_max_size ) {
throw APIError.create('field_too_large', null, {
key: 'text',
max_size: this.thread_body_max_size,
size: text.length,
});
}
const uid = this.modules.uuidv4();
const parent_uid = req.body.parent ?? null;
if ( parent_uid !== null ) {
if ( whatis(parent_uid) !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'parent',
expected: 'string',
got: whatis(parent_uid),
});
}
// Disable deep-nesting for now
{
const parent_thread = await this.get_thread({ uid: parent_uid });
if ( !parent_thread ) {
throw APIError.create('thread_not_found', null, {
uid: parent_uid,
});
}
if ( parent_thread.parent_uid ) {
throw APIError.create('not_yet_supported', null, {
message: 'deeply nested threads are not yet supported',
});
}
}
const svc_permission = this.services.get('permission');
const reading = await svc_permission.scan(
actor,
PermissionUtil.join('thread', parent_uid, 'post'),
);
const options = PermissionUtil.reading_to_options(reading);
if ( options.length <= 0 ) {
throw APIError.create('permission_denied', null, {
permission: 'thread:' + parent_uid + ':post',
});
}
}
if ( parent_uid === null ) {
console.log('its this one');
await this.db.write(
"INSERT INTO `thread` (uid, owner_user_id, text) VALUES (?, ?, ?)",
[uid, actor.type.user.id, text]
);
} else {
console.log('its tHAT one');
await this.db.write(
"INSERT INTO `thread` (uid, parent_uid, owner_user_id, text) VALUES (?, ?, ?, ?)",
[uid, parent_uid, actor.type.user.id, text]
);
}
res.json({ uid });
}
}).attach(router);
Endpoint({
route: '/edit',
methods: ['POST'],
mw: [configurable_auth()],
handler: async (req, res) => {
const text = req.body.text;
if ( whatis(text) !== 'string' ) {
throw APIError.create('field_invalid', null, {
key: 'text',
expected: 'string',
got: whatis(text),
});
}
if ( text.length > this.thread_body_max_size ) {
throw APIError.create('field_too_large', null, {
key: 'text',
max_size: this.thread_body_max_size,
size: text.length,
});
}
const uid = req.body.uid;
if ( ! is_valid_uuid(uid) ) {
throw APIError.create('field_invalid', null, {
key: 'uid',
expected: 'uuid',
got: whatis(uid),
});
}
// Get existing thread
const thread = await this.get_thread({ uid });
console.log('thread???', thread);
if ( !thread ) {
throw APIError.create('thread_not_found', null, {
uid,
});
}
const actor = Context.get('actor');
// Check edit permission
{
const permission = PermissionUtil.join('thread', uid, 'edit');
const svc_permission = this.services.get('permission');
const reading = await svc_permission.scan(actor, permission);
const options = PermissionUtil.reading_to_options(reading);
if ( options.length <= 0 ) {
throw APIError.create('permission_denied', null, {
permission,
});
}
}
// Update thread
await this.db.write(
"UPDATE `thread` SET text=? WHERE uid=?",
[text, uid]
);
res.json({});
}
}).attach(router);
}
async get_thread ({ uid }) {
const [thread] = await this.db.read(
"SELECT * FROM `thread` WHERE uid=?",
[uid]
);
if ( !thread ) {
throw APIError.create('thread_not_found', null, {
uid,
});
}
return thread;
}
}
module.exports = {
ThreadService,
};

View File

@@ -155,6 +155,9 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
[31, [
'0034_app-redirect.sql',
]],
[32, [
'0035_threads.sql',
]]
];
// Database upgrade logic

View File

@@ -0,0 +1,13 @@
CREATE TABLE `thread` (
`id` INTEGER PRIMARY KEY,
`uid` TEXT NOT NULL UNIQUE,
`parent_uid` TEXT NULL DEFAULT NULL,
`owner_user_id` INTEGER NOT NULL,
`schema` TEXT NULL DEFAULT NULL,
`text` TEXT NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY("parent_uid") REFERENCES "thread" ("uid") ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY("owner_user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX `idx_thread_uid` ON `thread` (`uid`);