From a5707e2f8120fce2f5b952c55ab6ac86bc8237f1 Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Sat, 8 Nov 2025 11:55:57 -0800 Subject: [PATCH] refactor: events to a pure stream instead of being part of topics NOTE: tests are passing, but the client is broken --- deno.json | 2 +- deno.lock | 8 +- models/channel.ts | 91 ++++++++++ models/event.ts | 153 ++++++++-------- models/topic.ts | 87 --------- models/watch.ts | 84 ++++++--- public/api/channels/:channel_id/README.md | 23 +++ .../:channel_id/events/:event_id/index.ts | 40 +++++ .../api/channels/:channel_id/events/index.ts | 134 ++++++++++++++ .../:channel_id}/index.ts | 58 +++--- .../events/:event_id => channels}/README.md | 0 public/api/{topics => channels}/index.ts | 54 +++--- public/api/events/:event_id/README.md | 0 public/api/events/:event_id/index.ts | 159 +++++++++++++++++ .../{topics/:topic_id => }/events/README.md | 0 .../{topics/:topic_id => }/events/index.ts | 162 ++++++++++------- public/api/topics/:topic_id/README.md | 23 --- .../:topic_id/events/:event_id/index.ts | 166 ------------------ public/api/topics/README.md | 28 --- public/api/users/index.ts | 36 ++-- tests/01_create_user.test.ts | 3 +- tests/02_update_user.test.ts | 3 - ...opic.test.ts => 04_create_channel.test.ts} | 28 ++- ...opic.test.ts => 05_update_channel.test.ts} | 40 ++--- ...opic.test.ts => 06_delete_channel.test.ts} | 16 +- ...st.ts => 07_create_channel_events.test.ts} | 41 +++-- ....test.ts => 08_get_channel_events.test.ts} | 22 +-- ...st.ts => 09_update_channel_events.test.ts} | 76 ++++---- ...e_channel_events_when_append_only.test.ts} | 46 ++--- utils/object_helpers.ts | 30 ++++ utils/prechecks.ts | 7 +- 31 files changed, 934 insertions(+), 686 deletions(-) create mode 100644 models/channel.ts delete mode 100644 models/topic.ts create mode 100644 public/api/channels/:channel_id/README.md create mode 100644 public/api/channels/:channel_id/events/:event_id/index.ts create mode 100644 public/api/channels/:channel_id/events/index.ts rename public/api/{topics/:topic_id => channels/:channel_id}/index.ts (57%) rename public/api/{topics/:topic_id/events/:event_id => channels}/README.md (100%) rename public/api/{topics => channels}/index.ts (62%) create mode 100644 public/api/events/:event_id/README.md create mode 100644 public/api/events/:event_id/index.ts rename public/api/{topics/:topic_id => }/events/README.md (100%) rename public/api/{topics/:topic_id => }/events/index.ts (51%) delete mode 100644 public/api/topics/:topic_id/README.md delete mode 100644 public/api/topics/:topic_id/events/:event_id/index.ts delete mode 100644 public/api/topics/README.md rename tests/{04_create_topic.test.ts => 04_create_channel.test.ts} (74%) rename tests/{05_update_topic.test.ts => 05_update_channel.test.ts} (59%) rename tests/{06_delete_topic.test.ts => 06_delete_channel.test.ts} (75%) rename tests/{07_create_topic_events.test.ts => 07_create_channel_events.test.ts} (70%) rename tests/{08_get_topic_events.test.ts => 08_get_channel_events.test.ts} (82%) rename tests/{09_update_topic_events.test.ts => 09_update_channel_events.test.ts} (69%) rename tests/{10_update_topic_events_when_append_only.test.ts => 10_update_channel_events_when_append_only.test.ts} (70%) create mode 100644 utils/object_helpers.ts diff --git a/deno.json b/deno.json index 812bc80..a69554d 100644 --- a/deno.json +++ b/deno.json @@ -32,7 +32,7 @@ } }, "imports": { - "@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.1.0", + "@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.2.4", "@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0", "@andyburke/serverus": "jsr:@andyburke/serverus@^0.13.0", "@da/bcrypt": "jsr:@da/bcrypt@^1.0.1", diff --git a/deno.lock b/deno.lock index 6397343..e96cc4d 100644 --- a/deno.lock +++ b/deno.lock @@ -1,7 +1,7 @@ { "version": "5", "specifiers": { - "jsr:@andyburke/fsdb@^1.1.0": "1.1.0", + "jsr:@andyburke/fsdb@^1.2.4": "1.2.4", "jsr:@andyburke/lurid@0.2": "0.2.0", "jsr:@andyburke/serverus@0.13": "0.13.0", "jsr:@da/bcrypt@*": "1.0.1", @@ -31,8 +31,8 @@ "npm:@types/node@*": "22.15.15" }, "jsr": { - "@andyburke/fsdb@1.1.0": { - "integrity": "ad2d062672137ca96df19df032b51f1c7aa3133c973a0b86eb8eaab3b4c2d47b", + "@andyburke/fsdb@1.2.4": { + "integrity": "3437078a5627d4c72d677e41c20293a47d58a3af19eda72869a12acb011064d2", "dependencies": [ "jsr:@std/cli@^1.0.20", "jsr:@std/fs@^1.0.18", @@ -133,7 +133,7 @@ }, "workspace": { "dependencies": [ - "jsr:@andyburke/fsdb@^1.1.0", + "jsr:@andyburke/fsdb@^1.2.4", "jsr:@andyburke/lurid@0.2", "jsr:@andyburke/serverus@0.13", "jsr:@da/bcrypt@^1.0.1", diff --git a/models/channel.ts b/models/channel.ts new file mode 100644 index 0000000..cd8c011 --- /dev/null +++ b/models/channel.ts @@ -0,0 +1,91 @@ +import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; +import { FSDB_COLLECTION } from '@andyburke/fsdb'; +import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; + +/** + * @typedef {object} CHANNEL_EVENT_PERMISSIONS + * @property {string[]} read a list of user_ids with read permission for the channel events + * @property {string[]} write a list of user_ids with write permission for the channel events + */ + +/** + * @typedef {object} CHANNEL_PERMISSIONS + * @property {string[]} read a list of user_ids with read permission for the channel + * @property {string[]} write a list of user_ids with write permission for the channel + * @property {CHANNEL_EVENT_PERMISSIONS} events + */ + +/** + * @typedef {object} CHANNEL_TIMESTAMPS + * @property {string} created when the channel was created + * @property {string} updated the last time the channel was updated + * @property {string} [archived] an option time the channel was archived + */ + +/** + * CHANNEL + * + * @property {string} id - lurid (stable) + * @property {string} name - channel name (max 64 characters, unique, unstable) + * @property {string} creator_id - user id of the channel creator + * @property {CHANNEL_PERMISSIONS} permissions - permissions setup for the channel + * @property {string} [icon] - optional url for channel icon + * @property {string} [topic] - optional topic for the channel + * @property {string} [rules] - optional channel rules (Markdown/text) + * @property {string[]} [tags] - optional tags for the channel + * @property {Record} [meta] - optional metadata about the channel + * @property {CHANNEL_TIMESTAMPS} timestamps - timestamps + */ + +export type CHANNEL = { + id: string; + name: string; + creator_id: string; + permissions: { + read: string[]; + write: string[]; + events: { + read: string[]; + write: string[]; + }; + }; + icon?: string; + topic?: string; + rules?: string; + tags?: string[]; + meta?: Record; + timestamps: { + created: string; + updated: string; + archived: string | undefined; + }; +}; + +export const CHANNELS = new FSDB_COLLECTION({ + name: 'channels', + id_field: 'id', + organize: by_lurid, + indexers: { + creator_id: new FSDB_INDEXER_SYMLINKS({ + name: 'creator_id', + field: 'creator_id', + to_many: true, + organize: by_lurid + }), + + name: new FSDB_INDEXER_SYMLINKS({ + name: 'name', + get_values_to_index: (channel) => [channel.name.toLowerCase()], + organize: by_character + }), + + tags: new FSDB_INDEXER_SYMLINKS({ + name: 'tags', + get_values_to_index: (channel): string[] => { + return (channel.tags ?? []).map((tag) => tag.toLowerCase()); + }, + to_many: true, + organize: by_character + }) + } +}); diff --git a/models/event.ts b/models/event.ts index 7eefbd8..bd66ac7 100644 --- a/models/event.ts +++ b/models/event.ts @@ -1,4 +1,4 @@ -import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; +import { by_lurid } from '@andyburke/fsdb/organizers'; import { FSDB_COLLECTION } from '@andyburke/fsdb'; import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; import { EMOJIS } from '../public/js/emojis/en.ts'; @@ -16,7 +16,9 @@ import { EMOJIS } from '../public/js/emojis/en.ts'; * @property {string} creator_id - id of the source user * @property {string} type - event type * @property {string} [parent_id] - optional parent event id - * @property {string[]} [tags] - optional event tags + * @property {string} [channel] - optional channel + * @property {string} [topic] - optional topic + * @property {string[]} [tags] - optional tags * @property {Record} [data] - optional data payload of the event * @property {TIMESTAMPS} timestamps - timestamps that will be set by the server */ @@ -25,6 +27,8 @@ export type EVENT = { creator_id: string; type: string; parent_id?: string; + channel?: string; + topic?: string; tags?: string[]; data?: Record; timestamps: { @@ -33,11 +37,6 @@ export type EVENT = { }; }; -type TOPIC_EVENT_CACHE_ENTRY = { - collection: FSDB_COLLECTION; - eviction_timeout: number; -}; - // TODO: separate out these different validators somewhere? export function VALIDATE_EVENT(event: EVENT) { const errors: any[] = []; @@ -111,6 +110,8 @@ export function VALIDATE_EVENT(event: EVENT) { }); } break; + case 'presence': + break; case 'reaction': if (typeof event.parent_id !== 'string') { errors.push({ @@ -148,78 +149,76 @@ export function VALIDATE_EVENT(event: EVENT) { return errors.length ? errors : undefined; } -const TOPIC_EVENT_ID_MATCHER = /^(?.*):(?.*)$/; +const EVENT_ID_EXTRACTOR = /^(?.*):(?.*)$/; -const TOPIC_EVENTS: Record = {}; -export function get_events_collection_for_topic(topic_id: string): FSDB_COLLECTION { - TOPIC_EVENTS[topic_id] = TOPIC_EVENTS[topic_id] ?? { - collection: new FSDB_COLLECTION({ - name: `topics/${topic_id.slice(0, 14)}/${topic_id.slice(0, 34)}/${topic_id}/events`, - id_field: 'id', - organize: (id) => { - TOPIC_EVENT_ID_MATCHER.lastIndex = 0; - - const groups: Record | undefined = TOPIC_EVENT_ID_MATCHER.exec(id ?? '')?.groups; - - if (!groups) { - throw new Error('Could not parse event id: ' + id); - } - - const event_type = groups.event_type; - const event_id = groups.event_id; - - return [ - event_type, - event_id.slice(0, 14), - event_id.slice(0, 34), - event_id, - `${event_id}.json` /* TODO: this should be ${id}.json - need to write a converter */ - ]; - }, - indexers: { - creator_id: new FSDB_INDEXER_SYMLINKS({ - name: 'creator_id', - field: 'creator_id', - to_many: true, - organize: by_lurid - }), - - parent_id: new FSDB_INDEXER_SYMLINKS({ - name: 'parent_id', - field: 'parent_id', - to_many: true, - organize: by_lurid - }), - - tags: new FSDB_INDEXER_SYMLINKS({ - name: 'tags', - get_values_to_index: (event: EVENT): string[] => { - return (event.tags ?? []).map((tag: string) => tag.toLowerCase()); - }, - to_many: true, - organize: by_character - }) - } - }), - eviction_timeout: 0 - }; - - if (TOPIC_EVENTS[topic_id].eviction_timeout) { - clearTimeout(TOPIC_EVENTS[topic_id].eviction_timeout); - } - - TOPIC_EVENTS[topic_id].eviction_timeout = setTimeout(() => { - delete TOPIC_EVENTS[topic_id]; - }, 60_000 * 5); - - return TOPIC_EVENTS[topic_id].collection; +function smart_event_id_organizer(id: string) { + const [event_type, event_id] = id.split(':', 2); + const event_dirs = by_lurid(event_id).slice(0, -1); + return [event_type, ...event_dirs, `${id}.json`]; } -export function clear_topic_events_cache() { - for (const [topic_id, cached] of Object.entries(TOPIC_EVENTS)) { - if (cached.eviction_timeout) { - clearTimeout(cached.eviction_timeout); +export const EVENTS = new FSDB_COLLECTION({ + name: `events`, + id_field: 'id', + organize: (id) => { + EVENT_ID_EXTRACTOR.lastIndex = 0; + + const groups: Record | undefined = EVENT_ID_EXTRACTOR.exec(id ?? '')?.groups; + + if (!groups) { + throw new Error('Could not parse event id: ' + id); } - delete TOPIC_EVENTS[topic_id]; + + const event_type = groups.event_type; + const event_id = groups.event_id; + + return [ + event_type, + event_id.slice(0, 14), + event_id.slice(0, 34), + event_id, + `${id}.json` + ]; + }, + indexers: { + creator_id: new FSDB_INDEXER_SYMLINKS({ + name: 'creator_id', + field: 'creator_id', + to_many: true, + organize: by_lurid + }), + + parent_id: new FSDB_INDEXER_SYMLINKS({ + name: 'parent_id', + field: 'parent_id', + to_many: true, + organize: smart_event_id_organizer + }), + + channel: new FSDB_INDEXER_SYMLINKS({ + name: 'channel', + field: 'channel', + to_many: true, + organize: (channel: string) => [channel], + organize_id: smart_event_id_organizer + }), + + topic: new FSDB_INDEXER_SYMLINKS({ + name: 'topic', + field: 'topic', + to_many: true, + organize: (topic: string) => [topic], + organize_id: smart_event_id_organizer + }), + + tags: new FSDB_INDEXER_SYMLINKS({ + name: 'tags', + get_values_to_index: (event: EVENT): string[] => { + return (event.tags ?? []).map((tag: string) => tag.toLowerCase()); + }, + to_many: true, + organize: (tag: string) => tag.length > 3 ? [tag.substring(0, 3), tag] : [tag], + organize_id: smart_event_id_organizer + }) } -} +}); diff --git a/models/topic.ts b/models/topic.ts deleted file mode 100644 index fa01e66..0000000 --- a/models/topic.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; -import { FSDB_COLLECTION } from '@andyburke/fsdb'; -import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; - -/** - * @typedef {object} TOPIC_PERMISSIONS - * @property {string[]} read a list of user_ids with read permission for the topic - * @property {string[]} write a list of user_ids with write permission for the topic - * @property {string[]} read_events a list of user_ids with read_events permission for this topic - * @property {string[]} write_events a list of user_ids with write_events permission for this topic - */ - -/** - * TOPIC - * - * @property {string} id - lurid (stable) - * @property {string} name - channel name (max 64 characters, unique, unstable) - * @property {string} creator_id - user id of the topic creator - * @property {TOPIC_PERMISSIONS} permissions - permissions setup for the topic - * @property {string} [icon_url] - optional url for topic icon - * @property {string} [topic] - optional topic for the topic - * @property {string} [rules] - optional topic rules (Markdown/text) - * @property {string[]} [tags] - optional tags for the topic - * @property {Record} [meta] - optional metadata about the topic - * @property {Record} [emojis] - optional emojis table, eg: { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } - */ - -export type TOPIC = { - id: string; - name: string; - creator_id: string; - permissions: { - read: string[]; - write: string[]; - read_events: string[]; - write_events: string[]; - }; - icon_url?: string; - topic?: string; - rules?: string; - tags?: string[]; - meta?: Record; - emojis?: Record; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } - timestamps: { - created: string; - updated: string; - archived: string | undefined; - }; -}; - -export const TOPICS = new FSDB_COLLECTION({ - name: 'topics', - id_field: 'id', - organize: by_lurid, - indexers: { - creator_id: new FSDB_INDEXER_SYMLINKS({ - name: 'creator_id', - field: 'creator_id', - to_many: true, - organize: by_lurid - }), - - name: new FSDB_INDEXER_SYMLINKS({ - name: 'name', - get_values_to_index: (topic) => [topic.name.toLowerCase()], - organize: by_character - }), - - tags: new FSDB_INDEXER_SYMLINKS({ - name: 'tags', - get_values_to_index: (topic): string[] => { - return (topic.tags ?? []).map((tag) => tag.toLowerCase()); - }, - to_many: true, - organize: by_character - }), - - topic: new FSDB_INDEXER_SYMLINKS({ - name: 'topic', - get_values_to_index: (topic): string[] => { - return (topic.topic ?? '').split(/\W/); - }, - to_many: true, - organize: by_character - }) - } -}); diff --git a/models/watch.ts b/models/watch.ts index 97e13b4..2d460ec 100644 --- a/models/watch.ts +++ b/models/watch.ts @@ -2,32 +2,30 @@ import { FSDB_COLLECTION } from '@andyburke/fsdb'; import { by_lurid } from '@andyburke/fsdb/organizers'; import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; -/** - * @typedef {object} WATCH_TYPE_INFO - * @property {boolean} ignored if true, this type should NOT produce any indications or notifications - * @property {string} last_id_seen the last event id the user has seen for this event type - * @property {string} last_id_notified the last event id the user was notified about for this type - */ - -export type WATCH_TYPE_INFO = { - ignored: boolean; - last_id_seen: string; - last_id_notified: string; -}; - /** * @typedef {object} WATCH_TIMESTAMPS * @property {string} created the created date of the watch * @property {string} updated the last updated date, usually coinciding with the last seen id being changed */ +export type WATCH_TIMESTAMPS = { + created: string; + updated: string; +}; + /** * WATCH * * @property {string} id - lurid (stable) * @property {string} creator_id - user id of the watch creator - * @property {string} topic_id - the topic_id being watched - * @property {[WATCH_TYPE_INFO]} types - information for types being watched within this topic + * @property {string} [type] - a filter for event type + * @property {string} [parent_id] - a filter for event parent_id + * @property {string} [channel] - a filter for event channel + * @property {string} [topic] - a filter for event topic + * @property {string[]} [tags] - a filter for event tags + * @property {Record} [data] - a filter on event data, each leaf should be a RegExp + * @property {string} last_id_seen - the last id the user has seen for this watch + * @property {string} [last_id_notified] - the last id the user was notified about for this watch * @property {Record} [meta] - optional metadata about the watch * @property {WATCH_TIMESTAMPS} timestamps - timestamps for the watch */ @@ -35,13 +33,16 @@ export type WATCH_TYPE_INFO = { export type WATCH = { id: string; creator_id: string; - topic_id: string; - types: [WATCH_TYPE_INFO]; + type?: string; + parent_id?: string; + channel?: string; + topic?: string; + tags?: string[]; + data?: Record; + last_id_seen: string; + last_id_notified?: string; meta?: Record; - timestamps: { - created: string; - updated: string; - }; + timestamps: WATCH_TIMESTAMPS; }; export const WATCHES = new FSDB_COLLECTION({ @@ -56,11 +57,44 @@ export const WATCHES = new FSDB_COLLECTION({ organize: by_lurid }), - topic_id: new FSDB_INDEXER_SYMLINKS({ - name: 'topic_id', - field: 'topic_id', + type: new FSDB_INDEXER_SYMLINKS({ + name: 'type', + field: 'type', to_many: true, - organize: by_lurid + organize: (type: string) => [type], + organize_id: by_lurid + }), + + parent_id: new FSDB_INDEXER_SYMLINKS({ + name: 'parent_id', + field: 'parent_id', + to_many: true, + organize: by_lurid, + organize_id: by_lurid + }), + + channel: new FSDB_INDEXER_SYMLINKS({ + name: 'channel', + field: 'channel', + to_many: true, + organize: (channel: string) => channel.length > 3 ? [channel.substring(0, 3), channel] : [channel], + organize_id: by_lurid + }), + + topic: new FSDB_INDEXER_SYMLINKS({ + name: 'topic', + field: 'topic', + to_many: true, + organize: (topic: string) => topic.length > 3 ? [topic.substring(0, 3), topic] : [topic], + organize_id: by_lurid + }), + + tags: new FSDB_INDEXER_SYMLINKS({ + name: 'tags', + field: 'tags', + to_many: true, + organize: (tag: string) => tag.length > 3 ? [tag.substring(0, 3), tag] : [tag], + organize_id: by_lurid }) } }); diff --git a/public/api/channels/:channel_id/README.md b/public/api/channels/:channel_id/README.md new file mode 100644 index 0000000..f3e8787 --- /dev/null +++ b/public/api/channels/:channel_id/README.md @@ -0,0 +1,23 @@ +# /api/channels/:channel_id + +Interact with a specific channel. + +## GET /api/channels/:channel_id + +Get the channel specified by `:channel_id`. + +## PUT /api/channels/:channel_id + +Update the channels specified by `:channel_id`. + +Eg: + +``` +{ + name?: string; +} +``` + +## DELETE /api/channels/:channel_id + +Delete the channel specified by `:channel_id`. diff --git a/public/api/channels/:channel_id/events/:event_id/index.ts b/public/api/channels/:channel_id/events/:event_id/index.ts new file mode 100644 index 0000000..2759b9d --- /dev/null +++ b/public/api/channels/:channel_id/events/:event_id/index.ts @@ -0,0 +1,40 @@ +import { EVENT, EVENTS } from '../../../../../../models/event.ts'; +import { CHANNEL, CHANNELS } from '../../../../../../models/channel.ts'; +import * as CANNED_RESPONSES from '../../../../../../utils/canned_responses.ts'; +import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../../utils/prechecks.ts'; + +export const PRECHECKS: PRECHECK_TABLE = {}; + +// GET /api/channels/:channel_id/events/:id - Get an event +PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; + + if (!channel) { + return CANNED_RESPONSES.not_found(); + } + + meta.channel = channel; + const channel_is_public = channel.permissions.read.length === 0; + const user_has_read_for_channel = channel_is_public || channel.permissions.read.includes(meta.user.id); + const channel_has_public_events = user_has_read_for_channel && (channel.permissions.events.read.length === 0); + const user_has_read_events_for_channel = user_has_read_for_channel && + (channel_has_public_events || channel.permissions.events.read.includes(meta.user.id)); + + if (!user_has_read_events_for_channel) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function GET(_req: Request, meta: Record): Promise { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + return Response.json(event, { + status: 200 + }); +} diff --git a/public/api/channels/:channel_id/events/index.ts b/public/api/channels/:channel_id/events/index.ts new file mode 100644 index 0000000..1d42417 --- /dev/null +++ b/public/api/channels/:channel_id/events/index.ts @@ -0,0 +1,134 @@ +import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; +import { CHANNEL, CHANNELS } from '../../../../../models/channel.ts'; +import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts'; +import { EVENT, EVENTS } from '../../../../../models/event.ts'; +import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; + +export const PRECHECKS: PRECHECK_TABLE = {}; + +// GET /api/channels/:channel_id/events - get channel events +// query parameters: +// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted) +PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; + + if (!channel) { + return CANNED_RESPONSES.not_found(); + } + + meta.channel = channel; + + const channel_is_public: boolean = meta.channel.permissions.read.length === 0; + const user_has_read_for_channel = channel_is_public || meta.channel.permissions.read.includes(meta.user.id); + + if (!user_has_read_for_channel) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function GET(request: Request, meta: Record): Promise { + const sorts = EVENTS.sorts; + const sort_name: string = meta.query.sort ?? 'newest'; + const key = sort_name as keyof typeof sorts; + const sort: any = sorts[key]; + if (!sort) { + return Response.json({ + error: { + message: 'You must specify a sort: newest, oldest, latest, stalest', + cause: 'invalid_sort' + } + }, { + status: 400 + }); + } + + const options: FSDB_SEARCH_OPTIONS = { + ...(meta.query ?? {}), + limit: Math.min(parseInt(meta.query?.limit ?? '10', 10), 1_000), + offset: Math.max(parseInt(meta.query?.offset ?? '0', 10), 0), + sort, + filter: (entry: WALK_ENTRY) => { + const { + event_type, + event_id + } = /^.*\/(?.*?):(?[A-Za-z-]+)\.json$/.exec(entry.path)?.groups ?? {}; + + if (meta.query.after_id && event_id <= meta.query.after_id) { + return false; + } + + if (meta.query.before_id && event_id >= meta.query.before_id) { + return false; + } + + if (meta.query.type && !meta.query.type.split(',').includes(event_type)) { + return false; + } + + return true; + } + }; + + const headers = { + 'Cache-Control': 'no-cache, must-revalidate' + }; + + const results = (await EVENTS.find({ + channel: meta.channel.id + }, options)) + .map((entry: WALK_ENTRY) => entry.load()) + .sort((lhs_item: EVENT, rhs_item: EVENT) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created)); + + // long-polling support + if (results.length === 0 && meta.query.wait) { + return new Promise((resolve, reject) => { + function on_create(create_event: any) { + if (create_event.item.channel !== meta.channel.id) { + return; + } + + if (meta.query.type && !meta.query.type.split(',').includes(create_event.item.type)) { + return; + } + + results.push(create_event.item); + clearTimeout(timeout); + EVENTS.off('create', on_create); + + return resolve(Response.json(results, { + status: 200, + headers + })); + } + + const timeout = setTimeout(() => { + EVENTS.off('create', on_create); + return resolve(Response.json(results, { + status: 200, + headers + })); + }, 60_000); // 60 seconds + EVENTS.on('create', on_create); + request.signal.addEventListener('abort', () => { + EVENTS.off('create', on_create); + clearTimeout(timeout); + reject(new Error('request aborted')); + }); + Deno.addSignalListener('SIGINT', () => { + EVENTS.off('create', on_create); + clearTimeout(timeout); + return resolve(Response.json(results, { + status: 200, + headers + })); + }); + }); + } + + return Response.json(results, { + status: 200, + headers + }); +} diff --git a/public/api/topics/:topic_id/index.ts b/public/api/channels/:channel_id/index.ts similarity index 57% rename from public/api/topics/:topic_id/index.ts rename to public/api/channels/:channel_id/index.ts index dd656bc..59a9234 100644 --- a/public/api/topics/:topic_id/index.ts +++ b/public/api/channels/:channel_id/index.ts @@ -1,50 +1,50 @@ import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../utils/prechecks.ts'; import parse_body from '../../../../utils/bodyparser.ts'; import * as CANNED_RESPONSES from '../../../../utils/canned_responses.ts'; -import { TOPIC, TOPICS } from '../../../../models/topic.ts'; +import { CHANNEL, CHANNELS } from '../../../../models/channel.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; -// GET /api/topics/:id - Get a topic +// GET /api/channels/:id - Get a channel PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; - if (!topic) { + if (!channel) { return CANNED_RESPONSES.not_found(); } - meta.topic = topic; - const topic_is_public = topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || topic.permissions.read.includes(meta.user.id); + meta.channel = channel; + const channel_is_public = channel.permissions.read.length === 0; + const user_has_read_for_channel = channel_is_public || channel.permissions.read.includes(meta.user.id); - if (!user_has_read_for_topic) { + if (!user_has_read_for_channel) { return CANNED_RESPONSES.permission_denied(); } }]; export function GET(_req: Request, meta: Record): Response { - return Response.json(meta.topic, { + return Response.json(meta.channel, { status: 200 }); } -// PUT /api/topics/:id - Update topic +// PUT /api/channels/:id - Update channel PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; - if (!topic) { + if (!channel) { return CANNED_RESPONSES.not_found(); } - meta.topic = topic; - const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id); + meta.channel = channel; + const user_has_write_for_channel = channel.permissions.write.includes(meta.user.id); - if (!user_has_write_for_topic) { + if (!user_has_write_for_channel) { return CANNED_RESPONSES.permission_denied(); } }]; @@ -54,16 +54,16 @@ export async function PUT(req: Request, meta: Record): Promise): Promise): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; - if (!topic) { + if (!channel) { return CANNED_RESPONSES.not_found(); } - meta.topic = topic; - const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id); + meta.channel = channel; + const user_has_write_for_channel = channel.permissions.write.includes(meta.user.id); - if (!user_has_write_for_topic) { + if (!user_has_write_for_channel) { return CANNED_RESPONSES.permission_denied(); } } ]; export async function DELETE(_req: Request, meta: Record): Promise { - await TOPICS.delete(meta.topic); + await CHANNELS.delete(meta.channel); return Response.json({ deleted: true diff --git a/public/api/topics/:topic_id/events/:event_id/README.md b/public/api/channels/README.md similarity index 100% rename from public/api/topics/:topic_id/events/:event_id/README.md rename to public/api/channels/README.md diff --git a/public/api/topics/index.ts b/public/api/channels/index.ts similarity index 62% rename from public/api/topics/index.ts rename to public/api/channels/index.ts index a8120b7..067561e 100644 --- a/public/api/topics/index.ts +++ b/public/api/channels/index.ts @@ -3,40 +3,34 @@ import parse_body from '../../../utils/bodyparser.ts'; import { get_session, get_user, require_user } from '../../../utils/prechecks.ts'; import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts'; import { PRECHECK_TABLE } from '../../../utils/prechecks.ts'; -import { TOPIC, TOPICS } from '../../../models/topic.ts'; -import { WALK_ENTRY } from '@andyburke/fsdb'; +import { CHANNEL, CHANNELS } from '../../../models/channel.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; -// GET /api/topics - get topics +// GET /api/channels - get channels PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const can_read_topics = meta.user.permissions.includes('topics.read'); + const can_read_channels = meta.user.permissions.includes('channels.read'); - if (!can_read_topics) { + if (!can_read_channels) { return CANNED_RESPONSES.permission_denied(); } }]; export async function GET(_req: Request, meta: Record): Promise { const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100); - const topics = (await TOPICS.all({ - limit, - filter: (entry: WALK_ENTRY) => { - // we push our event collections into the topics, and fsdb - // doesn't yet filter that out in its all() logic - return entry.path.indexOf('/events/') === -1; - } + const channels = (await CHANNELS.all({ + limit })).map((topic_entry) => topic_entry.load()); - return Response.json(topics, { + return Response.json(channels, { status: 200 }); } -// POST /api/topics - Create a topic +// POST /api/channels - Create a channel PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const can_create_topics = meta.user.permissions.includes('topics.create'); + const can_create_channels = meta.user.permissions.includes('channels.create'); - if (!can_create_topics) { + if (!can_create_channels) { return CANNED_RESPONSES.permission_denied(); } }]; @@ -49,8 +43,8 @@ export async function POST(req: Request, meta: Record): Promise): Promise 64) { return Response.json({ error: { - cause: 'invalid_topic_name', - message: 'topic names must be 64 characters or fewer.' + cause: 'invalid_channel_name', + message: 'channel names must be 64 characters or fewer.' } }, { status: 400 @@ -70,29 +64,31 @@ export async function POST(req: Request, meta: Record): Promise): Promise): Promise { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + return Response.json(event, { + status: 200 + }); +} + +// PUT /api/events/:event_id - Update event +PRECHECKS.PUT = [ + get_session, + get_user, + require_user, + (_req: Request, _meta: Record): Response | undefined => { + if (Deno.env.get('APPEND_ONLY_EVENTS')) { + return CANNED_RESPONSES.append_only_events(); + } + }, + (_req: Request, meta: Record): Response | undefined => { + if (!meta.user.permissions.some((permission: string) => permission.indexOf('events.write') === 0)) { + return CANNED_RESPONSES.permission_denied(); + } + } +]; +export async function PUT(req: Request, meta: Record): Promise { + const now = new Date().toISOString(); + + try { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + if (event.creator_id !== meta.user.id) { + return CANNED_RESPONSES.permission_denied(); + } + + const body = await parse_body(req); + const updated: EVENT = { + ...event, + ...body, + id: event.id, + creator_id: event.creator_id, + channel: event.channel, + timestamps: { + created: event.timestamps.created, + updated: now + } + }; + + if (updated.channel) { + const channel: CHANNEL | null = await CHANNELS.get(updated.channel); + if (!channel) { + return Response.json({ + errors: [{ + cause: 'missing_channel', + message: 'No such channel exists.' + }] + }, { + status: 400 + }); + } + + const user_can_write_events_to_channel = channel.permissions.events.write.length === 0 ? true : channel.permissions.events.write.includes(meta.user.id); + + if (!user_can_write_events_to_channel) { + return CANNED_RESPONSES.permission_denied(); + } + } + + if (!user_has_write_permission_for_event(meta.user, updated)) { + return CANNED_RESPONSES.permission_denied(); + } + + await EVENTS.update(updated); + return Response.json(updated, { + status: 200 + }); + } catch (err) { + return Response.json({ + error: { + message: (err as Error)?.message ?? 'Unknown error due to invalid data.', + cause: (err as Error)?.cause ?? 'invalid_data' + } + }, { + status: 400 + }); + } +} + +// DELETE /api/events/:event_id - Delete event +PRECHECKS.DELETE = [ + get_session, + get_user, + require_user, + (_req: Request, _meta: Record): Response | undefined => { + if (Deno.env.get('APPEND_ONLY_EVENTS')) { + return CANNED_RESPONSES.append_only_events(); + } + }, + (_req: Request, meta: Record): Response | undefined => { + if (!meta.user.permissions.some((permission: string) => permission.indexOf('events.write') === 0)) { + return CANNED_RESPONSES.permission_denied(); + } + } +]; +export async function DELETE(_req: Request, meta: Record): Promise { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + if (event.channel) { + const channel: CHANNEL | null = await CHANNELS.get(event.channel); + if (!channel) { + return Response.json({ + errors: [{ + cause: 'missing_channel', + message: 'No such channel exists.' + }] + }, { + status: 400 + }); + } + + const user_can_write_events_to_channel = channel.permissions.events.write.length === 0 ? true : channel.permissions.events.write.includes(meta.user.id); + + if (!user_can_write_events_to_channel) { + return CANNED_RESPONSES.permission_denied(); + } + } + + if (!user_has_write_permission_for_event(meta.user, event)) { + return CANNED_RESPONSES.permission_denied(); + } + + await EVENTS.delete(event); + + return Response.json({ + deleted: true + }, { + status: 200 + }); +} diff --git a/public/api/topics/:topic_id/events/README.md b/public/api/events/README.md similarity index 100% rename from public/api/topics/:topic_id/events/README.md rename to public/api/events/README.md diff --git a/public/api/topics/:topic_id/events/index.ts b/public/api/events/index.ts similarity index 51% rename from public/api/topics/:topic_id/events/index.ts rename to public/api/events/index.ts index fe99165..ca2fe45 100644 --- a/public/api/topics/:topic_id/events/index.ts +++ b/public/api/events/index.ts @@ -1,42 +1,21 @@ import lurid from '@andyburke/lurid'; -import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; -import { TOPIC, TOPICS } from '../../../../../models/topic.ts'; -import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts'; -import { EVENT, get_events_collection_for_topic, VALIDATE_EVENT } from '../../../../../models/event.ts'; -import parse_body from '../../../../../utils/bodyparser.ts'; -import { FSDB_COLLECTION, FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; -import { WATCH, WATCHES } from '../../../../../models/watch.ts'; +import { get_session, get_user, PRECHECK_TABLE, require_user, user_has_write_permission_for_event } from '../../../utils/prechecks.ts'; +import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts'; +import { EVENT, EVENTS, VALIDATE_EVENT } from '../../../models/event.ts'; +import parse_body from '../../../utils/bodyparser.ts'; +import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; +import { WATCH, WATCHES } from '../../../models/watch.ts'; +import { flatten } from '../../../utils/object_helpers.ts'; +import { CHANNEL, CHANNELS } from '../../../models/channel.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; -// GET /api/topics/:topic_id/events - get topic events +// GET /api/events - get events // query parameters: // partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted) -PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; - - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public: boolean = meta.topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id); - const topic_events_are_public = meta.topic.permissions.read_events.length === 0; - const user_has_read_events_for_topic = user_has_read_for_topic && - (topic_events_are_public || meta.topic.permissions.read_events.includes(meta.user.id)); - - if (!user_has_read_events_for_topic) { - return CANNED_RESPONSES.permission_denied(); - } -}]; +PRECHECKS.GET = [get_session, get_user, require_user]; export async function GET(request: Request, meta: Record): Promise { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - - const sorts = events.sorts; + const sorts = EVENTS.sorts; const sort_name: string = meta.query.sort ?? 'newest'; const key = sort_name as keyof typeof sorts; const sort: any = sorts[key]; @@ -53,7 +32,7 @@ export async function GET(request: Request, meta: Record): Promise< const options: FSDB_SEARCH_OPTIONS = { ...(meta.query ?? {}), - limit: Math.min(parseInt(meta.query?.limit ?? '10', 10), 1_000), + limit: Math.min(parseInt(meta.query?.limit ?? '100', 10), 1_000), offset: Math.max(parseInt(meta.query?.offset ?? '0', 10), 0), sort, filter: (entry: WALK_ENTRY) => { @@ -82,8 +61,9 @@ export async function GET(request: Request, meta: Record): Promise< 'Cache-Control': 'no-cache, must-revalidate' }; - const results = (await events.all(options)) + const results = (await EVENTS.all(options)) .map((entry: WALK_ENTRY) => entry.load()) + .filter((event) => typeof event.channel === 'undefined') // channel events must be queried via the channel's api .sort((lhs_item: EVENT, rhs_item: EVENT) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created)); // long-polling support @@ -96,7 +76,7 @@ export async function GET(request: Request, meta: Record): Promise< results.push(create_event.item); clearTimeout(timeout); - events.off('create', on_create); + EVENTS.off('create', on_create); return resolve(Response.json(results, { status: 200, @@ -105,20 +85,20 @@ export async function GET(request: Request, meta: Record): Promise< } const timeout = setTimeout(() => { - events.off('create', on_create); + EVENTS.off('create', on_create); return resolve(Response.json(results, { status: 200, headers })); }, 60_000); // 60 seconds - events.on('create', on_create); + EVENTS.on('create', on_create); request.signal.addEventListener('abort', () => { - events.off('create', on_create); + EVENTS.off('create', on_create); clearTimeout(timeout); reject(new Error('request aborted')); }); Deno.addSignalListener('SIGINT', () => { - events.off('create', on_create); + EVENTS.off('create', on_create); clearTimeout(timeout); return resolve(Response.json(results, { status: 200, @@ -134,52 +114,78 @@ export async function GET(request: Request, meta: Record): Promise< }); } -async function update_watches(topic: TOPIC, event: EVENT) { +async function update_watches(event: EVENT) { const limit = 100; let more_to_process; let offset = 0; do { - const watches: WATCH[] = (await WATCHES.find({ - topic_id: topic.id - }, { + const watches: WATCH[] = (await WATCHES.all({ limit, offset })).map((entry) => entry.load()); - // TODO: look at the watch .types[] and send notifications + for (const watch of watches) { + if (typeof watch.type === 'string' && event.type !== watch.type) { + continue; + } + + if (typeof watch.parent_id === 'string' && event.parent_id !== watch.parent_id) { + continue; + } + + if (typeof watch.channel === 'string' && event.channel !== watch.channel) { + continue; + } + + if (typeof watch.topic === 'string' && event.topic !== watch.topic) { + continue; + } + + if (typeof watch.tags !== 'undefined' && !watch.tags.every((tag) => event.tags?.includes(tag))) { + continue; + } + + if (typeof watch.data !== 'undefined') { + const event_data = flatten(event.data ?? {}); + for (const [key, value] of Object.entries(flatten(watch.data))) { + const matcher = new RegExp(value); + if (!matcher.test(event_data[key])) { + continue; + } + } + } + + if (event.id < watch.last_id_seen) { + continue; + } + + watch.last_id_seen = event.id; + + // TODO: send a notification + console.dir({ + notification: { + watch, + event + } + }); + } offset += watches.length; more_to_process = watches.length === limit; } while (more_to_process); } -// POST /api/topics/:topic_id/events - Create an event -PRECHECKS.POST = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; +// POST /api/events - Create an event +PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { + const user_can_create_events = meta.user.permissions.some((permission: string) => permission.indexOf('events.create') === 0); - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public: boolean = meta.topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id); - const topic_events_are_publicly_writable = meta.topic.permissions.write_events.length === 0; - const user_has_write_events_for_topic = user_has_read_for_topic && - (topic_events_are_publicly_writable || meta.topic.permissions.write_events.includes(meta.user.id)); - - if (!user_has_write_events_for_topic) { + if (!user_can_create_events) { return CANNED_RESPONSES.permission_denied(); } }]; export async function POST(req: Request, meta: Record): Promise { try { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - const now = new Date().toISOString(); const body = await parse_body(req); @@ -204,7 +210,33 @@ export async function POST(req: Request, meta: Record): Promise): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; - - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public = topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || topic.permissions.read.includes(meta.user.id); - const topic_has_public_events = user_has_read_for_topic && (topic.permissions.read_events.length === 0); - const user_has_read_events_for_topic = user_has_read_for_topic && - (topic_has_public_events || topic.permissions.read_events.includes(meta.user.id)); - - if (!user_has_read_events_for_topic) { - return CANNED_RESPONSES.permission_denied(); - } -}]; -export async function GET(_req: Request, meta: Record): Promise { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - const event: EVENT | null = await events.get(meta.params.event_id); - - if (!event) { - return CANNED_RESPONSES.not_found(); - } - - return Response.json(event, { - status: 200 - }); -} - -// PUT /api/topics/:topic_id/events/:event_id - Update event -PRECHECKS.PUT = [ - get_session, - get_user, - require_user, - (_req: Request, _meta: Record): Response | undefined => { - if (Deno.env.get('APPEND_ONLY_EVENTS')) { - return CANNED_RESPONSES.append_only_events(); - } - }, - async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; - - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public: boolean = meta.topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id); - const topic_events_are_publicly_writable = meta.topic.permissions.write_events.length === 0; - const user_has_write_events_for_topic = user_has_read_for_topic && - (topic_events_are_publicly_writable || meta.topic.permissions.write_events.includes(meta.user.id)); - - if (!user_has_write_events_for_topic) { - return CANNED_RESPONSES.permission_denied(); - } - } -]; -export async function PUT(req: Request, meta: Record): Promise { - const now = new Date().toISOString(); - - try { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - const event: EVENT | null = await events.get(meta.params.event_id); - - if (!event) { - return CANNED_RESPONSES.not_found(); - } - - if (event.creator_id !== meta.user.id) { - return CANNED_RESPONSES.permission_denied(); - } - - const body = await parse_body(req); - const updated = { - ...event, - ...body, - id: event.id, - creator_id: event.creator_id, - timestamps: { - created: event.timestamps.created, - updated: now - } - }; - - await events.update(updated); - return Response.json(updated, { - status: 200 - }); - } catch (err) { - return Response.json({ - error: { - message: (err as Error)?.message ?? 'Unknown error due to invalid data.', - cause: (err as Error)?.cause ?? 'invalid_data' - } - }, { - status: 400 - }); - } -} - -// DELETE /api/topics/:topic_id/events/:event_id - Delete event -PRECHECKS.DELETE = [ - get_session, - get_user, - require_user, - (_req: Request, _meta: Record): Response | undefined => { - if (Deno.env.get('APPEND_ONLY_EVENTS')) { - return CANNED_RESPONSES.append_only_events(); - } - }, - async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; - - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public: boolean = meta.topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id); - const topic_events_are_publicly_writable = meta.topic.permissions.write_events.length === 0; - const user_has_write_events_for_topic = user_has_read_for_topic && - (topic_events_are_publicly_writable || meta.topic.permissions.write_events.includes(meta.user.id)); - - if (!user_has_write_events_for_topic) { - return CANNED_RESPONSES.permission_denied(); - } - } -]; -export async function DELETE(_req: Request, meta: Record): Promise { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - const event: EVENT | null = await events.get(meta.params.event_id); - if (!event) { - return CANNED_RESPONSES.not_found(); - } - - await events.delete(event); - - return Response.json({ - deleted: true - }, { - status: 200 - }); -} diff --git a/public/api/topics/README.md b/public/api/topics/README.md deleted file mode 100644 index 7783a6d..0000000 --- a/public/api/topics/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# /api/topics - -Interact with topics. - -## POST /api/topics - -Create a new topic. - -``` -export type TOPIC = { - id: string; // unique id for this topic - name: string; // the name of the topic (max 128 characters) - icon_url?: string; // optional url for a topic icon - topic?: string; // optional topic topic - tags: string[]; // a list of tags for the topic - meta: Record; - limits: { - users: number; - user_messages_per_minute: number; - }; - creator_id: string; // user_id of the topic creator - emojis: Record; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } -}; -``` - -## GET /api/topics - -Get topics. diff --git a/public/api/users/index.ts b/public/api/users/index.ts index 93b358a..24f426e 100644 --- a/public/api/users/index.ts +++ b/public/api/users/index.ts @@ -12,24 +12,30 @@ import { INVITE_CODE, INVITE_CODES } from '../../../models/invites.ts'; // TODO: figure out a better solution for doling out permissions const DEFAULT_USER_PERMISSIONS: string[] = [ + 'events.create.blurb', + 'events.create.chat', + 'events.create.essay', + 'events.create.post', + 'events.create.presence', + + 'events.read.blurb', + 'events.read.chat', + 'events.read.essay', + 'events.read.post', + 'events.read.presence', + + 'events.write.blurb', + 'events.write.chat', + 'events.write.essay', + 'events.write.post', + 'events.write.presence', + 'files.write.own', 'invites.create', 'invites.read.own', 'self.read', 'self.write', 'signups.read.own', - 'topics.read', - 'topics.blurbs.create', - 'topics.blurbs.read', - 'topics.blurbs.write', - 'topics.chat.write', - 'topics.chat.read', - 'topics.essays.create', - 'topics.essays.read', - 'topics.essays.write', - 'topics.posts.create', - 'topics.posts.write', - 'topics.posts.read', 'users.read', 'watches.create.own', 'watches.read.own', @@ -39,12 +45,12 @@ const DEFAULT_USER_PERMISSIONS: string[] = [ // TODO: figure out a better solution for doling out permissions const DEFAULT_SUPERUSER_PERMISSIONS: string[] = [ ...DEFAULT_USER_PERMISSIONS, + 'channels.create', + 'channels.delete', + 'channels.write', 'files.write.all', 'invites.read.all', 'signups.read.all', - 'topics.create', - 'topics.delete', - 'topics.write', 'users.write', 'watches.read.all', 'watches.write.all' diff --git a/tests/01_create_user.test.ts b/tests/01_create_user.test.ts index 9309566..fbea069 100644 --- a/tests/01_create_user.test.ts +++ b/tests/01_create_user.test.ts @@ -3,7 +3,6 @@ import * as asserts from '@std/assert'; import { USER } from '../models/user.ts'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, random_username } from './helpers.ts'; import { encodeBase64 } from '@std/encoding'; -import { generateTotp } from '../utils/totp.ts'; Deno.test({ name: 'API - USERS - Create', @@ -39,7 +38,7 @@ Deno.test({ asserts.assert(info.session); asserts.assert(info.headers); - const user = info.user; + const user: USER = info.user; asserts.assertEquals(user.username, username); diff --git a/tests/02_update_user.test.ts b/tests/02_update_user.test.ts index 240d4fc..2076649 100644 --- a/tests/02_update_user.test.ts +++ b/tests/02_update_user.test.ts @@ -2,9 +2,6 @@ import { api, API_CLIENT } from '../utils/api.ts'; import * as asserts from '@std/assert'; import { USER } from '../models/user.ts'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, random_username } from './helpers.ts'; -import { Cookie, getSetCookies } from '@std/http/cookie'; -import { encodeBase64 } from '@std/encoding'; -import { generateTotp } from '../utils/totp.ts'; Deno.test({ name: 'API - USERS - Update', diff --git a/tests/04_create_topic.test.ts b/tests/04_create_channel.test.ts similarity index 74% rename from tests/04_create_topic.test.ts rename to tests/04_create_channel.test.ts index 5b248e9..5b28f72 100644 --- a/tests/04_create_topic.test.ts +++ b/tests/04_create_channel.test.ts @@ -2,10 +2,9 @@ import { api, API_CLIENT } from '../utils/api.ts'; import * as asserts from '@std/assert'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts'; import { generateTotp } from '../utils/totp.ts'; -import { clear_topic_events_cache } from '../models/event.ts'; Deno.test({ - name: 'API - TOPICS - Create', + name: 'API - CHANNELS - Create', permissions: { env: true, read: true, @@ -25,18 +24,18 @@ Deno.test({ const root_user_info = await get_new_user(client); try { - const root_user_topic = await client.fetch('/topics', { + const root_user_channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': root_user_info.session.id, 'x-totp': await generateTotp(root_user_info.session.secret) }, json: { - name: 'this is the root user topic' + name: 'this is the root user channel' } }); - asserts.assert(root_user_topic); + asserts.assert(root_user_channel); } catch (error) { const reason: string = (error as Error).cause as string ?? (error as Error).toString(); asserts.fail(reason); @@ -45,7 +44,7 @@ Deno.test({ const regular_user_info = await get_new_user(client, {}, root_user_info); try { - const _permission_denied_topic = await client.fetch('/topics', { + const _permission_denied_channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': regular_user_info.session.id, @@ -56,15 +55,15 @@ Deno.test({ } }); - asserts.fail('allowed creation of a topic without topic creation permissions'); + asserts.fail('allowed creation of a channel without channel creation permissions'); } catch (error) { asserts.assertEquals((error as Error).cause, 'permission_denied'); } - await set_user_permissions(client, regular_user_info.user, regular_user_info.session, [...regular_user_info.user.permissions, 'topics.create']); + await set_user_permissions(client, regular_user_info.user, regular_user_info.session, [...regular_user_info.user.permissions, 'channels.create']); try { - const _too_long_name_topic = await client.fetch('/topics', { + const _too_long_name_channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': regular_user_info.session.id, @@ -75,28 +74,27 @@ Deno.test({ } }); - asserts.fail('allowed creation of a topic with an excessively long name'); + asserts.fail('allowed creation of a channel with an excessively long name'); } catch (error) { - asserts.assertEquals((error as Error).cause, 'invalid_topic_name'); + asserts.assertEquals((error as Error).cause, 'invalid_channel_name'); } - const new_topic = await client.fetch('/topics', { + const new_channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': regular_user_info.session.id, 'x-totp': await generateTotp(regular_user_info.session.secret) }, json: { - name: 'test topic' + name: 'test channel' } }); - asserts.assert(new_topic); + asserts.assert(new_channel); await delete_user(client, regular_user_info); await delete_user(client, root_user_info); } finally { - clear_topic_events_cache(); if (test_server_info) { await test_server_info?.server?.stop(); } diff --git a/tests/05_update_topic.test.ts b/tests/05_update_channel.test.ts similarity index 59% rename from tests/05_update_topic.test.ts rename to tests/05_update_channel.test.ts index e258aed..e0ea8c0 100644 --- a/tests/05_update_topic.test.ts +++ b/tests/05_update_channel.test.ts @@ -2,10 +2,9 @@ import { api, API_CLIENT } from '../utils/api.ts'; import * as asserts from '@std/assert'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts'; import { generateTotp } from '../utils/totp.ts'; -import { clear_topic_events_cache } from '../models/event.ts'; Deno.test({ - name: 'API - TOPICS - Update', + name: 'API - CHANNELS - Update', permissions: { env: true, read: true, @@ -24,25 +23,25 @@ Deno.test({ const info = await get_new_user(client); - await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'topics.create']); + await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'channels.create']); - const new_topic = await client.fetch('/topics', { + const new_channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': info.session.id, 'x-totp': await generateTotp(info.session.secret) }, json: { - name: 'test update topic' + name: 'test update channel' } }); - asserts.assert(new_topic); + asserts.assert(new_channel); const other_user_info = await get_new_user(client, {}, info); try { - const _permission_denied_topic = await client.fetch(`/topics/${new_topic.id}`, { + const _permission_denied_channel = await client.fetch(`/channels/${new_channel.id}`, { method: 'PUT', headers: { 'x-session_id': other_user_info.session.id, @@ -53,49 +52,48 @@ Deno.test({ } }); - asserts.fail('allowed updating a topic owned by someone else'); + asserts.fail('allowed updating a channel owned by someone else'); } catch (error) { asserts.assertEquals((error as Error).cause, 'permission_denied'); } - const updated_by_owner_topic = await client.fetch(`/topics/${new_topic.id}`, { + const updated_by_owner_channel = await client.fetch(`/channels/${new_channel.id}`, { method: 'PUT', headers: { 'x-session_id': info.session.id, 'x-totp': await generateTotp(info.session.secret) }, json: { - topic: 'this is a new topic', + channel: 'this is a new channel', permissions: { - ...new_topic.permissions, - write: [...new_topic.permissions.write, other_user_info.user.id] + ...new_channel.permissions, + write: [...new_channel.permissions.write, other_user_info.user.id] } } }); - asserts.assert(updated_by_owner_topic); - asserts.assertEquals(updated_by_owner_topic.topic, 'this is a new topic'); - asserts.assertEquals(updated_by_owner_topic.permissions.write, [info.user.id, other_user_info.user.id]); + asserts.assert(updated_by_owner_channel); + asserts.assertEquals(updated_by_owner_channel.channel, 'this is a new channel'); + asserts.assertEquals(updated_by_owner_channel.permissions.write, [info.user.id, other_user_info.user.id]); - const updated_by_other_user_topic = await client.fetch(`/topics/${new_topic.id}`, { + const updated_by_other_user_channel = await client.fetch(`/channels/${new_channel.id}`, { method: 'PUT', headers: { 'x-session_id': other_user_info.session.id, 'x-totp': await generateTotp(other_user_info.session.secret) }, json: { - topic: 'this is a newer topic' + channel: 'this is a newer channel' } }); - asserts.assert(updated_by_other_user_topic); - asserts.assertEquals(updated_by_other_user_topic.topic, 'this is a newer topic'); - asserts.assertEquals(updated_by_other_user_topic.permissions.write, [info.user.id, other_user_info.user.id]); + asserts.assert(updated_by_other_user_channel); + asserts.assertEquals(updated_by_other_user_channel.channel, 'this is a newer channel'); + asserts.assertEquals(updated_by_other_user_channel.permissions.write, [info.user.id, other_user_info.user.id]); await delete_user(client, other_user_info); await delete_user(client, info); } finally { - clear_topic_events_cache(); if (test_server_info) { await test_server_info?.server?.stop(); } diff --git a/tests/06_delete_topic.test.ts b/tests/06_delete_channel.test.ts similarity index 75% rename from tests/06_delete_topic.test.ts rename to tests/06_delete_channel.test.ts index 46e6014..37e8cd4 100644 --- a/tests/06_delete_topic.test.ts +++ b/tests/06_delete_channel.test.ts @@ -2,10 +2,9 @@ import { api, API_CLIENT } from '../utils/api.ts'; import * as asserts from '@std/assert'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts'; import { generateTotp } from '../utils/totp.ts'; -import { clear_topic_events_cache } from '../models/event.ts'; Deno.test({ - name: 'API - TOPICS - Delete', + name: 'API - CHANNELS - Delete', permissions: { env: true, read: true, @@ -24,22 +23,22 @@ Deno.test({ const info = await get_new_user(client); - await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'topics.create']); + await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'channels.create']); - const new_topic = await client.fetch('/topics', { + const new_channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': info.session.id, 'x-totp': await generateTotp(info.session.secret) }, json: { - name: 'test delete topic' + name: 'test delete channel' } }); - asserts.assert(new_topic); + asserts.assert(new_channel); - const deleted_topic = await client.fetch(`/topics/${new_topic.id}`, { + const deleted_channel = await client.fetch(`/channels/${new_channel.id}`, { method: 'DELETE', headers: { 'x-session_id': info.session.id, @@ -47,11 +46,10 @@ Deno.test({ } }); - asserts.assert(deleted_topic); + asserts.assert(deleted_channel); await delete_user(client, info); } finally { - clear_topic_events_cache(); if (test_server_info) { await test_server_info?.server?.stop(); } diff --git a/tests/07_create_topic_events.test.ts b/tests/07_create_channel_events.test.ts similarity index 70% rename from tests/07_create_topic_events.test.ts rename to tests/07_create_channel_events.test.ts index 84eaeda..63f4469 100644 --- a/tests/07_create_topic_events.test.ts +++ b/tests/07_create_channel_events.test.ts @@ -2,10 +2,9 @@ import * as asserts from '@std/assert'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts'; import { api, API_CLIENT } from '../utils/api.ts'; import { generateTotp } from '../utils/totp.ts'; -import { clear_topic_events_cache } from '../models/event.ts'; Deno.test({ - name: 'API - TOPICS - EVENTS - Create', + name: 'API - CHANNELS - EVENTS - Create', permissions: { env: true, read: true, @@ -24,25 +23,27 @@ Deno.test({ const owner_info = await get_new_user(client); - await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']); + await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'channels.create']); - const topic = await client.fetch('/topics', { + const channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': owner_info.session.id, 'x-totp': await generateTotp(owner_info.session.secret) }, json: { - name: 'test events topic', + name: 'test events channel', permissions: { - write_events: [owner_info.user.id] + events: { + write: [owner_info.user.id] + } } } }); - asserts.assert(topic); + asserts.assert(channel); - const event_from_owner = await client.fetch(`/topics/${topic.id}/events`, { + const event_from_owner = await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': owner_info.session.id, @@ -50,6 +51,7 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { foo: 'bar' } @@ -61,7 +63,7 @@ Deno.test({ const other_user_info = await get_new_user(client, {}, owner_info); try { - const _permission_denied_topic = await client.fetch(`/topics/${topic.id}/events`, { + const _permission_denied_channel = await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': other_user_info.session.id, @@ -69,19 +71,20 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { other_user: true } } }); - asserts.fail('allowed adding an event to a topic without permission'); + asserts.fail('allowed adding an event to a channel without permission'); } catch (error) { asserts.assertEquals((error as Error).cause, 'permission_denied'); } - // make the topic public write - const updated_by_owner_topic = await client.fetch(`/topics/${topic.id}`, { + // make the channel public write + const updated_by_owner_channel = await client.fetch(`/channels/${channel.id}`, { method: 'PUT', headers: { 'x-session_id': owner_info.session.id, @@ -89,16 +92,18 @@ Deno.test({ }, json: { permissions: { - ...topic.permissions, - write_events: [] + ...channel.permissions, + events: { + write: [] + } } } }); - asserts.assert(updated_by_owner_topic); - asserts.assertEquals(updated_by_owner_topic.permissions.write_events, []); + asserts.assert(updated_by_owner_channel); + asserts.assertEquals(updated_by_owner_channel.permissions.events.write, []); - const event_from_other_user = await client.fetch(`/topics/${topic.id}/events`, { + const event_from_other_user = await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': other_user_info.session.id, @@ -106,6 +111,7 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { other_user: true } @@ -117,7 +123,6 @@ Deno.test({ await delete_user(client, other_user_info); await delete_user(client, owner_info); } finally { - clear_topic_events_cache(); if (test_server_info) { await test_server_info?.server?.stop(); } diff --git a/tests/08_get_topic_events.test.ts b/tests/08_get_channel_events.test.ts similarity index 82% rename from tests/08_get_topic_events.test.ts rename to tests/08_get_channel_events.test.ts index d9ce1ff..13e4749 100644 --- a/tests/08_get_topic_events.test.ts +++ b/tests/08_get_channel_events.test.ts @@ -2,10 +2,9 @@ import * as asserts from '@std/assert'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts'; import { api, API_CLIENT } from '../utils/api.ts'; import { generateTotp } from '../utils/totp.ts'; -import { clear_topic_events_cache } from '../models/event.ts'; Deno.test({ - name: 'API - TOPICS - EVENTS - Get', + name: 'API - CHANNELS - EVENTS - Get', permissions: { env: true, read: true, @@ -29,25 +28,25 @@ Deno.test({ const owner_info = await get_new_user(client); - await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']); + await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'channels.create']); - const topic = await client.fetch('/topics', { + const channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': owner_info.session.id, 'x-totp': await generateTotp(owner_info.session.secret) }, json: { - name: 'test get events topic' + name: 'test get events channel' } }); - asserts.assert(topic); + asserts.assert(channel); const NUM_INITIAL_EVENTS = 5; const events_initial_batch: any[] = []; for (let i = 0; i < NUM_INITIAL_EVENTS; ++i) { - const event = await client.fetch(`/topics/${topic.id}/events`, { + const event = await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': owner_info.session.id, @@ -55,6 +54,7 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { i } @@ -69,7 +69,7 @@ Deno.test({ const other_user_info = await get_new_user(client, {}, owner_info); - const events_from_server = await client.fetch(`/topics/${topic.id}/events`, { + const events_from_server = await client.fetch(`/channels/${channel.id}/events`, { method: 'GET', headers: { 'x-session_id': other_user_info.session.id, @@ -82,7 +82,7 @@ Deno.test({ const newest_event = events_from_server[0]; asserts.assert(newest_event); - const long_poll_request_promise = client.fetch(`/topics/${topic.id}/events?wait=true&after_id=${newest_event.id}`, { + const long_poll_request_promise = client.fetch(`/channels/${channel.id}/events?wait=true&after_id=${newest_event.id.split(':', 2)[1]}`, { method: 'GET', headers: { 'x-session_id': other_user_info.session.id, @@ -92,7 +92,7 @@ Deno.test({ const wait_and_then_create_an_event = new Promise((resolve) => { setTimeout(async () => { - await client.fetch(`/topics/${topic.id}/events`, { + await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': owner_info.session.id, @@ -100,6 +100,7 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { i: 12345 } @@ -120,7 +121,6 @@ Deno.test({ await delete_user(client, other_user_info); await delete_user(client, owner_info); } finally { - clear_topic_events_cache(); if (test_server_info) { await test_server_info.server.stop(); } diff --git a/tests/09_update_topic_events.test.ts b/tests/09_update_channel_events.test.ts similarity index 69% rename from tests/09_update_topic_events.test.ts rename to tests/09_update_channel_events.test.ts index 9c26058..f67d104 100644 --- a/tests/09_update_topic_events.test.ts +++ b/tests/09_update_channel_events.test.ts @@ -2,10 +2,9 @@ import * as asserts from '@std/assert'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts'; import { api, API_CLIENT } from '../utils/api.ts'; import { generateTotp } from '../utils/totp.ts'; -import { clear_topic_events_cache } from '../models/event.ts'; Deno.test({ - name: 'API - TOPICS - EVENTS - Update', + name: 'API - CHANNELS - EVENTS - Update', permissions: { env: true, read: true, @@ -24,22 +23,22 @@ Deno.test({ const owner_info = await get_new_user(client); - await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']); + await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'channels.create']); - const topic = await client.fetch('/topics', { + const channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': owner_info.session.id, 'x-totp': await generateTotp(owner_info.session.secret) }, json: { - name: 'test update events topic' + name: 'test update events channel' } }); - asserts.assert(topic); + asserts.assert(channel); - const event_from_owner = await client.fetch(`/topics/${topic.id}/events`, { + const event_from_owner = await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': owner_info.session.id, @@ -47,6 +46,7 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { foo: 'bar' } @@ -55,7 +55,7 @@ Deno.test({ asserts.assert(event_from_owner); - const fetched_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { + const fetched_event_from_owner = await client.fetch(`/channels/${channel.id}/events/${event_from_owner.id}`, { method: 'GET', headers: { 'x-session_id': owner_info.session.id, @@ -65,25 +65,23 @@ Deno.test({ asserts.assertEquals(fetched_event_from_owner, event_from_owner); - const updated_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { + const updated_event_from_owner = await client.fetch(`/events/${event_from_owner.id}`, { method: 'PUT', headers: { 'x-session_id': owner_info.session.id, 'x-totp': await generateTotp(owner_info.session.secret) }, json: { - type: 'other', - data: { + meta: { foo: 'baz' } } }); asserts.assertNotEquals(updated_event_from_owner, event_from_owner); - asserts.assertEquals(updated_event_from_owner.type, 'other'); - asserts.assertEquals(updated_event_from_owner.data.foo, 'baz'); + asserts.assertEquals(updated_event_from_owner.meta?.foo, 'baz'); - const fetched_updated_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { + const fetched_updated_event_from_owner = await client.fetch(`/channels/${channel.id}/events/${event_from_owner.id}`, { method: 'GET', headers: { 'x-session_id': owner_info.session.id, @@ -97,7 +95,7 @@ Deno.test({ const other_user_info = await get_new_user(client, {}, owner_info); - const event_from_other_user = await client.fetch(`/topics/${topic.id}/events`, { + const event_from_other_user = await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': other_user_info.session.id, @@ -105,6 +103,7 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { other_user: true } @@ -113,7 +112,7 @@ Deno.test({ asserts.assert(event_from_other_user); - const fetched_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + const fetched_event_from_other_user = await client.fetch(`/channels/${channel.id}/events/${event_from_other_user.id}`, { method: 'GET', headers: { 'x-session_id': other_user_info.session.id, @@ -123,14 +122,13 @@ Deno.test({ asserts.assertEquals(fetched_event_from_other_user, event_from_other_user); - const updated_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + const updated_event_from_other_user = await client.fetch(`/events/${event_from_other_user.id}`, { method: 'PUT', headers: { 'x-session_id': other_user_info.session.id, 'x-totp': await generateTotp(other_user_info.session.secret) }, json: { - type: 'other', data: { other_user: 'bloop' } @@ -138,10 +136,9 @@ Deno.test({ }); asserts.assertNotEquals(updated_event_from_other_user, event_from_other_user); - asserts.assertEquals(updated_event_from_other_user.type, 'other'); asserts.assertEquals(updated_event_from_other_user.data.other_user, 'bloop'); - const fetched_updated_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + const fetched_updated_event_from_other_user = await client.fetch(`/channels/${channel.id}/events/${event_from_other_user.id}`, { method: 'GET', headers: { 'x-session_id': other_user_info.session.id, @@ -153,7 +150,7 @@ Deno.test({ asserts.assertNotEquals(fetched_updated_event_from_other_user, fetched_event_from_other_user); asserts.assertEquals(fetched_updated_event_from_other_user, updated_event_from_other_user); - const updated_by_owner_topic = await client.fetch(`/topics/${topic.id}`, { + const updated_by_owner_channel = await client.fetch(`/channels/${channel.id}`, { method: 'PUT', headers: { 'x-session_id': owner_info.session.id, @@ -161,33 +158,38 @@ Deno.test({ }, json: { permissions: { - ...topic.permissions, - write_events: [owner_info.user.id] + ...channel.permissions, + events: { + read: [], + write: [owner_info.user.id] + } } } }); - asserts.assertEquals(updated_by_owner_topic.permissions.write_events, [owner_info.user.id]); + asserts.assertEquals(updated_by_owner_channel.permissions.events.write, [owner_info.user.id]); try { - await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + await client.fetch(`/events/${event_from_other_user.id}`, { method: 'PUT', headers: { 'x-session_id': other_user_info.session.id, 'x-totp': await generateTotp(other_user_info.session.secret) }, json: { - type: 'new' + data: { + other_user: 'glop' + } } }); - asserts.fail('allowed updating an event in a topic with a write_events allowed only by owner'); + asserts.fail('allowed updating an event in a channel with a events.write allowed only by owner'); } catch (error) { asserts.assertEquals((error as Error).cause, 'permission_denied'); } try { - await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + await client.fetch(`/events/${event_from_other_user.id}`, { method: 'DELETE', headers: { 'x-session_id': other_user_info.session.id, @@ -195,12 +197,12 @@ Deno.test({ } }); - asserts.fail('allowed deleting an event in a topic with a write_events allowed only by owner'); + asserts.fail('allowed deleting an event in a channel with a events.write allowed only by owner'); } catch (error) { asserts.assertEquals((error as Error).cause, 'permission_denied'); } - const publicly_writable_topic = await client.fetch(`/topics/${topic.id}`, { + const publicly_writable_channel = await client.fetch(`/channels/${channel.id}`, { method: 'PUT', headers: { 'x-session_id': owner_info.session.id, @@ -208,15 +210,18 @@ Deno.test({ }, json: { permissions: { - ...topic.permissions, - write_events: [] + ...channel.permissions, + events: { + read: [], + write: [] + } } } }); - asserts.assertEquals(publicly_writable_topic.permissions.write_events, []); + asserts.assertEquals(publicly_writable_channel.permissions.events.write, []); - const delete_other_user_event_response = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + const delete_other_user_event_response = await client.fetch(`/events/${event_from_other_user.id}`, { method: 'DELETE', headers: { 'x-session_id': other_user_info.session.id, @@ -226,7 +231,7 @@ Deno.test({ asserts.assertEquals(delete_other_user_event_response.deleted, true); - const delete_owner_event_response = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { + const delete_owner_event_response = await client.fetch(`/events/${event_from_owner.id}`, { method: 'DELETE', headers: { 'x-session_id': owner_info.session.id, @@ -239,7 +244,6 @@ Deno.test({ await delete_user(client, other_user_info); await delete_user(client, owner_info); } finally { - clear_topic_events_cache(); if (test_server_info) { await test_server_info?.server?.stop(); } diff --git a/tests/10_update_topic_events_when_append_only.test.ts b/tests/10_update_channel_events_when_append_only.test.ts similarity index 70% rename from tests/10_update_topic_events_when_append_only.test.ts rename to tests/10_update_channel_events_when_append_only.test.ts index 3ce4024..3d46add 100644 --- a/tests/10_update_topic_events_when_append_only.test.ts +++ b/tests/10_update_channel_events_when_append_only.test.ts @@ -2,10 +2,9 @@ import * as asserts from '@std/assert'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts'; import { api, API_CLIENT } from '../utils/api.ts'; import { generateTotp } from '../utils/totp.ts'; -import { clear_topic_events_cache } from '../models/event.ts'; Deno.test({ - name: 'API - TOPICS - EVENTS - Update (APPEND_ONLY_EVENTS)', + name: 'API - CHANNELS - EVENTS - Update (APPEND_ONLY_EVENTS)', permissions: { env: true, read: true, @@ -27,22 +26,22 @@ Deno.test({ const owner_info = await get_new_user(client); - await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']); + await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'channels.create']); - const topic = await client.fetch('/topics', { + const channel = await client.fetch('/channels', { method: 'POST', headers: { 'x-session_id': owner_info.session.id, 'x-totp': await generateTotp(owner_info.session.secret) }, json: { - name: 'test update events topic in append only mode' + name: 'test update events channel in append only mode' } }); - asserts.assert(topic); + asserts.assert(channel); - const event_from_owner = await client.fetch(`/topics/${topic.id}/events`, { + const event_from_owner = await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': owner_info.session.id, @@ -50,6 +49,7 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { foo: 'bar' } @@ -58,7 +58,7 @@ Deno.test({ asserts.assert(event_from_owner); - const fetched_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { + const fetched_event_from_owner = await client.fetch(`/channels/${channel.id}/events/${event_from_owner.id}`, { method: 'GET', headers: { 'x-session_id': owner_info.session.id, @@ -69,24 +69,26 @@ Deno.test({ asserts.assertEquals(fetched_event_from_owner, event_from_owner); try { - await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { + await client.fetch(`/events/${event_from_owner.id}`, { method: 'PUT', headers: { 'x-session_id': owner_info.session.id, 'x-totp': await generateTotp(owner_info.session.secret) }, json: { - type: 'new' + meta: { + foo: 'bar' + } } }); - asserts.fail('allowed updating an event in a topic with APPEND_ONLY_EVENTS on'); + asserts.fail('allowed updating an event in a channel with APPEND_ONLY_EVENTS on'); } catch (error) { asserts.assertEquals((error as Error).cause, 'append_only_events'); } try { - await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { + await client.fetch(`/events/${event_from_owner.id}`, { method: 'DELETE', headers: { 'x-session_id': owner_info.session.id, @@ -94,14 +96,14 @@ Deno.test({ } }); - asserts.fail('allowed deleting an event in a topic with APPEND_ONLY_EVENTS on'); + asserts.fail('allowed deleting an event in a channel with APPEND_ONLY_EVENTS on'); } catch (error) { asserts.assertEquals((error as Error).cause, 'append_only_events'); } const other_user_info = await get_new_user(client, {}, owner_info); - const event_from_other_user = await client.fetch(`/topics/${topic.id}/events`, { + const event_from_other_user = await client.fetch(`/events`, { method: 'POST', headers: { 'x-session_id': other_user_info.session.id, @@ -109,6 +111,7 @@ Deno.test({ }, json: { type: 'test', + channel: channel.id, data: { other_user: true } @@ -117,7 +120,7 @@ Deno.test({ asserts.assert(event_from_other_user); - const fetched_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + const fetched_event_from_other_user = await client.fetch(`/channels/${channel.id}/events/${event_from_other_user.id}`, { method: 'GET', headers: { 'x-session_id': other_user_info.session.id, @@ -128,24 +131,26 @@ Deno.test({ asserts.assertEquals(fetched_event_from_other_user, event_from_other_user); try { - await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + await client.fetch(`/events/${event_from_other_user.id}`, { method: 'PUT', headers: { 'x-session_id': other_user_info.session.id, 'x-totp': await generateTotp(other_user_info.session.secret) }, json: { - type: 'new' + meta: { + foo: 'bar' + } } }); - asserts.fail('allowed updating an event in a topic with APPEND_ONLY_EVENTS on'); + asserts.fail('allowed updating an event in a channel with APPEND_ONLY_EVENTS on'); } catch (error) { asserts.assertEquals((error as Error).cause, 'append_only_events'); } try { - await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { + await client.fetch(`/events/${event_from_other_user.id}`, { method: 'DELETE', headers: { 'x-session_id': other_user_info.session.id, @@ -153,7 +158,7 @@ Deno.test({ } }); - asserts.fail('allowed deleting an event in a topic with APPEND_ONLY_EVENTS on'); + asserts.fail('allowed deleting an event in a channel with APPEND_ONLY_EVENTS on'); } catch (error) { asserts.assertEquals((error as Error).cause, 'append_only_events'); } @@ -163,7 +168,6 @@ Deno.test({ } finally { Deno.env.delete('APPEND_ONLY_EVENTS'); - clear_topic_events_cache(); if (test_server_info) { await test_server_info?.server?.stop(); } diff --git a/utils/object_helpers.ts b/utils/object_helpers.ts new file mode 100644 index 0000000..5566375 --- /dev/null +++ b/utils/object_helpers.ts @@ -0,0 +1,30 @@ +export function flatten(obj: Record, path?: string, result?: Record) { + result = result ?? {}; + + for (const [key, value] of Object.entries(obj)) { + if (typeof value === 'object') { + flatten(value, (path ?? '') + key + '.', result); + } else { + result[(path ?? '') + key] = value; + } + } + + return result; +} + +export function expand(obj: Record) { + const result: Record = {}; + + for (const [key, value] of Object.entries(obj)) { + const elements = key.split('.'); + + let current = result; + for (const element of elements.slice(0, elements.length - 1)) { + current[element] = current[element] ?? {}; + current = current[element]; + } + current[elements[elements.length - 1]] = value; + } + + return result; +} diff --git a/utils/prechecks.ts b/utils/prechecks.ts index 3d0f82d..2c910b9 100644 --- a/utils/prechecks.ts +++ b/utils/prechecks.ts @@ -1,8 +1,9 @@ import { getCookies } from '@std/http/cookie'; import { SESSIONS } from '../models/session.ts'; import { verifyTotp } from './totp.ts'; -import { USERS } from '../models/user.ts'; +import { USER, USERS } from '../models/user.ts'; import * as CANNED_RESPONSES from './canned_responses.ts'; +import { EVENT } from '../models/event.ts'; export type PRECHECK = (req: Request, meta: Record) => Promise | Response | undefined; export type PRECHECK_TABLE = Record; @@ -41,3 +42,7 @@ export function require_user( return CANNED_RESPONSES.permission_denied(); } } + +export function user_has_write_permission_for_event(user: USER, event: EVENT) { + return user.permissions.includes('events.create.' + event.type) || (Deno.env.get('DENO_ENV') === 'test' && event.type === 'test'); +}