From 90d9b41ec3f3e326d0facf997fb4bb5280becbd6 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Fri, 28 Feb 2025 15:24:21 -0500 Subject: [PATCH] dev: add threads API with create and edit --- src/backend/src/CoreModule.js | 3 + src/backend/src/api/APIError.js | 5 + src/backend/src/services/ThreadService.js | 301 ++++++++++++++++++ .../database/SqliteDatabaseAccessService.js | 3 + .../database/sqlite_setup/0035_threads.sql | 13 + 5 files changed, 325 insertions(+) create mode 100644 src/backend/src/services/ThreadService.js create mode 100644 src/backend/src/services/database/sqlite_setup/0035_threads.sql diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index 5623c303..d6115e58 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -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 }) => { diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js index ec70d088..87309824 100644 --- a/src/backend/src/api/APIError.js +++ b/src/backend/src/api/APIError.js @@ -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, + } }; /** diff --git a/src/backend/src/services/ThreadService.js b/src/backend/src/services/ThreadService.js new file mode 100644 index 00000000..cd1d46e4 --- /dev/null +++ b/src/backend/src/services/ThreadService.js @@ -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, +}; diff --git a/src/backend/src/services/database/SqliteDatabaseAccessService.js b/src/backend/src/services/database/SqliteDatabaseAccessService.js index ca0c43ce..df02c7e7 100644 --- a/src/backend/src/services/database/SqliteDatabaseAccessService.js +++ b/src/backend/src/services/database/SqliteDatabaseAccessService.js @@ -155,6 +155,9 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService { [31, [ '0034_app-redirect.sql', ]], + [32, [ + '0035_threads.sql', + ]] ]; // Database upgrade logic diff --git a/src/backend/src/services/database/sqlite_setup/0035_threads.sql b/src/backend/src/services/database/sqlite_setup/0035_threads.sql new file mode 100644 index 00000000..c0d7fc1c --- /dev/null +++ b/src/backend/src/services/database/sqlite_setup/0035_threads.sql @@ -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`);