Compare commits

..

12 commits

Author SHA1 Message Date
9ab3950502 feature: add qr codes to the invite popup
feature: starting work on notifications
fix: some styling updates
2026-02-04 11:28:00 -08:00
7b3494cc32 feature: remember if someone has logged in and default to a login screen
fix: some includes needed updating
feature: some more overrides
2026-01-30 18:39:03 -08:00
ebf0e4428e feature: allow overriding signup pitch 2026-01-27 00:18:55 -08:00
6637927e20 fix: load a chat channel on login/load 2026-01-21 15:40:13 -08:00
32ed2dfd33 feature: add a map 2026-01-15 20:53:57 -08:00
19afb7f9fa fix: fix 404s from templates trying to load bad image urls 2025-11-09 13:33:14 -08:00
0bc303a762 fix: more navigation and chat fixes 2025-11-09 13:16:49 -08:00
de77f0fbe9 fix: get channel creation and chat to sort of work 2025-11-09 13:01:56 -08:00
afeb6f75e8 refactor: first pass on getting the client back into working order
(still broken, but loading as a baseline)
2025-11-08 17:15:26 -08:00
a5707e2f81 refactor: events to a pure stream instead of being part of topics
NOTE: tests are passing, but the client is broken
2025-11-08 11:55:57 -08:00
c34069066d fix: clean up fixes after APP overhaul 2025-10-25 19:44:07 -07:00
52f46207ec Merge pull request 'Add vscode dir to gitignore, fix light mode css, add SU default perms' (#1) from tim/autonomous.contact:tr-fixes into dev
Reviewed-on: andyburke/autonomous.contact#1
2025-10-25 19:00:35 -07:00
84 changed files with 31467 additions and 1387 deletions

4
.gitignore vendored
View file

@ -1,4 +1,4 @@
data/ data/
.fsdb .fsdb*
public/files/* public/files/*
.vscode/* .vscode/*

View file

@ -1,13 +1,13 @@
# autonomous.contact # autonomous.contact
Bringing the BBS back. A hub for communities as a single service with no required external dependencies.
## TODO ## TODO
These are in no particular order. Pull requests updating this section welcome for These are in no particular order. Pull requests updating this section welcome for
feature discussions. feature discussions.
- [X] should everything be an event in a topic? - [X] the core is a stream of events
- [X] get a first-pass podman/docker setup up - [X] get a first-pass podman/docker setup up
- [X] sign up - [X] sign up
- [X] check for logged in user session - [X] check for logged in user session
@ -21,14 +21,14 @@ feature discussions.
- [X] logout button - [X] logout button
- [ ] profile editing - [ ] profile editing
- [X] avatar uploads - [X] avatar uploads
- [X] chat topics - [X] chat channels
- [X] chat messages - [X] chat messages
- [ ] membership and presence - [ ] membership and presence
- [ ] add memberships to topics - [ ] add memberships to channels
- [ ] join to get notifications - [ ] join to get notifications
- [ ] join for additional permissions - [ ] join for additional permissions
- [ ] filters for allowing joining a topic based on criteria on the user? - [ ] filters for allowing joining a channel based on criteria on the user?
- [ ] display topic members somehwere - [ ] display channel members somehwere
- [ ] emit presence events on join/leave - [ ] emit presence events on join/leave
- [ ] display user presence - [ ] display user presence
- [ ] chat message actions - [ ] chat message actions
@ -88,7 +88,7 @@ feature discussions.
- [ ] if web notifications are enabled, emit on events - [ ] if web notifications are enabled, emit on events
- [ ] ability to mute - [ ] ability to mute
- [ ] users - [ ] users
- [ ] topics - [ ] channels
- [ ] tags (#tags?) - [ ] tags (#tags?)
- [ ] admin panel - [ ] admin panel
- [ ] add invite code generation - [ ] add invite code generation

View file

@ -11,11 +11,15 @@
"test": "DENO_ENV=test FSDB_ROOT=$PWD/tests/data/$(date --iso-8601=seconds) SERVERUS_ROOT=$PWD/public SERVERUS_PUT_PATHS_ALLOWED=./files SERVERUS_DELETE_PATHS_ALLOWED=./files deno test --allow-env --allow-read --allow-write --allow-net --allow-import --trace-leaks --fail-fast tests/" "test": "DENO_ENV=test FSDB_ROOT=$PWD/tests/data/$(date --iso-8601=seconds) SERVERUS_ROOT=$PWD/public SERVERUS_PUT_PATHS_ALLOWED=./files SERVERUS_DELETE_PATHS_ALLOWED=./files deno test --allow-env --allow-read --allow-write --allow-net --allow-import --trace-leaks --fail-fast tests/"
}, },
"test": { "test": {
"exclude": ["tests/data/"] "exclude": [
"tests/data/"
]
}, },
"compilerOptions": {}, "compilerOptions": {},
"fmt": { "fmt": {
"include": ["**/*.ts"], "include": [
"**/*.ts"
],
"options": { "options": {
"useTabs": true, "useTabs": true,
"lineWidth": 180, "lineWidth": 180,
@ -25,22 +29,28 @@
} }
}, },
"lint": { "lint": {
"include": ["**/*.ts"], "include": [
"**/*.ts"
],
"rules": { "rules": {
"tags": ["recommended"], "tags": [
"exclude": ["no-explicit-any"] "recommended"
],
"exclude": [
"no-explicit-any"
]
} }
}, },
"imports": { "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/lurid": "jsr:@andyburke/lurid@^0.2.0",
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.13.0", "@andyburke/serverus": "jsr:@andyburke/serverus@^0.16.0",
"@da/bcrypt": "jsr:@da/bcrypt@^1.0.1", "@da/bcrypt": "jsr:@da/bcrypt@^1.0.1",
"@std/assert": "jsr:@std/assert@^1.0.15", "@std/assert": "jsr:@std/assert@^1.0.17",
"@std/encoding": "jsr:@std/encoding@^1.0.10", "@std/encoding": "jsr:@std/encoding@^1.0.10",
"@std/fs": "jsr:@std/fs@^1.0.19", "@std/fs": "jsr:@std/fs@^1.0.22",
"@std/http": "jsr:@std/http@^1.0.21", "@std/http": "jsr:@std/http@^1.0.23",
"@std/media-types": "jsr:@std/media-types@^1.1.0", "@std/media-types": "jsr:@std/media-types@^1.1.0",
"@std/path": "jsr:@std/path@^1.1.2" "@std/path": "jsr:@std/path@^1.1.4"
} }
} }

92
deno.lock generated
View file

@ -1,38 +1,38 @@
{ {
"version": "5", "version": "5",
"specifiers": { "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/lurid@0.2": "0.2.0",
"jsr:@andyburke/serverus@0.13": "0.13.0", "jsr:@andyburke/serverus@0.16": "0.16.0",
"jsr:@da/bcrypt@*": "1.0.1", "jsr:@da/bcrypt@*": "1.0.1",
"jsr:@da/bcrypt@^1.0.1": "1.0.1", "jsr:@da/bcrypt@^1.0.1": "1.0.1",
"jsr:@std/assert@^1.0.15": "1.0.15", "jsr:@std/assert@^1.0.17": "1.0.17",
"jsr:@std/cli@^1.0.19": "1.0.23", "jsr:@std/cli@^1.0.19": "1.0.25",
"jsr:@std/cli@^1.0.20": "1.0.23", "jsr:@std/cli@^1.0.20": "1.0.25",
"jsr:@std/cli@^1.0.21": "1.0.23", "jsr:@std/cli@^1.0.21": "1.0.25",
"jsr:@std/cli@^1.0.23": "1.0.23", "jsr:@std/cli@^1.0.25": "1.0.25",
"jsr:@std/encoding@^1.0.10": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.6": "1.0.8", "jsr:@std/fmt@^1.0.6": "1.0.8",
"jsr:@std/fmt@^1.0.8": "1.0.8", "jsr:@std/fmt@^1.0.8": "1.0.8",
"jsr:@std/fs@^1.0.18": "1.0.19", "jsr:@std/fs@^1.0.18": "1.0.22",
"jsr:@std/fs@^1.0.19": "1.0.19", "jsr:@std/fs@^1.0.19": "1.0.22",
"jsr:@std/fs@^1.0.21": "1.0.22",
"jsr:@std/fs@^1.0.22": "1.0.22",
"jsr:@std/html@^1.0.5": "1.0.5", "jsr:@std/html@^1.0.5": "1.0.5",
"jsr:@std/http@^1.0.20": "1.0.21", "jsr:@std/http@^1.0.20": "1.0.23",
"jsr:@std/http@^1.0.21": "1.0.21", "jsr:@std/http@^1.0.23": "1.0.23",
"jsr:@std/internal@^1.0.10": "1.0.12",
"jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/internal@^1.0.9": "1.0.12",
"jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.6": "1.0.6", "jsr:@std/net@^1.0.6": "1.0.6",
"jsr:@std/path@^1.1.0": "1.1.2", "jsr:@std/path@^1.1.0": "1.1.4",
"jsr:@std/path@^1.1.1": "1.1.2", "jsr:@std/path@^1.1.1": "1.1.4",
"jsr:@std/path@^1.1.2": "1.1.2", "jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/streams@^1.0.13": "1.0.13", "jsr:@std/streams@^1.0.16": "1.0.16",
"npm:@types/node@*": "22.15.15" "npm:@types/node@*": "22.15.15"
}, },
"jsr": { "jsr": {
"@andyburke/fsdb@1.1.0": { "@andyburke/fsdb@1.2.4": {
"integrity": "ad2d062672137ca96df19df032b51f1c7aa3133c973a0b86eb8eaab3b4c2d47b", "integrity": "3437078a5627d4c72d677e41c20293a47d58a3af19eda72869a12acb011064d2",
"dependencies": [ "dependencies": [
"jsr:@std/cli@^1.0.20", "jsr:@std/cli@^1.0.20",
"jsr:@std/fs@^1.0.18", "jsr:@std/fs@^1.0.18",
@ -45,8 +45,8 @@
"jsr:@std/cli@^1.0.19" "jsr:@std/cli@^1.0.19"
] ]
}, },
"@andyburke/serverus@0.13.0": { "@andyburke/serverus@0.16.0": {
"integrity": "73f451e1b68cd9be3938333b06290bfeab275361453559f40dfeab19dc4ad6d7", "integrity": "625fc3f08ddc377beb86b282d603ca6154cf38e136d916ec19a87ae4c4ed86d5",
"dependencies": [ "dependencies": [
"jsr:@std/cli@^1.0.21", "jsr:@std/cli@^1.0.21",
"jsr:@std/fmt@^1.0.6", "jsr:@std/fmt@^1.0.6",
@ -59,14 +59,14 @@
"@da/bcrypt@1.0.1": { "@da/bcrypt@1.0.1": {
"integrity": "d2172d3acbcff52e0465557a1a48b1ff1c92df08c90712dae5372255a8c45eb3" "integrity": "d2172d3acbcff52e0465557a1a48b1ff1c92df08c90712dae5372255a8c45eb3"
}, },
"@std/assert@1.0.15": { "@std/assert@1.0.17": {
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", "integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe",
"dependencies": [ "dependencies": [
"jsr:@std/internal@^1.0.12" "jsr:@std/internal"
] ]
}, },
"@std/cli@1.0.23": { "@std/cli@1.0.25": {
"integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca" "integrity": "1f85051b370c97a7a9dfc6ba626e7ed57a91bea8c081597276d1e78d929d8c91"
}, },
"@std/encoding@1.0.10": { "@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
@ -74,27 +74,27 @@
"@std/fmt@1.0.8": { "@std/fmt@1.0.8": {
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
}, },
"@std/fs@1.0.19": { "@std/fs@1.0.22": {
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308",
"dependencies": [ "dependencies": [
"jsr:@std/internal@^1.0.9", "jsr:@std/internal",
"jsr:@std/path@^1.1.1" "jsr:@std/path@^1.1.4"
] ]
}, },
"@std/html@1.0.5": { "@std/html@1.0.5": {
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
}, },
"@std/http@1.0.21": { "@std/http@1.0.23": {
"integrity": "abb5c747651ee6e3ea6139858fd9b1810d2c97f53a5e6722f3b6d27a6d263edc", "integrity": "6634e9e034c589bf35101c1b5ee5bbf052a5987abca20f903e58bdba85c80dee",
"dependencies": [ "dependencies": [
"jsr:@std/cli@^1.0.23", "jsr:@std/cli@^1.0.25",
"jsr:@std/encoding", "jsr:@std/encoding",
"jsr:@std/fmt@^1.0.8", "jsr:@std/fmt@^1.0.8",
"jsr:@std/fs@^1.0.19", "jsr:@std/fs@^1.0.21",
"jsr:@std/html", "jsr:@std/html",
"jsr:@std/media-types", "jsr:@std/media-types",
"jsr:@std/net", "jsr:@std/net",
"jsr:@std/path@^1.1.2", "jsr:@std/path@^1.1.4",
"jsr:@std/streams" "jsr:@std/streams"
] ]
}, },
@ -107,14 +107,14 @@
"@std/net@1.0.6": { "@std/net@1.0.6": {
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
}, },
"@std/path@1.1.2": { "@std/path@1.1.4": {
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [ "dependencies": [
"jsr:@std/internal@^1.0.10" "jsr:@std/internal"
] ]
}, },
"@std/streams@1.0.13": { "@std/streams@1.0.16": {
"integrity": "772d208cd0d3e5dac7c1d9e6cdb25842846d136eea4a41a62e44ed4ab0c8dd9e" "integrity": "85030627befb1767c60d4f65cb30fa2f94af1d6ee6e5b2515b76157a542e89c4"
} }
}, },
"npm": { "npm": {
@ -133,16 +133,16 @@
}, },
"workspace": { "workspace": {
"dependencies": [ "dependencies": [
"jsr:@andyburke/fsdb@^1.1.0", "jsr:@andyburke/fsdb@^1.2.4",
"jsr:@andyburke/lurid@0.2", "jsr:@andyburke/lurid@0.2",
"jsr:@andyburke/serverus@0.13", "jsr:@andyburke/serverus@0.16",
"jsr:@da/bcrypt@^1.0.1", "jsr:@da/bcrypt@^1.0.1",
"jsr:@std/assert@^1.0.15", "jsr:@std/assert@^1.0.17",
"jsr:@std/encoding@^1.0.10", "jsr:@std/encoding@^1.0.10",
"jsr:@std/fs@^1.0.19", "jsr:@std/fs@^1.0.22",
"jsr:@std/http@^1.0.21", "jsr:@std/http@^1.0.23",
"jsr:@std/media-types@^1.1.0", "jsr:@std/media-types@^1.1.0",
"jsr:@std/path@^1.1.2" "jsr:@std/path@^1.1.4"
] ]
} }
} }

91
models/channel.ts Normal file
View file

@ -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<string,any>} [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<string, any>;
timestamps: {
created: string;
updated: string;
archived: string | undefined;
};
};
export const CHANNELS = new FSDB_COLLECTION<CHANNEL>({
name: 'channels',
id_field: 'id',
organize: by_lurid,
indexers: {
creator_id: new FSDB_INDEXER_SYMLINKS<CHANNEL>({
name: 'creator_id',
field: 'creator_id',
to_many: true,
organize: by_lurid
}),
name: new FSDB_INDEXER_SYMLINKS<CHANNEL>({
name: 'name',
get_values_to_index: (channel) => [channel.name.toLowerCase()],
organize: by_character
}),
tags: new FSDB_INDEXER_SYMLINKS<CHANNEL>({
name: 'tags',
get_values_to_index: (channel): string[] => {
return (channel.tags ?? []).map((tag) => tag.toLowerCase());
},
to_many: true,
organize: by_character
})
}
});

View file

@ -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_COLLECTION } from '@andyburke/fsdb';
import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers';
import { EMOJIS } from '../public/js/emojis/en.ts'; 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} creator_id - id of the source user
* @property {string} type - event type * @property {string} type - event type
* @property {string} [parent_id] - optional parent event id * @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<string,any>} [data] - optional data payload of the event * @property {Record<string,any>} [data] - optional data payload of the event
* @property {TIMESTAMPS} timestamps - timestamps that will be set by the server * @property {TIMESTAMPS} timestamps - timestamps that will be set by the server
*/ */
@ -25,6 +27,8 @@ export type EVENT = {
creator_id: string; creator_id: string;
type: string; type: string;
parent_id?: string; parent_id?: string;
channel?: string;
topic?: string;
tags?: string[]; tags?: string[];
data?: Record<string, any>; data?: Record<string, any>;
timestamps: { timestamps: {
@ -33,11 +37,6 @@ export type EVENT = {
}; };
}; };
type TOPIC_EVENT_CACHE_ENTRY = {
collection: FSDB_COLLECTION<EVENT>;
eviction_timeout: number;
};
// TODO: separate out these different validators somewhere? // TODO: separate out these different validators somewhere?
export function VALIDATE_EVENT(event: EVENT) { export function VALIDATE_EVENT(event: EVENT) {
const errors: any[] = []; const errors: any[] = [];
@ -111,6 +110,8 @@ export function VALIDATE_EVENT(event: EVENT) {
}); });
} }
break; break;
case 'presence':
break;
case 'reaction': case 'reaction':
if (typeof event.parent_id !== 'string') { if (typeof event.parent_id !== 'string') {
errors.push({ errors.push({
@ -148,78 +149,76 @@ export function VALIDATE_EVENT(event: EVENT) {
return errors.length ? errors : undefined; return errors.length ? errors : undefined;
} }
const TOPIC_EVENT_ID_MATCHER = /^(?<event_type>.*):(?<event_id>.*)$/; const EVENT_ID_EXTRACTOR = /^(?<event_type>.*):(?<event_id>.*)$/;
const TOPIC_EVENTS: Record<string, TOPIC_EVENT_CACHE_ENTRY> = {}; function smart_event_id_organizer(id: string) {
export function get_events_collection_for_topic(topic_id: string): FSDB_COLLECTION<EVENT> { const [event_type, event_id] = id.split(':', 2);
TOPIC_EVENTS[topic_id] = TOPIC_EVENTS[topic_id] ?? { const event_dirs = by_lurid(event_id).slice(0, -1);
collection: new FSDB_COLLECTION<EVENT>({ return [event_type, ...event_dirs, `${id}.json`];
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<string, string> | 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<EVENT>({
name: 'creator_id',
field: 'creator_id',
to_many: true,
organize: by_lurid
}),
parent_id: new FSDB_INDEXER_SYMLINKS<EVENT>({
name: 'parent_id',
field: 'parent_id',
to_many: true,
organize: by_lurid
}),
tags: new FSDB_INDEXER_SYMLINKS<EVENT>({
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;
} }
export function clear_topic_events_cache() { export const EVENTS = new FSDB_COLLECTION<EVENT>({
for (const [topic_id, cached] of Object.entries(TOPIC_EVENTS)) { name: `events`,
if (cached.eviction_timeout) { id_field: 'id',
clearTimeout(cached.eviction_timeout); organize: (id) => {
EVENT_ID_EXTRACTOR.lastIndex = 0;
const groups: Record<string, string> | 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<EVENT>({
name: 'creator_id',
field: 'creator_id',
to_many: true,
organize: by_lurid
}),
parent_id: new FSDB_INDEXER_SYMLINKS<EVENT>({
name: 'parent_id',
field: 'parent_id',
to_many: true,
organize: smart_event_id_organizer
}),
channel: new FSDB_INDEXER_SYMLINKS<EVENT>({
name: 'channel',
field: 'channel',
to_many: true,
organize: (channel: string) => [channel],
organize_id: smart_event_id_organizer
}),
topic: new FSDB_INDEXER_SYMLINKS<EVENT>({
name: 'topic',
field: 'topic',
to_many: true,
organize: (topic: string) => [topic],
organize_id: smart_event_id_organizer
}),
tags: new FSDB_INDEXER_SYMLINKS<EVENT>({
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
})
} }
} });

View file

@ -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<string,any>} [meta] - optional metadata about the topic
* @property {Record<string,string>} [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<string, any>;
emojis?: Record<string, string>; // 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<TOPIC>({
name: 'topics',
id_field: 'id',
organize: by_lurid,
indexers: {
creator_id: new FSDB_INDEXER_SYMLINKS<TOPIC>({
name: 'creator_id',
field: 'creator_id',
to_many: true,
organize: by_lurid
}),
name: new FSDB_INDEXER_SYMLINKS<TOPIC>({
name: 'name',
get_values_to_index: (topic) => [topic.name.toLowerCase()],
organize: by_character
}),
tags: new FSDB_INDEXER_SYMLINKS<TOPIC>({
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<TOPIC>({
name: 'topic',
get_values_to_index: (topic): string[] => {
return (topic.topic ?? '').split(/\W/);
},
to_many: true,
organize: by_character
})
}
});

View file

@ -2,32 +2,30 @@ import { FSDB_COLLECTION } from '@andyburke/fsdb';
import { by_lurid } from '@andyburke/fsdb/organizers'; import { by_lurid } from '@andyburke/fsdb/organizers';
import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; 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 * @typedef {object} WATCH_TIMESTAMPS
* @property {string} created the created date of the watch * @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 * @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 * WATCH
* *
* @property {string} id - lurid (stable) * @property {string} id - lurid (stable)
* @property {string} creator_id - user id of the watch creator * @property {string} creator_id - user id of the watch creator
* @property {string} topic_id - the topic_id being watched * @property {string} [type] - a filter for event type
* @property {[WATCH_TYPE_INFO]} types - information for types being watched within this topic * @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<string,any>} [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<string,any>} [meta] - optional metadata about the watch * @property {Record<string,any>} [meta] - optional metadata about the watch
* @property {WATCH_TIMESTAMPS} timestamps - timestamps for the watch * @property {WATCH_TIMESTAMPS} timestamps - timestamps for the watch
*/ */
@ -35,13 +33,16 @@ export type WATCH_TYPE_INFO = {
export type WATCH = { export type WATCH = {
id: string; id: string;
creator_id: string; creator_id: string;
topic_id: string; type?: string;
types: [WATCH_TYPE_INFO]; parent_id?: string;
channel?: string;
topic?: string;
tags?: string[];
data?: Record<string, any>;
last_id_seen: string;
last_id_notified?: string;
meta?: Record<string, any>; meta?: Record<string, any>;
timestamps: { timestamps: WATCH_TIMESTAMPS;
created: string;
updated: string;
};
}; };
export const WATCHES = new FSDB_COLLECTION<WATCH>({ export const WATCHES = new FSDB_COLLECTION<WATCH>({
@ -56,11 +57,44 @@ export const WATCHES = new FSDB_COLLECTION<WATCH>({
organize: by_lurid organize: by_lurid
}), }),
topic_id: new FSDB_INDEXER_SYMLINKS<WATCH>({ type: new FSDB_INDEXER_SYMLINKS<WATCH>({
name: 'topic_id', name: 'type',
field: 'topic_id', field: 'type',
to_many: true, to_many: true,
organize: by_lurid organize: (type: string) => [type],
organize_id: by_lurid
}),
parent_id: new FSDB_INDEXER_SYMLINKS<WATCH>({
name: 'parent_id',
field: 'parent_id',
to_many: true,
organize: by_lurid,
organize_id: by_lurid
}),
channel: new FSDB_INDEXER_SYMLINKS<WATCH>({
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<WATCH>({
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<WATCH>({
name: 'tags',
field: 'tags',
to_many: true,
organize: (tag: string) => tag.length > 3 ? [tag.substring(0, 3), tag] : [tag],
organize_id: by_lurid
}) })
} }
}); });

View file

@ -6,10 +6,11 @@ import { SESSION, SESSIONS } from '../../../models/session.ts';
import { TOTP_ENTRIES } from '../../../models/totp_entry.ts'; import { TOTP_ENTRIES } from '../../../models/totp_entry.ts';
import { encodeBase64 } from '@std/encoding/base64'; import { encodeBase64 } from '@std/encoding/base64';
import parse_body from '../../../utils/bodyparser.ts'; import parse_body from '../../../utils/bodyparser.ts';
import { get_session, get_user, PRECHECK_TABLE, require_user, SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts'; import { AUTHED_BEFORE_COOKIE_ID, get_session, get_user, PRECHECK_TABLE, require_user, SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts';
import * as bcrypt from '@da/bcrypt'; import * as bcrypt from '@da/bcrypt';
import { verifyTotp } from '../../../utils/totp.ts'; import { verifyTotp } from '../../../utils/totp.ts';
const AUTHED_BEFORE_EXPIRATION: number = 399 * (24 * (60 * (60 * 1_000))); // 399 days
const DEFAULT_SESSION_TIME: number = 365 * (24 * (60 * (60 * 1_000))); // 365 days const DEFAULT_SESSION_TIME: number = 365 * (24 * (60 * (60 * 1_000))); // 365 days
export const PRECHECKS: PRECHECK_TABLE = {}; export const PRECHECKS: PRECHECK_TABLE = {};
@ -206,6 +207,7 @@ export async function create_new_session(session_settings: SESSION_INFO): Promis
const headers = new Headers(); const headers = new Headers();
const expires_in_utc = new Date(session.timestamps.expires).toUTCString(); const expires_in_utc = new Date(session.timestamps.expires).toUTCString();
headers.append('Set-Cookie', `${AUTHED_BEFORE_COOKIE_ID}=1; Path=/; Secure; Expires=${new Date(new Date(now).valueOf() + AUTHED_BEFORE_EXPIRATION).toUTCString()}`);
headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=${session.id}; Path=/; Secure; Expires=${expires_in_utc}`); headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=${session.id}; Path=/; Secure; Expires=${expires_in_utc}`);
headers.append(`x-${SESSION_ID_TOKEN}`, session.id); headers.append(`x-${SESSION_ID_TOKEN}`, session.id);

View file

@ -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`.

View file

@ -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<string, any>): Promise<Response | undefined> => {
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<string, any>): Promise<Response> {
const event: EVENT | null = await EVENTS.get(meta.params.event_id);
if (!event) {
return CANNED_RESPONSES.not_found();
}
return Response.json(event, {
status: 200
});
}

View file

@ -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<string, any>): Promise<Response | undefined> => {
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<string, any>): Promise<Response> {
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<EVENT> = {
...(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<EVENT>) => {
const {
event_type,
event_id
} = /^.*\/(?<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<EVENT>) => 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
});
}

View file

@ -1,50 +1,50 @@
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../utils/prechecks.ts'; import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../utils/prechecks.ts';
import parse_body from '../../../../utils/bodyparser.ts'; import parse_body from '../../../../utils/bodyparser.ts';
import * as CANNED_RESPONSES from '../../../../utils/canned_responses.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 = {}; 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<string, any>): Promise<Response | undefined> => { PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
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" // 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(); return CANNED_RESPONSES.not_found();
} }
meta.topic = topic; meta.channel = channel;
const topic_is_public = topic.permissions.read.length === 0; const channel_is_public = channel.permissions.read.length === 0;
const user_has_read_for_topic = topic_is_public || topic.permissions.read.includes(meta.user.id); 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(); return CANNED_RESPONSES.permission_denied();
} }
}]; }];
export function GET(_req: Request, meta: Record<string, any>): Response { export function GET(_req: Request, meta: Record<string, any>): Response {
return Response.json(meta.topic, { return Response.json(meta.channel, {
status: 200 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<string, any>): Promise<Response | undefined> => { PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
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" // 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(); return CANNED_RESPONSES.not_found();
} }
meta.topic = topic; meta.channel = channel;
const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id); 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(); return CANNED_RESPONSES.permission_denied();
} }
}]; }];
@ -54,16 +54,16 @@ export async function PUT(req: Request, meta: Record<string, any>): Promise<Resp
try { try {
const body = await parse_body(req); const body = await parse_body(req);
const updated = { const updated = {
...meta.topic, ...meta.channel,
...body, ...body,
id: meta.topic.id, id: meta.channel.id,
timestamps: { timestamps: {
created: meta.topic.timestamps.created, created: meta.channel.timestamps.created,
updated: now updated: now
} }
}; };
await TOPICS.update(updated); await CHANNELS.update(updated);
return Response.json(updated, { return Response.json(updated, {
status: 200 status: 200
}); });
@ -79,31 +79,31 @@ export async function PUT(req: Request, meta: Record<string, any>): Promise<Resp
} }
} }
// DELETE /api/topics/:id - Delete topic // DELETE /api/channels/:id - Delete channel
PRECHECKS.DELETE = [ PRECHECKS.DELETE = [
get_session, get_session,
get_user, get_user,
require_user, require_user,
async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => { async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
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" // 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(); return CANNED_RESPONSES.not_found();
} }
meta.topic = topic; meta.channel = channel;
const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id); 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(); return CANNED_RESPONSES.permission_denied();
} }
} }
]; ];
export async function DELETE(_req: Request, meta: Record<string, any>): Promise<Response> { export async function DELETE(_req: Request, meta: Record<string, any>): Promise<Response> {
await TOPICS.delete(meta.topic); await CHANNELS.delete(meta.channel);
return Response.json({ return Response.json({
deleted: true deleted: true

View file

@ -0,0 +1,28 @@
# /api/channels
Interact with channels.
## POST /api/channels
Create a new channel.
```
export type CHANNEL = {
id: string; // unique id for this channel
name: string; // the name of the channel (max 128 characters)
icon?: string; // optional url for a channel icon
topic?: string; // optional channel topic
tags?: string[]; // optional tags for the channel
meta?: Record<string, any>; // optional metadata
limits: {
users: number;
user_messages_per_minute: number;
};
creator_id: string; // user_id of the topic creator
emojis: Record<string, string>; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' }
};
```
## GET /api/channels
Get channels.

View file

@ -3,40 +3,34 @@ import parse_body from '../../../utils/bodyparser.ts';
import { get_session, get_user, require_user } from '../../../utils/prechecks.ts'; import { get_session, get_user, require_user } from '../../../utils/prechecks.ts';
import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts'; import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts';
import { PRECHECK_TABLE } from '../../../utils/prechecks.ts'; import { PRECHECK_TABLE } from '../../../utils/prechecks.ts';
import { TOPIC, TOPICS } from '../../../models/topic.ts'; import { CHANNEL, CHANNELS } from '../../../models/channel.ts';
import { WALK_ENTRY } from '@andyburke/fsdb';
export const PRECHECKS: PRECHECK_TABLE = {}; 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<string, any>): Response | undefined => { PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): 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(); return CANNED_RESPONSES.permission_denied();
} }
}]; }];
export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> { export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> {
const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100); const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100);
const topics = (await TOPICS.all({ const channels = (await CHANNELS.all({
limit, limit
filter: (entry: WALK_ENTRY<TOPIC>) => { })).map((channel_entry) => channel_entry.load());
// 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;
}
})).map((topic_entry) => topic_entry.load());
return Response.json(topics, { return Response.json(channels, {
status: 200 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<string, any>): Response | undefined => { PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): 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(); return CANNED_RESPONSES.permission_denied();
} }
}]; }];
@ -49,8 +43,8 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
if (typeof body.name !== 'string' || body.name.length === 0) { if (typeof body.name !== 'string' || body.name.length === 0) {
return Response.json({ return Response.json({
error: { error: {
cause: 'missing_topic_name', cause: 'missing_channel_name',
message: 'You must specify a unique name for a topic.' message: 'You must specify a unique name for a channel.'
} }
}, { }, {
status: 400 status: 400
@ -60,8 +54,8 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
if (body.name.length > 64) { if (body.name.length > 64) {
return Response.json({ return Response.json({
error: { error: {
cause: 'invalid_topic_name', cause: 'invalid_channel_name',
message: 'topic names must be 64 characters or fewer.' message: 'channel names must be 64 characters or fewer.'
} }
}, { }, {
status: 400 status: 400
@ -70,29 +64,31 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
const normalized_name = body.name.toLowerCase(); const normalized_name = body.name.toLowerCase();
const existing_topic = (await TOPICS.find({ const existing_channel = (await CHANNELS.find({
name: normalized_name name: normalized_name
})).shift(); })).shift();
if (existing_topic) { if (existing_channel) {
return Response.json({ return Response.json({
error: { error: {
cause: 'topic_name_conflict', cause: 'channel_name_conflict',
message: 'There is already a topic with this name.' message: 'There is already a channel with this name.'
} }
}, { }, {
status: 400 status: 400
}); });
} }
const topic: TOPIC = { const channel: CHANNEL = {
...body, ...body,
id: lurid(), id: lurid(),
creator_id: meta.user.id, creator_id: meta.user.id,
permissions: { permissions: {
read: (body.permissions?.read ?? []), read: (body.permissions?.read ?? []),
write: (body.permissions?.write ?? [meta.user.id]), write: (body.permissions?.write ?? [meta.user.id]),
read_events: (body.permissions?.read_events ?? []), events: {
write_events: (body.permissions?.write_events ?? []) read: (body.permissions?.events?.read ?? []),
write: (body.permissions?.events?.write ?? [])
}
}, },
timestamps: { timestamps: {
created: now, created: now,
@ -101,9 +97,9 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
} }
}; };
await TOPICS.create(topic); await CHANNELS.create(channel);
return Response.json(topic, { return Response.json(channel, {
status: 201 status: 201
}); });
} catch (error) { } catch (error) {

View file

@ -0,0 +1,7 @@
## PUT /api/events/:event_id
Update an event.
## DELETE /api/events/:event_id
Delete an event.

View file

@ -0,0 +1,159 @@
import { CHANNEL, CHANNELS } from '../../../../models/channel.ts';
import { EVENT, EVENTS } from '../../../../models/event.ts';
import parse_body from '../../../../utils/bodyparser.ts';
import * as CANNED_RESPONSES from '../../../../utils/canned_responses.ts';
import { get_session, get_user, PRECHECK_TABLE, require_user, user_has_write_permission_for_event } from '../../../../utils/prechecks.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/events/:id - Get an event
PRECHECKS.GET = [get_session, get_user, require_user];
export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> {
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<string, any>): Response | undefined => {
if (Deno.env.get('APPEND_ONLY_EVENTS')) {
return CANNED_RESPONSES.append_only_events();
}
},
(_req: Request, meta: Record<string, any>): 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<string, any>): Promise<Response> {
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<string, any>): Response | undefined => {
if (Deno.env.get('APPEND_ONLY_EVENTS')) {
return CANNED_RESPONSES.append_only_events();
}
},
(_req: Request, meta: Record<string, any>): 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<string, any>): Promise<Response> {
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
});
}

View file

@ -0,0 +1,11 @@
# /api/events
Interact with events.
## GET /api/events
Get events.
## POST /api/events
Create an event.

View file

@ -1,42 +1,21 @@
import lurid from '@andyburke/lurid'; import lurid from '@andyburke/lurid';
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; import { get_session, get_user, PRECHECK_TABLE, require_user, user_has_write_permission_for_event } from '../../../utils/prechecks.ts';
import { TOPIC, TOPICS } from '../../../../../models/topic.ts'; import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts';
import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts'; import { EVENT, EVENTS, VALIDATE_EVENT } from '../../../models/event.ts';
import { EVENT, get_events_collection_for_topic, VALIDATE_EVENT } from '../../../../../models/event.ts'; import parse_body from '../../../utils/bodyparser.ts';
import parse_body from '../../../../../utils/bodyparser.ts'; import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb';
import { FSDB_COLLECTION, FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; import { WATCH, WATCHES } from '../../../models/watch.ts';
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 = {}; export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/topics/:topic_id/events - get topic events // GET /api/events - get events
// query parameters: // query parameters:
// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted) // 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<string, any>): Promise<Response | undefined> => { PRECHECKS.GET = [get_session, get_user, require_user];
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();
}
}];
export async function GET(request: Request, meta: Record<string, any>): Promise<Response> { export async function GET(request: Request, meta: Record<string, any>): Promise<Response> {
const events: FSDB_COLLECTION<EVENT> = 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 sort_name: string = meta.query.sort ?? 'newest';
const key = sort_name as keyof typeof sorts; const key = sort_name as keyof typeof sorts;
const sort: any = sorts[key]; const sort: any = sorts[key];
@ -53,7 +32,7 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
const options: FSDB_SEARCH_OPTIONS<EVENT> = { const options: FSDB_SEARCH_OPTIONS<EVENT> = {
...(meta.query ?? {}), ...(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), offset: Math.max(parseInt(meta.query?.offset ?? '0', 10), 0),
sort, sort,
filter: (entry: WALK_ENTRY<EVENT>) => { filter: (entry: WALK_ENTRY<EVENT>) => {
@ -82,8 +61,9 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
'Cache-Control': 'no-cache, must-revalidate' 'Cache-Control': 'no-cache, must-revalidate'
}; };
const results = (await events.all(options)) const results = (await EVENTS.all(options))
.map((entry: WALK_ENTRY<EVENT>) => entry.load()) .map((entry: WALK_ENTRY<EVENT>) => 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)); .sort((lhs_item: EVENT, rhs_item: EVENT) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created));
// long-polling support // long-polling support
@ -96,7 +76,7 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
results.push(create_event.item); results.push(create_event.item);
clearTimeout(timeout); clearTimeout(timeout);
events.off('create', on_create); EVENTS.off('create', on_create);
return resolve(Response.json(results, { return resolve(Response.json(results, {
status: 200, status: 200,
@ -105,20 +85,20 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
} }
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
events.off('create', on_create); EVENTS.off('create', on_create);
return resolve(Response.json(results, { return resolve(Response.json(results, {
status: 200, status: 200,
headers headers
})); }));
}, 60_000); // 60 seconds }, 60_000); // 60 seconds
events.on('create', on_create); EVENTS.on('create', on_create);
request.signal.addEventListener('abort', () => { request.signal.addEventListener('abort', () => {
events.off('create', on_create); EVENTS.off('create', on_create);
clearTimeout(timeout); clearTimeout(timeout);
reject(new Error('request aborted')); reject(new Error('request aborted'));
}); });
Deno.addSignalListener('SIGINT', () => { Deno.addSignalListener('SIGINT', () => {
events.off('create', on_create); EVENTS.off('create', on_create);
clearTimeout(timeout); clearTimeout(timeout);
return resolve(Response.json(results, { return resolve(Response.json(results, {
status: 200, status: 200,
@ -134,52 +114,78 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
}); });
} }
async function update_watches(topic: TOPIC, event: EVENT) { async function update_watches(event: EVENT) {
const limit = 100; const limit = 100;
let more_to_process; let more_to_process;
let offset = 0; let offset = 0;
do { do {
const watches: WATCH[] = (await WATCHES.find({ const watches: WATCH[] = (await WATCHES.all({
topic_id: topic.id
}, {
limit, limit,
offset offset
})).map((entry) => entry.load()); })).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; offset += watches.length;
more_to_process = watches.length === limit; more_to_process = watches.length === limit;
} while (more_to_process); } while (more_to_process);
} }
// POST /api/topics/:topic_id/events - Create an event // POST /api/events - Create an event
PRECHECKS.POST = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => { PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; 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" if (!user_can_create_events) {
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(); return CANNED_RESPONSES.permission_denied();
} }
}]; }];
export async function POST(req: Request, meta: Record<string, any>): Promise<Response> { export async function POST(req: Request, meta: Record<string, any>): Promise<Response> {
try { try {
const events: FSDB_COLLECTION<EVENT> = get_events_collection_for_topic(meta.topic.id);
const now = new Date().toISOString(); const now = new Date().toISOString();
const body = await parse_body(req); const body = await parse_body(req);
@ -204,11 +210,33 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
}); });
} }
console.dir({ if (!user_has_write_permission_for_event(meta.user, event)) {
event return CANNED_RESPONSES.permission_denied();
}); }
await events.create(event); 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();
}
}
await EVENTS.create(event);
update_watches(event);
return Response.json(event, { return Response.json(event, {
status: 201 status: 201

View file

@ -1,23 +0,0 @@
# /api/topics/:topic_id
Interact with a specific topic.
## GET /api/topics/:topic_id
Get the topic specified by `:topic_id`.
## PUT /api/topics/:topic_id
Update the topics specified by `:topic_id`.
Eg:
```
{
name?: string;
}
```
## DELETE /api/topics/:topic_id
Delete the topic specified by `:topic_id`.

View file

@ -1,166 +0,0 @@
import { FSDB_COLLECTION } from '@andyburke/fsdb';
import { EVENT, get_events_collection_for_topic } from '../../../../../../models/event.ts';
import { TOPIC, TOPICS } from '../../../../../../models/topic.ts';
import parse_body from '../../../../../../utils/bodyparser.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/topics/:topic_id/events/:id - Get an event
PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
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<string, any>): Promise<Response> {
const events: FSDB_COLLECTION<EVENT> = 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<string, any>): Response | undefined => {
if (Deno.env.get('APPEND_ONLY_EVENTS')) {
return CANNED_RESPONSES.append_only_events();
}
},
async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
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<string, any>): Promise<Response> {
const now = new Date().toISOString();
try {
const events: FSDB_COLLECTION<EVENT> = 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<string, any>): Response | undefined => {
if (Deno.env.get('APPEND_ONLY_EVENTS')) {
return CANNED_RESPONSES.append_only_events();
}
},
async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
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<string, any>): Promise<Response> {
const events: FSDB_COLLECTION<EVENT> = 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
});
}

View file

@ -1,15 +0,0 @@
# /api/topics/:topic_id/events
Interact with a events for a topic.
## GET /api/topics/:topic_id/events
Get events for the given topic.
## PUT /api/topics/:topic_id/events/:event_id
Update an event.
## DELETE /api/topics/:topic_id/events/:event_id
Delete an event.

View file

@ -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<string, any>;
limits: {
users: number;
user_messages_per_minute: number;
};
creator_id: string; // user_id of the topic creator
emojis: Record<string, string>; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' }
};
```
## GET /api/topics
Get topics.

View file

@ -5,7 +5,7 @@ import parse_body from '../../../../../../utils/bodyparser.ts';
export const PRECHECKS: PRECHECK_TABLE = {}; export const PRECHECKS: PRECHECK_TABLE = {};
// PUT /api/users/:user_id/watches/:watch_id - Update topic // PUT /api/users/:user_id/watches/:watch_id - Update watch
PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => { PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const watch_id: string = meta.params?.watch_id?.toLowerCase().trim() ?? ''; const watch_id: string = meta.params?.watch_id?.toLowerCase().trim() ?? '';
@ -69,7 +69,7 @@ PRECHECKS.DELETE = [
return CANNED_RESPONSES.not_found(); return CANNED_RESPONSES.not_found();
} }
meta.topic = watch; meta.watch = watch;
const user_owns_watch = watch.creator_id === meta.user.id; const user_owns_watch = watch.creator_id === meta.user.id;
if (!user_owns_watch) { if (!user_owns_watch) {

View file

@ -4,7 +4,7 @@ import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts';
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts';
import parse_body from '../../../../../utils/bodyparser.ts'; import parse_body from '../../../../../utils/bodyparser.ts';
import lurid from '@andyburke/lurid'; import lurid from '@andyburke/lurid';
import { TOPICS } from '../../../../../models/topic.ts'; import { CHANNELS } from '../../../../../models/channel.ts';
export const PRECHECKS: PRECHECK_TABLE = {}; export const PRECHECKS: PRECHECK_TABLE = {};
@ -99,34 +99,18 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
} }
}; };
const topic = await TOPICS.get(watch.topic_id); if (watch.channel) {
if (!topic) { const channel = await CHANNELS.get(watch.channel);
return Response.json({ if (!channel) {
errors: [{ return Response.json({
cause: 'invalid_topic_id', errors: [{
message: 'Could not find a topic with id: ' + watch.topic_id cause: 'invalid_channel_id',
}] message: 'Could not find a channel with id: ' + watch.channel
}, { }]
status: 400 }, {
}); status: 400
} });
}
const existing_watch: WATCH | undefined = (await WATCHES.find({
creator_id: meta.user.id,
topic_id: topic.id
}, {
limit: 1
})).shift()?.load();
if (existing_watch) {
return Response.json({
errors: [{
cause: 'existing_watch',
message: 'You already have a watch for this topic.'
}]
}, {
status: 400
});
} }
await WATCHES.create(watch); await WATCHES.create(watch);

View file

@ -10,27 +10,34 @@ import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts';
import * as bcrypt from '@da/bcrypt'; import * as bcrypt from '@da/bcrypt';
import { INVITE_CODE, INVITE_CODES } from '../../../models/invites.ts'; import { INVITE_CODE, INVITE_CODES } from '../../../models/invites.ts';
// TODO: figure out a better solution for doling out permissions // TODO: figure out a better solution for doling out permissions
const DEFAULT_USER_PERMISSIONS: string[] = [ const DEFAULT_USER_PERMISSIONS: string[] = [
'channels.read',
'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', 'files.write.own',
'invites.create', 'invites.create',
'invites.read.own', 'invites.read.own',
'self.read', 'self.read',
'self.write', 'self.write',
'signups.read.own', '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', 'users.read',
'watches.create.own', 'watches.create.own',
'watches.read.own', 'watches.read.own',
@ -39,10 +46,16 @@ const DEFAULT_USER_PERMISSIONS: string[] = [
// TODO: figure out a better solution for doling out permissions // TODO: figure out a better solution for doling out permissions
const DEFAULT_SUPERUSER_PERMISSIONS: string[] = [ const DEFAULT_SUPERUSER_PERMISSIONS: string[] = [
...DEFAULT_USER_PERMISSIONS, ...DEFAULT_USER_PERMISSIONS,
'topics.create', 'channels.create',
'topics.delete', 'channels.delete',
'topics.write', 'channels.write',
'files.write.all',
'invites.read.all',
'signups.read.all',
'users.write',
'watches.read.all',
'watches.write.all'
]; ];
export const PRECHECKS: PRECHECK_TABLE = {}; export const PRECHECKS: PRECHECK_TABLE = {};
@ -148,7 +161,7 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
await INVITE_CODES.create(root_invite_code); await INVITE_CODES.create(root_invite_code);
} }
const secret_code = submitted_invite_code ?? root_invite_code_secret; const secret_code = root_invite_code_secret ?? submitted_invite_code; // if it's the first user, use the autogen code, ignore anything they submit
if (typeof secret_code !== 'string' || secret_code.length < 3) { if (typeof secret_code !== 'string' || secret_code.length < 3) {
return Response.json({ return Response.json({
error: { error: {

View file

@ -16,7 +16,7 @@
--border-highlight: hsl(from var(--base-color) h 50% 75%); --border-highlight: hsl(from var(--base-color) h 50% 75%);
--icon-scale: 1; --icon-scale: 1;
--border-radius: 12px; --border-radius: 4px;
} }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
@ -169,6 +169,15 @@ body {
/* fixed height? */ /* fixed height? */
} }
#background-container {
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
z-index: -1;
}
main { main {
position: relative; position: relative;
width: 100%; width: 100%;
@ -362,21 +371,21 @@ button.primary {
body[data-perms*="self.read"] [data-requires-permission="self.read"], body[data-perms*="self.read"] [data-requires-permission="self.read"],
body[data-perms*="self.write"] [data-requires-permission="self.write"], body[data-perms*="self.write"] [data-requires-permission="self.write"],
body[data-perms*="topics.create"] [data-requires-permission="topics.create"], body[data-perms*="channels.create"] [data-requires-permission="channels.create"],
body[data-perms*="topics.read"] [data-requires-permission="topics.read"], body[data-perms*="channels.read"] [data-requires-permission="channels.read"],
body[data-perms*="topics.write"] [data-requires-permission="topics.write"], body[data-perms*="channels.write"] [data-requires-permission="channels.write"],
body[data-perms*="topics.blurbs.create"] [data-requires-permission="topics.blurbs.create"], body[data-perms*="events.create.blurb"] [data-requires-permission="events.create.blurb"],
body[data-perms*="topics.blurbs.read"] [data-requires-permission="topics.blurbs.read"], body[data-perms*="events.read.blurb"] [data-requires-permission="events.read.blurb"],
body[data-perms*="topics.blurbs.write"] [data-requires-permission="topics.blurbs.write"], body[data-perms*="events.write.blurb"] [data-requires-permission="events.write.blurb"],
body[data-perms*="topics.chat.create"] [data-requires-permission="topics.chat.create"], body[data-perms*="events.create.chat"] [data-requires-permission="events.create.chat"],
body[data-perms*="topics.chat.read"] [data-requires-permission="topics.chat.read"], body[data-perms*="events.read.chat"] [data-requires-permission="events.read.chat"],
body[data-perms*="topics.chat.write"] [data-requires-permission="topics.chat.write"], body[data-perms*="events.write.chat"] [data-requires-permission="events.write.chat"],
body[data-perms*="topics.essays.create"] [data-requires-permission="topics.essays.create"], body[data-perms*="events.create.essay"] [data-requires-permission="events.create.essay"],
body[data-perms*="topics.essays.read"] [data-requires-permission="topics.essays.read"], body[data-perms*="events.read.essay"] [data-requires-permission="events.read.essay"],
body[data-perms*="topics.essays.write"] [data-requires-permission="topics.essays.write"], body[data-perms*="events.write.essay"] [data-requires-permission="events.write.essay"],
body[data-perms*="topics.posts.create"] [data-requires-permission="topics.posts.create"], body[data-perms*="events.create.post"] [data-requires-permission="events.create.post"],
body[data-perms*="topics.posts.read"] [data-requires-permission="topics.posts.read"], body[data-perms*="events.read.post"] [data-requires-permission="events.read.post"],
body[data-perms*="topics.posts.write"] [data-requires-permission="topics.posts.write"], body[data-perms*="events.write.post"] [data-requires-permission="events.write.post"],
body[data-perms*="users.read"] [data-requires-permission="users.read"], body[data-perms*="users.read"] [data-requires-permission="users.read"],
body[data-perms*="users.write"] [data-requires-permission="users.write"], body[data-perms*="users.write"] [data-requires-permission="users.write"],
body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"] { body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"] {
@ -1682,6 +1691,32 @@ body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"]
left: 11px; left: 11px;
} }
.icon.map-pin {
box-sizing: border-box;
position: relative;
display: block;
transform: rotate(45deg) scale(var(--icon-scale, 1));
width: 18px;
height: 18px;
border-radius: 100% 100% 0 100%;
border: 2px solid;
margin-top: -4px;
}
.icon.map-pin::before {
content: "";
display: block;
box-sizing: border-box;
position: absolute;
width: 8px;
height: 8px;
border: 2px solid;
top: 3px;
left: 3px;
border-radius: 40px;
}
/* AUDIO PLAYER ICONS */ /* AUDIO PLAYER ICONS */
.icon.skip-back { .icon.skip-back {
box-sizing: border-box; box-sizing: border-box;

6
public/foo/index.html Normal file
View file

@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body>
<h1>Hello World - foo</h1>
</body>
</html>

View file

@ -3,54 +3,57 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>autonomous.contact</title> <title><!-- #include "./files/settings/title.txt" or "./title.txt" --></title>
<link rel="apple-touch-icon" sizes="180x180" href="./icons/apple-touch-icon.png" ></link> <link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" ></link>
<link rel="icon" type="image/png" sizes="32x32" href="./icons/favicon-32x32.png" ></link> <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" ></link>
<link rel="icon" type="image/png" sizes="16x16" href="./icons/favicon-16x16.png" ></link> <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" ></link>
<link rel="stylesheet" href="./base.css"></link> <link rel="stylesheet" href="/base.css"></link>
<link rel="stylesheet" href="/files/custom.css"></link>
<!-- inlining these to force them to be scoped for everything else --> <!-- inlining these to force them to be scoped for everything else -->
<script type="text/javascript"><!-- #include file="./js/_utils.js" --></script> <script type="text/javascript"><!-- #include "./js/_utils.js" --></script>
<script type="text/javascript"><!-- #include file="./js/api.js" --></script> <script type="text/javascript"><!-- #include "./js/api.js" --></script>
<script type="text/javascript"><!-- #include file="./js/app.js" --></script> <script type="text/javascript"><!-- #include "./js/app.js" --></script>
<!-- everything else --> <!-- everything else -->
<script src="./js/audioplayer.js" type="text/javascript"></script> <script src="/js/audioplayer.js" type="text/javascript"></script>
<script src="./js/datetimeutils.js" type="text/javascript"></script> <script src="/js/datetimeutils.js" type="text/javascript"></script>
<script src="./js/debounce.js" type="text/javascript"></script> <script src="/js/debounce.js" type="text/javascript"></script>
<script src="./js/external/md_to_html.js" type="text/javascript"></script> <script src="/js/external/md_to_html.js" type="text/javascript"></script>
<script src="./js/embeds/audio.js" type="text/javascript"></script> <script src="/js/embeds/audio.js" type="text/javascript"></script>
<script src="./js/embeds/gif.js" type="text/javascript"></script> <script src="/js/embeds/gif.js" type="text/javascript"></script>
<script src="./js/embeds/image.js" type="text/javascript"></script> <script src="/js/embeds/image.js" type="text/javascript"></script>
<script src="./js/embeds/link.js" type="text/javascript"></script> <script src="/js/embeds/link.js" type="text/javascript"></script>
<script src="./js/embeds/mkv.js" type="text/javascript"></script> <script src="/js/embeds/mkv.js" type="text/javascript"></script>
<script src="./js/embeds/mov.js" type="text/javascript"></script> <script src="/js/embeds/mov.js" type="text/javascript"></script>
<script src="./js/embeds/mp4.js" type="text/javascript"></script> <script src="/js/embeds/mp4.js" type="text/javascript"></script>
<script src="./js/embeds/spotify.js" type="text/javascript"></script> <script src="/js/embeds/spotify.js" type="text/javascript"></script>
<script src="./js/embeds/tidal.js" type="text/javascript"></script> <script src="/js/embeds/tidal.js" type="text/javascript"></script>
<script src="./js/embeds/vimeo.js" type="text/javascript"></script> <script src="/js/embeds/vimeo.js" type="text/javascript"></script>
<script src="./js/embeds/youtube.js" type="text/javascript"></script> <script src="/js/embeds/youtube.js" type="text/javascript"></script>
<script src="./js/emojis/en.js" type="text/javascript"></script> <script src="/js/emojis/en.js" type="text/javascript"></script>
<script src="./js/eventactions.js" type="text/javascript"></script> <script src="/js/eventactions.js" type="text/javascript"></script>
<script src="./js/htmlify.js" type="text/javascript"></script> <script src="/js/htmlify.js" type="text/javascript"></script>
<script src="./js/locationchange.js" type="text/javascript"></script> <script src="/js/locationchange.js" type="text/javascript"></script>
<script src="./js/notifications.js" type="text/javascript"></script> <script src="/js/notifications.js" type="text/javascript"></script>
<script src="./js/reactions.js" type="text/javascript"></script> <script src="/js/reactions.js" type="text/javascript"></script>
<script src="./js/smartfeeds.js" type="text/javascript"></script> <script src="/js/smartfeeds.js" type="text/javascript"></script>
<script src="./js/smartforms.js" type="text/javascript"></script> <script src="/js/smartforms.js" type="text/javascript"></script>
<script src="./js/textareaenhancements.js" type="text/javascript"></script> <script src="/js/textareaenhancements.js" type="text/javascript"></script>
<script src="./js/totp.js" type="text/javascript"></script> <script src="/js/totp.js" type="text/javascript"></script>
</head> </head>
<body> <body>
<!-- #include file="./signup_login_wall.html" --> <div id="background-container"></div>
<!-- #include "./signup_login_wall.html" -->
<main> <main>
<!-- #include file="./sidebar/sidebar.html" --> <!-- #include "./sidebar/sidebar.html" -->
<!-- #include file="./tabs/tabs.html" --> <!-- #include "./tabs/tabs.html" -->
</main> </main>
</body> </body>
</html> </html>

View file

@ -5,10 +5,13 @@ const api = {
...__options, ...__options,
}; };
// FIXME: this will break with different server settings
// TODO: we need the cookie names here to match any configured on the server
const session_id = (document.cookie.match( const session_id = (document.cookie.match(
/^(?:.*;)?\s*session_id\s*=\s*([^;]+)(?:.*)?$/, /^(?:.*;)?\s*session_id\s*=\s*([^;]+)(?:.*)?$/,
) || [, null])[1]; ) || [, null])[1];
// FIXME: this will break with different server settings
// TODO: this wasn't really intended to be persisted in a cookie // TODO: this wasn't really intended to be persisted in a cookie
const session_secret = (document.cookie.match( const session_secret = (document.cookie.match(
/^(?:.*;)?\s*session_secret\s*=\s*([^;]+)(?:.*)?$/, /^(?:.*;)?\s*session_secret\s*=\s*([^;]+)(?:.*)?$/,

View file

@ -1,7 +1,8 @@
const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)?/gm; const HASH_EXTRACTOR = /^\#\/(?<view>\w+)(?:\/channel\/(?<channel_id>[A-Za-z\-]+)\/?)?/gm;
const UPDATE_TOPICS_FREQUENCY = 60_000; const UPDATE_CHANNELS_FREQUENCY = 60_000;
const APP = { const APP = {
user: undefined,
user_servers: [], user_servers: [],
user_watches: [], user_watches: [],
@ -51,61 +52,39 @@ const APP = {
extract_url_hash_info: async function () { extract_url_hash_info: async function () {
HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls
const { const {
groups: { topic_id, view }, groups: { view, channel_id },
} = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? { } = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? {
groups: {}, groups: {},
}; };
console.dir({
url: window.location.href,
hash: window.location.hash,
topic_id,
view,
});
if (!document.body.dataset.topic || document.body.dataset.topic !== topic_id) {
const previous = document.body.dataset.topic;
console.dir({
topic_changed: {
detail: {
previous,
topic_id,
},
},
});
document.body.dataset.topic = topic_id;
this._emit( 'topic_changed', {
previous,
topic_id
});
if (!topic_id) {
const first_topic_id = this.TOPICS.TOPIC_LIST[0]?.id;
if (first_topic_id) {
window.location.hash = `/topic/${first_topic_id}/chat`; // TODO: allow a different default than chat
}
}
}
if (!document.body.dataset.view || document.body.dataset.view !== view) { if (!document.body.dataset.view || document.body.dataset.view !== view) {
const previous = document.body.dataset.view; const previous = typeof document.body.dataset.view === 'string' ? document.body.dataset.view : undefined;
document.body.dataset.view = view; if ( view ) {
document.body.dataset.view = view;
console.dir({ }
view_changed: { else {
detail: { delete document.body.dataset.view;
previous, }
view,
},
},
});
this._emit( 'view_changed', { this._emit( 'view_changed', {
previous, previous,
view view,
channel_id
});
}
if (!document.body.dataset.channel || document.body.dataset.channel !== channel_id) {
const previous = typeof document.body.dataset.channel === 'string' ? document.body.dataset.channel : undefined;
if ( channel_id ) {
document.body.dataset.channel = channel_id;
}
else {
delete document.body.dataset.channel;
}
this._emit( 'channel_changed', {
previous,
channel_id
}); });
} }
}, },
@ -142,19 +121,20 @@ const APP = {
} }
window.addEventListener("locationchange", this.extract_url_hash_info.bind( this )); window.addEventListener("locationchange", this.extract_url_hash_info.bind( this ));
window.addEventListener("locationchange", this.TOPICS.update ); window.addEventListener("locationchange", this.CHANNELS.update );
this.check_if_logged_in(); this.check_if_logged_in();
this.extract_url_hash_info(); this.extract_url_hash_info();
this._emit( 'load', this ); this._emit( 'load', this );
}, },
update_user: async function( updated_user ) { update_user: async function( user ) {
const user = this.user = updated_user; this.user = user;
document.body.dataset.user = JSON.stringify(user); document.body.dataset.user = JSON.stringify(user);
document.body.dataset.perms = user.permissions.join(":"); document.body.dataset.perms = user.permissions.join(":");
this.TOPICS.update(); this.CHANNELS.update();
this.user_servers = []; this.user_servers = [];
try { try {
@ -223,59 +203,54 @@ const APP = {
}, },
}, },
TOPICS: { CHANNELS: {
_last_topic_update: undefined, _last_channel_update: undefined,
_update_topics_timeout: undefined, _update_channels_timeout: undefined,
TOPIC_LIST: [], CHANNEL_LIST: [],
update: async () => { update: async ( force = false ) => {
const now = new Date(); const now = new Date();
const time_since_last_update = now - (APP.TOPICS._last_topic_update ?? 0); const time_since_last_update = now - (APP.CHANNELS._last_channel_update ?? 0);
if (time_since_last_update < UPDATE_TOPICS_FREQUENCY / 2) { const sufficient_time_has_passed_since_last_update = time_since_last_update > UPDATE_CHANNELS_FREQUENCY / 2;
if ( !force && !sufficient_time_has_passed_since_last_update ) {
return; return;
} }
if (APP.TOPICS._update_topics_timeout) { if (APP.CHANNELS._update_channels_timeout) {
clearTimeout(APP.TOPICS._update_topics_timeout); clearTimeout(APP.CHANNELS._update_channels_timeout);
APP.TOPICS._update_topics_timeout = undefined; APP.CHANNELS._update_channels_timeout = undefined;
} }
try { try {
const topics_response = await api.fetch("/api/topics"); const channels_response = await api.fetch("/api/channels");
if (topics_response.ok) { if (channels_response.ok) {
const new_topics = await topics_response.json(); const new_channels = await channels_response.json();
const has_differences = APP.CHANNELS.CHANNEL_LIST = [...new_channels];
APP.TOPICS.TOPIC_LIST.length !== new_topics.length ||
new_topics.some((topic, index) => {
return (
APP.TOPICS.TOPIC_LIST[index]?.id !== topic.id ||
APP.TOPICS.TOPIC_LIST[index]?.name !== topic.name
);
});
if (has_differences) { APP._emit( 'channels_updated', {
APP.TOPICS.TOPIC_LIST = [...new_topics]; channels: APP.CHANNELS.CHANNEL_LIST
});
APP._emit( 'topics_updated', { APP.CHANNELS._last_channel_update = now;
topics: APP.TOPICS.TOPIC_LIST
});
}
APP.TOPICS._last_topic_update = now;
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
APP.TOPICS._update_topics_timeout = setTimeout( APP.CHANNELS._update_channels_timeout = setTimeout(
APP.TOPICS.update, APP.CHANNELS.update,
UPDATE_TOPICS_FREQUENCY, UPDATE_CHANNELS_FREQUENCY,
); );
// now that we have topics, make sure our url is all good // now that we have channels, make sure our url is all good
APP.extract_url_hash_info(); APP.extract_url_hash_info();
}, },
}, },
}; };
document.addEventListener("DOMContentLoaded", APP.load.bind( APP )); document.addEventListener("DOMContentLoaded", APP.load.bind( APP ));
APP.on( 'view_changed', ( { view } ) => {
if ( !view ) {
window.location.hash = '/chat';
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

14512
public/js/external/leaflet/leaflet-src.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

661
public/js/external/leaflet/leaflet.css vendored Normal file
View file

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
public/js/external/leaflet/leaflet.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

28
public/js/external/qr-creator.min.js vendored Normal file
View file

@ -0,0 +1,28 @@
/*
jquery-qrcode v0.14.0 - https://larsjung.de/jquery-qrcode/ */
'use strict';let G=null;class H{}H.render=function(w,B){G(w,B)};self.QrCreator=H;
(function(w){function B(t,c,a,e){var b={},h=w(a,c);h.u(t);h.J();e=e||0;var r=h.h(),d=h.h()+2*e;b.text=t;b.level=c;b.version=a;b.O=d;b.a=function(b,a){b-=e;a-=e;return 0>b||b>=r||0>a||a>=r?!1:h.a(b,a)};return b}function C(t,c,a,e,b,h,r,d,g,x){function u(b,a,f,c,d,r,g){b?(t.lineTo(a+r,f+g),t.arcTo(a,f,c,d,h)):t.lineTo(a,f)}r?t.moveTo(c+h,a):t.moveTo(c,a);u(d,e,a,e,b,-h,0);u(g,e,b,c,b,0,-h);u(x,c,b,c,a,h,0);u(r,c,a,e,a,0,h)}function z(t,c,a,e,b,h,r,d,g,x){function u(b,a,c,d){t.moveTo(b+c,a);t.lineTo(b,
a);t.lineTo(b,a+d);t.arcTo(b,a,b+c,a,h)}r&&u(c,a,h,h);d&&u(e,a,-h,h);g&&u(e,b,-h,-h);x&&u(c,b,h,-h)}function A(t,c){var a=c.fill;if("string"===typeof a)t.fillStyle=a;else{var e=a.type,b=a.colorStops;a=a.position.map((b)=>Math.round(b*c.size));if("linear-gradient"===e)var h=t.createLinearGradient.apply(t,a);else if("radial-gradient"===e)h=t.createRadialGradient.apply(t,a);else throw Error("Unsupported fill");b.forEach(([b,a])=>{h.addColorStop(b,a)});t.fillStyle=h}}function y(t,c){a:{var a=c.text,e=
c.v,b=c.N,h=c.K,r=c.P;b=Math.max(1,b||1);for(h=Math.min(40,h||40);b<=h;b+=1)try{var d=B(a,e,b,r);break a}catch(J){}d=void 0}if(!d)return null;a=t.getContext("2d");c.background&&(a.fillStyle=c.background,a.fillRect(c.left,c.top,c.size,c.size));e=d.O;h=c.size/e;a.beginPath();for(r=0;r<e;r+=1)for(b=0;b<e;b+=1){var g=a,x=c.left+b*h,u=c.top+r*h,p=r,q=b,f=d.a,k=x+h,m=u+h,D=p-1,E=p+1,n=q-1,l=q+1,y=Math.floor(Math.min(.5,Math.max(0,c.R))*h),v=f(p,q),I=f(D,n),w=f(D,q);D=f(D,l);var F=f(p,l);l=f(E,l);q=f(E,
q);E=f(E,n);p=f(p,n);x=Math.round(x);u=Math.round(u);k=Math.round(k);m=Math.round(m);v?C(g,x,u,k,m,y,!w&&!p,!w&&!F,!q&&!F,!q&&!p):z(g,x,u,k,m,y,w&&p&&I,w&&F&&D,q&&F&&l,q&&p&&E)}A(a,c);a.fill();return t}var v={minVersion:1,maxVersion:40,ecLevel:"L",left:0,top:0,size:200,fill:"#000",background:null,text:"no text",radius:.5,quiet:0};G=function(t,c){var a={};Object.assign(a,v,t);a.N=a.minVersion;a.K=a.maxVersion;a.v=a.ecLevel;a.left=a.left;a.top=a.top;a.size=a.size;a.fill=a.fill;a.background=a.background;
a.text=a.text;a.R=a.radius;a.P=a.quiet;if(c instanceof HTMLCanvasElement){if(c.width!==a.size||c.height!==a.size)c.width=a.size,c.height=a.size;c.getContext("2d").clearRect(0,0,c.width,c.height);y(c,a)}else t=document.createElement("canvas"),t.width=a.size,t.height=a.size,a=y(t,a),c.appendChild(a)}})(function(){function w(c){var a=C.s(c);return{S:function(){return 4},b:function(){return a.length},write:function(c){for(var b=0;b<a.length;b+=1)c.put(a[b],8)}}}function B(){var c=[],a=0,e={B:function(){return c},
c:function(b){return 1==(c[Math.floor(b/8)]>>>7-b%8&1)},put:function(b,h){for(var a=0;a<h;a+=1)e.m(1==(b>>>h-a-1&1))},f:function(){return a},m:function(b){var h=Math.floor(a/8);c.length<=h&&c.push(0);b&&(c[h]|=128>>>a%8);a+=1}};return e}function C(c,a){function e(b,h){for(var a=-1;7>=a;a+=1)if(!(-1>=b+a||d<=b+a))for(var c=-1;7>=c;c+=1)-1>=h+c||d<=h+c||(r[b+a][h+c]=0<=a&&6>=a&&(0==c||6==c)||0<=c&&6>=c&&(0==a||6==a)||2<=a&&4>=a&&2<=c&&4>=c?!0:!1)}function b(b,a){for(var f=d=4*c+17,k=Array(f),m=0;m<
f;m+=1){k[m]=Array(f);for(var p=0;p<f;p+=1)k[m][p]=null}r=k;e(0,0);e(d-7,0);e(0,d-7);f=y.G(c);for(k=0;k<f.length;k+=1)for(m=0;m<f.length;m+=1){p=f[k];var q=f[m];if(null==r[p][q])for(var n=-2;2>=n;n+=1)for(var l=-2;2>=l;l+=1)r[p+n][q+l]=-2==n||2==n||-2==l||2==l||0==n&&0==l}for(f=8;f<d-8;f+=1)null==r[f][6]&&(r[f][6]=0==f%2);for(f=8;f<d-8;f+=1)null==r[6][f]&&(r[6][f]=0==f%2);f=y.w(h<<3|a);for(k=0;15>k;k+=1)m=!b&&1==(f>>k&1),r[6>k?k:8>k?k+1:d-15+k][8]=m,r[8][8>k?d-k-1:9>k?15-k:14-k]=m;r[d-8][8]=!b;if(7<=
c){f=y.A(c);for(k=0;18>k;k+=1)m=!b&&1==(f>>k&1),r[Math.floor(k/3)][k%3+d-8-3]=m;for(k=0;18>k;k+=1)m=!b&&1==(f>>k&1),r[k%3+d-8-3][Math.floor(k/3)]=m}if(null==g){b=t.I(c,h);f=B();for(k=0;k<x.length;k+=1)m=x[k],f.put(4,4),f.put(m.b(),y.f(4,c)),m.write(f);for(k=m=0;k<b.length;k+=1)m+=b[k].j;if(f.f()>8*m)throw Error("code length overflow. ("+f.f()+">"+8*m+")");for(f.f()+4<=8*m&&f.put(0,4);0!=f.f()%8;)f.m(!1);for(;!(f.f()>=8*m);){f.put(236,8);if(f.f()>=8*m)break;f.put(17,8)}var u=0;m=k=0;p=Array(b.length);
q=Array(b.length);for(n=0;n<b.length;n+=1){var v=b[n].j,w=b[n].o-v;k=Math.max(k,v);m=Math.max(m,w);p[n]=Array(v);for(l=0;l<p[n].length;l+=1)p[n][l]=255&f.B()[l+u];u+=v;l=y.C(w);v=z(p[n],l.b()-1).l(l);q[n]=Array(l.b()-1);for(l=0;l<q[n].length;l+=1)w=l+v.b()-q[n].length,q[n][l]=0<=w?v.c(w):0}for(l=f=0;l<b.length;l+=1)f+=b[l].o;f=Array(f);for(l=u=0;l<k;l+=1)for(n=0;n<b.length;n+=1)l<p[n].length&&(f[u]=p[n][l],u+=1);for(l=0;l<m;l+=1)for(n=0;n<b.length;n+=1)l<q[n].length&&(f[u]=q[n][l],u+=1);g=f}b=g;f=
-1;k=d-1;m=7;p=0;a=y.F(a);for(q=d-1;0<q;q-=2)for(6==q&&--q;;){for(n=0;2>n;n+=1)null==r[k][q-n]&&(l=!1,p<b.length&&(l=1==(b[p]>>>m&1)),a(k,q-n)&&(l=!l),r[k][q-n]=l,--m,-1==m&&(p+=1,m=7));k+=f;if(0>k||d<=k){k-=f;f=-f;break}}}var h=A[a],r=null,d=0,g=null,x=[],u={u:function(b){b=w(b);x.push(b);g=null},a:function(b,a){if(0>b||d<=b||0>a||d<=a)throw Error(b+","+a);return r[b][a]},h:function(){return d},J:function(){for(var a=0,h=0,c=0;8>c;c+=1){b(!0,c);var d=y.D(u);if(0==c||a>d)a=d,h=c}b(!1,h)}};return u}
function z(c,a){if("undefined"==typeof c.length)throw Error(c.length+"/"+a);var e=function(){for(var b=0;b<c.length&&0==c[b];)b+=1;for(var r=Array(c.length-b+a),d=0;d<c.length-b;d+=1)r[d]=c[d+b];return r}(),b={c:function(b){return e[b]},b:function(){return e.length},multiply:function(a){for(var h=Array(b.b()+a.b()-1),c=0;c<b.b();c+=1)for(var g=0;g<a.b();g+=1)h[c+g]^=v.i(v.g(b.c(c))+v.g(a.c(g)));return z(h,0)},l:function(a){if(0>b.b()-a.b())return b;for(var c=v.g(b.c(0))-v.g(a.c(0)),h=Array(b.b()),
g=0;g<b.b();g+=1)h[g]=b.c(g);for(g=0;g<a.b();g+=1)h[g]^=v.i(v.g(a.c(g))+c);return z(h,0).l(a)}};return b}C.s=function(c){for(var a=[],e=0;e<c.length;e++){var b=c.charCodeAt(e);128>b?a.push(b):2048>b?a.push(192|b>>6,128|b&63):55296>b||57344<=b?a.push(224|b>>12,128|b>>6&63,128|b&63):(e++,b=65536+((b&1023)<<10|c.charCodeAt(e)&1023),a.push(240|b>>18,128|b>>12&63,128|b>>6&63,128|b&63))}return a};var A={L:1,M:0,Q:3,H:2},y=function(){function c(b){for(var a=0;0!=b;)a+=1,b>>>=1;return a}var a=[[],[6,18],
[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],
[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],e={w:function(b){for(var a=b<<10;0<=c(a)-c(1335);)a^=1335<<c(a)-c(1335);return(b<<10|a)^21522},A:function(b){for(var a=b<<12;0<=c(a)-c(7973);)a^=7973<<c(a)-c(7973);return b<<12|a},G:function(b){return a[b-1]},F:function(b){switch(b){case 0:return function(b,a){return 0==(b+a)%2};case 1:return function(b){return 0==b%2};case 2:return function(b,a){return 0==a%3};case 3:return function(b,a){return 0==
(b+a)%3};case 4:return function(b,a){return 0==(Math.floor(b/2)+Math.floor(a/3))%2};case 5:return function(b,a){return 0==b*a%2+b*a%3};case 6:return function(b,a){return 0==(b*a%2+b*a%3)%2};case 7:return function(b,a){return 0==(b*a%3+(b+a)%2)%2};default:throw Error("bad maskPattern:"+b);}},C:function(b){for(var a=z([1],0),c=0;c<b;c+=1)a=a.multiply(z([1,v.i(c)],0));return a},f:function(b,a){if(4!=b||1>a||40<a)throw Error("mode: "+b+"; type: "+a);return 10>a?8:16},D:function(b){for(var a=b.h(),c=0,
d=0;d<a;d+=1)for(var g=0;g<a;g+=1){for(var e=0,t=b.a(d,g),p=-1;1>=p;p+=1)if(!(0>d+p||a<=d+p))for(var q=-1;1>=q;q+=1)0>g+q||a<=g+q||(0!=p||0!=q)&&t==b.a(d+p,g+q)&&(e+=1);5<e&&(c+=3+e-5)}for(d=0;d<a-1;d+=1)for(g=0;g<a-1;g+=1)if(e=0,b.a(d,g)&&(e+=1),b.a(d+1,g)&&(e+=1),b.a(d,g+1)&&(e+=1),b.a(d+1,g+1)&&(e+=1),0==e||4==e)c+=3;for(d=0;d<a;d+=1)for(g=0;g<a-6;g+=1)b.a(d,g)&&!b.a(d,g+1)&&b.a(d,g+2)&&b.a(d,g+3)&&b.a(d,g+4)&&!b.a(d,g+5)&&b.a(d,g+6)&&(c+=40);for(g=0;g<a;g+=1)for(d=0;d<a-6;d+=1)b.a(d,g)&&!b.a(d+
1,g)&&b.a(d+2,g)&&b.a(d+3,g)&&b.a(d+4,g)&&!b.a(d+5,g)&&b.a(d+6,g)&&(c+=40);for(g=e=0;g<a;g+=1)for(d=0;d<a;d+=1)b.a(d,g)&&(e+=1);return c+=Math.abs(100*e/a/a-50)/5*10}};return e}(),v=function(){for(var c=Array(256),a=Array(256),e=0;8>e;e+=1)c[e]=1<<e;for(e=8;256>e;e+=1)c[e]=c[e-4]^c[e-5]^c[e-6]^c[e-8];for(e=0;255>e;e+=1)a[c[e]]=e;return{g:function(b){if(1>b)throw Error("glog("+b+")");return a[b]},i:function(b){for(;0>b;)b+=255;for(;256<=b;)b-=255;return c[b]}}}(),t=function(){function c(b,c){switch(c){case A.L:return a[4*
(b-1)];case A.M:return a[4*(b-1)+1];case A.Q:return a[4*(b-1)+2];case A.H:return a[4*(b-1)+3]}}var a=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,
2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12,7,37,13],[5,122,98,1,123,99],[7,73,
45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,
151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],
[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],
[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],e={I:function(b,a){var e=c(b,a);if("undefined"==
typeof e)throw Error("bad rs block @ typeNumber:"+b+"/errorCorrectLevel:"+a);b=e.length/3;a=[];for(var d=0;d<b;d+=1)for(var g=e[3*d],h=e[3*d+1],t=e[3*d+2],p=0;p<g;p+=1){var q=t,f={};f.o=h;f.j=q;a.push(f)}return a}};return e}();return C}());
//# sourceMappingURL=/js/external/qr-creator.min.js.map

File diff suppressed because one or more lines are too long

View file

@ -1,14 +1,25 @@
const NOTIFICATIONS = { const NOTIFICATIONS = {
_notifications: [],
send: function (message, options = {}) { send: function (message, options = {}) {
if (!Notification || Notification.permission !== "granted") { if (!Notification || Notification.permission !== "granted") {
return; return;
} }
const notification = new Notification(message, options); const notification = new Notification(message, options);
this._notifications.push( notification );
return notification; return notification;
}, },
request_permissions: function () { close: function() {
for ( const notification of this._notifications ) {
notification?.close();
}
this._notifications.length = 0;
},
request_permission: function () {
if (Notification && Notification.permission === "granted") { if (Notification && Notification.permission === "granted") {
return; return;
} }

View file

@ -139,6 +139,7 @@ document.addEventListener("DOMContentLoaded", () => {
<div class="icon close" onclick="clear_reactions_popup()"></div> <div class="icon close" onclick="clear_reactions_popup()"></div>
<form <form
id="reactions-selection-form" id="reactions-selection-form"
action="/api/events"
data-smart="true" data-smart="true"
method="POST" method="POST"
on_reply="async (event) => { clear_reactions_popup(); await document.querySelectorAll( '[data-feed]' ).forEach((feed) => feed.__render(event)); }" on_reply="async (event) => { clear_reactions_popup(); await document.querySelectorAll( '[data-feed]' ).forEach((feed) => feed.__render(event)); }"
@ -167,6 +168,12 @@ document.addEventListener("DOMContentLoaded", () => {
generator="() => { return APP.user?.id; }" generator="() => { return APP.user?.id; }"
/> />
<input
type="hidden"
name="channel"
generator="() => { return document.body.dataset.channel; }"
/>
<input <input
type="hidden" type="hidden"
name="timestamps.created" name="timestamps.created"
@ -198,12 +205,6 @@ document.addEventListener("DOMContentLoaded", () => {
document.body.appendChild(reactions_popup); document.body.appendChild(reactions_popup);
reactions_popup_form = document.getElementById("reactions-selection-form"); reactions_popup_form = document.getElementById("reactions-selection-form");
APP.on("topic_changed", ({ topic_id }) => {
const reaction_topic_id = topic_id ?? document.body.dataset.topic;
reactions_popup_form.action = reaction_topic_id
? `/api/topics/${reaction_topic_id}/events`
: "";
});
reactions_popup_search_input = document.getElementById("reactions-search-input"); reactions_popup_search_input = document.getElementById("reactions-search-input");
reactions_popup_parent_id_input = reactions_popup_form.querySelector('[name="parent_id"]'); reactions_popup_parent_id_input = reactions_popup_form.querySelector('[name="parent_id"]');

View file

@ -1,38 +1,5 @@
<script src="/js/external/qr-creator.min.js"></script>
<script> <script>
APP.on("topics_updated", ({ topics }) => {
const topic_list = document.getElementById("topic-list");
topic_list.innerHTML = "";
for (const topic of topics.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) {
topic_list.insertAdjacentHTML(
"beforeend",
`<li id="topic-selector-${topic.id}" class="topic" data-topic-selector-for="${topic.id}"><a href="#/topic/${topic.id}/chat">${topic.name}</a></li>`,
);
}
});
function update_topic_indicators(event) {
document
.querySelectorAll("[data-topic-selector-for]")
.forEach((element) => element.classList.remove("active"));
const new_topic_id = event?.detail?.topic_id ?? document.body.dataset.topic;
if (new_topic_id) {
document
.querySelectorAll(`[data-topic-selector-for="${new_topic_id}"]`)
.forEach((element) => element.classList.add("active"));
}
for ( const watch of APP.user_watches ) {
// find the topic indicator for this watch
// if there is new stuff - TODO implement a HEAD for getting latest event id?
// add a class of 'new-content'
}
}
APP.on("topics_updated", update_topic_indicators);
APP.on("topic_changed", update_topic_indicators);
APP.on("user_logged_in", update_topic_indicators);
function clear_invite_popup() { function clear_invite_popup() {
document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove()); document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove());
} }
@ -53,8 +20,9 @@
</form>`; </form>`;
document.body.appendChild(invite_div); document.body.appendChild(invite_div);
invite_div.style.left = button.getBoundingClientRect().left + "px"; const document_width = document.body.getBoundingClientRect().width;
invite_div.style.top = button.getBoundingClientRect().top + "px"; invite_div.style.left = document_width > 800 ? button.getBoundingClientRect().left + "px" : '1rem';
invite_div.style.top = document_width > 800 ? button.getBoundingClientRect().top + "px" : '1rem';
} }
async function create_invite(click_event) { async function create_invite(click_event) {
@ -89,6 +57,8 @@
} }
const invite_code = await invite_code_response.json(); const invite_code = await invite_code_response.json();
const invite_url = `${window.location.protocol}//${window.location.host}/?invite_code=${ encodeURIComponent(invite_code.code) }`;
const qr_div_id = 'qr-div-' + Math.random().toString(36).substring(2, 8);
invite_popover.innerHTML = ` invite_popover.innerHTML = `
<div> <div>
@ -100,56 +70,25 @@
</div> </div>
<div class="share-option"> <div class="share-option">
<span class="name">Link</span> <span class="name">Link</span>
<input readonly type="text" name="code" value="${window.location.protocol + "//" + window.location.host + "/?invite_code=" + encodeURIComponent(invite_code.code)}" /> <input readonly type="text" name="code" value="${ invite_url }" />
<button onclick="navigator.clipboard.writeText('${window.location.protocol + "//" + window.location.host + "/?invite_code=" + encodeURIComponent(invite_code.code)}');" />Copy</button> <button onclick="navigator.clipboard.writeText('${ invite_url }');" />Copy</button>
</div>
<div class="share-option">
<span class="name">Scan</span>
<div id="${ qr_div_id }" class="qr-invite-container"></div>
</div> </div>
<button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button> <button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button>
</div>`; </div>`;
QrCreator.render({
text: invite_url,
radius: 0.5, // 0.0 to 0.5
ecLevel: 'H', // L, M, Q, H
fill: '#999999', // foreground color
background: null, // color or null for transparent
size: 256 // in pixels
}, document.querySelector(`#${ qr_div_id }`));
} }
document.addEventListener(
"contextmenu",
(event) => {
if (!event.target?.closest("#sidebar")) {
return;
}
const topic_selector = event.target.closest("li.topic");
if (!topic_selector) {
return;
}
event.preventDefault();
const context_menu = document.getElementById("sidebar-context-menu");
context_menu.dataset.prepare = true;
const position = get_best_coords_for_popup({
target_element: topic_selector,
popup: {
width: context_menu.getBoundingClientRect().width,
height: context_menu.getBoundingClientRect().height,
},
offset: {
x: 4,
y: 4,
},
});
context_menu.style.left = position.x + "px";
context_menu.style.top = position.y + "px";
context_menu.dataset.show = true;
},
false,
);
document.addEventListener("click", (event) => {
if (!event.target?.closest("#sidebar-context-menu")) {
const context_menu = document.getElementById("sidebar-context-menu");
delete context_menu.dataset.show;
delete context_menu.dataset.prepare;
}
});
</script> </script>
<style type="text/css"> <style type="text/css">
@ -165,6 +104,11 @@
border-right: 1px solid var(--border-subtle); border-right: 1px solid var(--border-subtle);
} }
#sidebar .profile-container {
width: 100%;
max-width: 100%;
}
#sidebar #sidebar-context-menu { #sidebar #sidebar-context-menu {
display: none; display: none;
visibility: hidden; visibility: hidden;
@ -284,46 +228,6 @@
line-height: 2rem; line-height: 2rem;
} }
#sidebar #topic-creation-container {
margin-top: 0.5rem;
}
#sidebar #topic-creation-container #toggle-topic-creation-form-button {
transform: scale(0.8);
}
#sidebar .topic-list {
list-style-type: none;
margin-left: 1rem;
}
#sidebar .topic-list > li.topic a:before {
position: absolute;
left: -1.75rem;
top: 0;
font-weight: bold;
font-size: x-large;
content: "#";
color: var(--text);
}
#sidebar .topic-list > li.topic a {
position: relative;
display: block;
width: 100%;
min-height: 1.5rem;
line-height: 1.5rem;
font-weight: bold;
font-size: large;
margin-left: 1.75rem;
text-decoration: none;
margin-bottom: 0.75rem;
}
#sidebar .topic-list > li.topic.active a {
color: var(--accent);
}
.invitepopover { .invitepopover {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
@ -351,7 +255,7 @@
.invitepopover .share-option input { .invitepopover .share-option input {
padding: 0.75rem; padding: 0.75rem;
margin: 0 1rem 1rem 0; margin: 0 0.75rem 1rem 0;
background: none; background: none;
color: var(--text); color: var(--text);
border: 1px solid var(--border-highlight); border: 1px solid var(--border-highlight);
@ -370,6 +274,12 @@
margin-left: -0.75rem; margin-left: -0.75rem;
} }
.invitepopover .qr-invite-container {
width: 256px;
height: 256px;
margin: 1rem auto;
}
@media screen and (max-width: 1200px) { @media screen and (max-width: 1200px) {
.invitepopover { .invitepopover {
margin: 0; margin: 0;
@ -382,9 +292,8 @@
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.invitepopover .share-option button { .invitepopover .share-option input {
display: block; max-width: 14rem;
margin: 0 auto 1rem;
} }
} }
</style> </style>
@ -497,7 +406,7 @@
<template id="server-list-entry-template"> <template id="server-list-entry-template">
<div class="server-list-entry"> <div class="server-list-entry">
<a href="${ server.url }"> <a href="${ server.url }">
<img class="server-icon" src="${ server.icon ?? ( server.url + '/favicon.ico' ) }" alt="${ server.name ?? server.url } icon" style="${ server.icon_background ? `--icon-background: ${ server.icon_background };` : '' }" /> <img class="server-icon" src="${ server.icon ?? ( server.url + '/favicon.ico' ) }" alt="${ server.name ?? server.url } icon" style="${ server.icon_background ? `--icon-background: ${ server.icon_background };` : '' }" loading="lazy" />
<div class="server-name">${ server.name ?? server.url }</div> <div class="server-name">${ server.name ?? server.url }</div>
</a> </a>
</div> </div>
@ -711,7 +620,7 @@
</summary> </summary>
<div class="notifications-settings-container"> <div class="notifications-settings-container">
<button class="mockup" onclick="NOTIFICATIONS.request_permission()"> <button onclick="NOTIFICATIONS.request_permission()">
Enable Notifications Enable Notifications
</button> </button>
</div> </div>
@ -760,68 +669,7 @@
<button class="primary">Log Out</button> <button class="primary">Log Out</button>
</form> </form>
<div class="topics-container"> <div id="sidebar-dynamic-container">
<div style="margin-bottom: 1rem">
<span class="title">topics</span>
</div>
<ul id="topic-list" class="topic-list"></ul>
<div id="topic-creation-container" data-requires-permission="topics.create">
<button
id="toggle-topic-creation-form-button"
onclick="((event) => {
event.preventDefault();
const topic_create_form = document.getElementById( 'topic-create' );
topic_create_form.style[ 'height' ] = topic_create_form.style[ 'height' ] === '5rem' ? '0' : '5rem';
})(event)"
>
<div class="icon plus"></div>
</button>
<form
id="topic-create"
data-smart="true"
action="/api/topics"
method="POST"
style="
margin-top: 1rem;
width: 100%;
overflow: hidden;
height: 0;
overflow: hidden;
transition: all 0.5s;
"
>
<input
id="new-topic-name-input"
type="text"
name="name"
value=""
placeholder="new topic"
/>
<input type="submit" hidden />
<script>
{
const form = document.currentScript.closest("form");
const topic_create_form = document.getElementById("topic-create");
const new_topic_name_input =
document.getElementById("new-topic-name-input");
form.on_reply = (new_topic) => {
const topic_list = document.getElementById("topic-list");
topic_list.insertAdjacentHTML(
"beforeend",
`<li id="topic-selector-${new_topic.id}" class="topic"><a href="#/topic/${new_topic.id}">${new_topic.name}</a></li>`,
);
new_topic_name_input.value = "";
window.location.hash = `/topic/${new_topic.id}/chat`;
topic_create_form.style["height"] = "0";
};
}
</script>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -14,11 +14,36 @@
background: var(--bg); background: var(--bg);
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
transition: all 0.33s; }
#auth-container {
width: 95%;
min-height: 25rem;
position: relative;
background: hsl(from var(--bg) h s calc(l/1.1) / 0.5);
max-width: 40em;
margin: 0 auto;
border-radius: calc( var(--border-radius) * 2);
overflow: hidden;
transition: all 0.33s ease;
animation: zoomsettle 0.4s ease;
}
@keyframes zoomsettle {
from {
opacity: 0;
transform: scale(1.2);
}
to {
opacity: 1;
transform: scale(1.0);
}
} }
#login-tab .tab-content { #login-tab .tab-content {
min-height: 17rem; min-height: 17rem;
overflow: hidden;
} }
#signup-tab .tab-content { #signup-tab .tab-content {
@ -31,69 +56,23 @@
display: none; display: none;
} }
#signup-login-wall .limiter {
width: 95%;
min-height: 24rem;
position: relative;
background: hsl(from var(--bg) h s calc(l/1.1));
max-width: 40em;
margin: 0 auto;
border-radius: var(--border-radius);
overflow: hidden;
}
#signup-login-wall form { #signup-login-wall form {
width: 100%; width: 100%;
padding: 1.5rem 1.5rem 0 1.5rem; padding: 1.5rem 1.5rem 0 1.5rem;
} }
</style> </style>
<!-- #include file="./signup_pitch.md" --> <div id="auth-container" class="limiter">
<!-- #include "./files/settings/signup_pitch.html" or "./files/settings/signup_pitch.md" or "./signup_pitch.default.md" -->
<div class="limiter">
<div class="tabs"> <div class="tabs">
<div id="login-tab" class="tab">
<input
type="radio"
name="signup-login-tabs"
id="login-tab-input"
class="tab-switch"
checked="checked"
/>
<label for="login-tab-input" class="tab-label">
<div class="label">Log In</div>
</label>
<div class="tab-content">
<form data-smart="true" data-method="POST" id="login-form" action="/api/auth">
<script>
{
const form = document.currentScript.closest("form");
form.on_reply = (response) => {
const user = response.user;
APP.login( user );
};
}
</script>
<div>
<input id="login-username" type="text" name="username" required />
<label class="placeholder" for="login-username">username</label>
</div>
<div>
<input id="login-password" type="password" name="password" required />
<label class="placeholder" for="login-password">password</label>
</div>
<div>
<button id="login-submit" type="submit" class="primary">Log In</button>
</div>
</form>
</div>
</div>
<div id="signup-tab" class="tab"> <div id="signup-tab" class="tab">
<input <input
type="radio" type="radio"
name="signup-login-tabs" name="signup-login-tabs"
id="signup-tab-input" id="signup-tab-input"
class="tab-switch" class="tab-switch"
checked="checked"
/> />
<label for="signup-tab-input" class="tab-label"> <label for="signup-tab-input" class="tab-label">
<div class="label">Sign Up</div> <div class="label">Sign Up</div>
@ -105,12 +84,7 @@
const form = document.currentScript.closest("form"); const form = document.currentScript.closest("form");
form.on_reply = (response) => { form.on_reply = (response) => {
const user = response.user; const user = response.user;
document.body.dataset.user = JSON.stringify(user); APP.login( user );
document.body.dataset.perms = user.permissions.join(":");
document.dispatchEvent(
new CustomEvent("user_logged_in", { detail: { user } }),
);
}; };
} }
</script> </script>
@ -140,6 +114,7 @@
id="signup-invite-code" id="signup-invite-code"
type="text" type="text"
name="invite_code" name="invite_code"
required
/> />
<label class="placeholder" for="signup-invite-code">invite code</label> <label class="placeholder" for="signup-invite-code">invite code</label>
</div> </div>
@ -147,6 +122,53 @@
</form> </form>
</div> </div>
</div> </div>
<div id="login-tab" class="tab">
<input
type="radio"
name="signup-login-tabs"
id="login-tab-input"
class="tab-switch"
/>
<label for="login-tab-input" class="tab-label">
<div class="label">Log In</div>
</label>
<div class="tab-content">
<form data-smart="true" data-method="POST" id="login-form" action="/api/auth">
<script>
{
const form = document.currentScript.closest("form");
form.on_reply = (response) => {
const user = response.user;
APP.login( user );
};
}
</script>
<div>
<input id="login-username" type="text" name="username" required />
<label class="placeholder" for="login-username">username</label>
</div>
<div>
<input id="login-password" type="password" name="password" required />
<label class="placeholder" for="login-password">password</label>
</div>
<div>
<button id="login-submit" type="submit" class="primary">Log In</button>
</div>
</form>
</div>
</div>
</div> </div>
</div> </div>
<script>
document.addEventListener( 'DOMContentLoaded', () => {
const authed_before = (document.cookie.match(
/^(?:.*;)?\s*authed_before\s*=\s*([^;]+)(?:.*)?$/,
) || [, null])[1];
if ( authed_before ) {
document.getElementById("login-tab-input").checked = true;
}
});
</script>
</div> </div>

View file

@ -1,6 +1,6 @@
# verifiedhuman.network # Welcome!
## You're here because someone else said you were a cool human. ## You're here because someone else thought you needed an invite.
### Use your invite code to gain access. ### Use your invite code to gain access.

View file

@ -141,15 +141,15 @@
</label> </label>
<div class="tab-content"> <div class="tab-content">
<div id="blurbs-container" class="container"> <div id="blurbs-container" class="container">
<!-- #include file="./README.md" --> <!-- #include "./README.md" -->
<!-- #include file="./new_blurb.html" --> <!-- #include "./new_blurb.html" -->
<div <div
id="blurbs-list" id="blurbs-list"
data-feed data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.blurbs.read' ) !== -1" data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.blurb' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=blurb,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }" data-source="/api/events?type=blurb,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true" data-longpolling="true"
data-reverse="true" data-reverse="true"
data-insert="prepend" data-insert="prepend"
@ -159,7 +159,6 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
feed.__target_element = (item) => { feed.__target_element = (item) => {
@ -210,12 +209,12 @@
data-blurb_id="${context.blurb.id}" data-blurb_id="${context.blurb.id}"
data-temp_id="${context.blurb.meta?.temp_id ?? ""}"> data-temp_id="${context.blurb.meta?.temp_id ?? ""}">
<div class="media-preview-container"> <div class="media-preview-container">
${context.blurb.data?.media?.length ? context.blurb.data.media.map(function(url) { return `<img src='${url}' />`; }).join('\n') : ''} ${context.blurb.data?.media?.length ? context.blurb.data.media.map(function(url) { return url ? `<img src='${url}' loading="lazy"/>` : ''; }).join('\n') : ''}
</div> </div>
<div class="info-container"> <div class="info-container">
<div class="avatar-container inline"> <div class="avatar-container inline">
<img src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}" alt="user avatar" /> <img src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}" alt="user avatar" loading="lazy" />
</div> </div>
<div class="username-container"> <div class="username-container">
<span class="username">${context.creator.username}</span> <span class="username">${context.creator.username}</span>
@ -228,7 +227,7 @@
<div class="content-container">${htmlify(md_to_html(context.blurb.data.blurb))}</div> <div class="content-container">${htmlify(md_to_html(context.blurb.data.blurb))}</div>
<div class="reactions-container"></div> <div class="reactions-container"></div>
<button class="icon more" commandfor="eventactionspopover"></button> <button class="icon more" commandfor="eventactionspopover"></button>
<!-- #include file="./new_blurb.html" --> <!-- #include "./new_blurb.html" -->
<div class="replies-container"></div> <div class="replies-container"></div>
</div> </div>
</template> </template>

View file

@ -25,7 +25,7 @@
display: inline-block; display: inline-block;
} }
</style> </style>
<div class="new-blurb-container" data-requires-permission="topics.blurbs.create"> <div class="new-blurb-container" data-requires-permission="events.create.blurb">
<label> <label>
<input type="checkbox" collapse-toggle /> <input type="checkbox" collapse-toggle />
<i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i> <i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i>
@ -33,6 +33,7 @@
</label> </label>
<form <form
data-smart="true" data-smart="true"
action="/api/events"
method="POST" method="POST"
class="blurb-creation-form collapsible" class="blurb-creation-form collapsible"
style=" style="
@ -40,7 +41,6 @@
width: 100%; width: 100%;
transition: all 0.5s; transition: all 0.5s;
" "
url="/api/topics/${ document.body.dataset.topic }/events"
on_reply="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }" on_reply="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }" on_parsed="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
> >

View file

@ -1,3 +1,3 @@
# Calendar # Calendar
The calendar should help people coordinate events around a topic. The calendar should help people coordinate events.

View file

@ -11,6 +11,6 @@
<div class="label">Calendar</div></label <div class="label">Calendar</div></label
> >
<div class="tab-content"> <div class="tab-content">
<!-- #include file="./README.md" --> <!-- #include "./README.md" -->
</div> </div>
</div> </div>

View file

@ -0,0 +1,232 @@
<script>
function on_channels_updated({ channels }) {
const channel_list = document.getElementById("channel-list");
if ( !channel_list ) {
setTimeout( () => {
on_channels_updated( { channels } );
}, 100 );
return;
}
channel_list.innerHTML = "";
for (const channel of channels.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) {
if ( !document.body.dataset.channel ) {
document.body.dataset.channel = APP.user?.meta?.chat?.last_channel ?? channel.id;
if ( APP.view === 'chat' ) {
window.location.hash = '/chat/channel/' + document.body.dataset.channel;
}
}
channel_list.insertAdjacentHTML(
"beforeend",
`<li id="channel-selector-${channel.id}" class="channel" data-channel-selector-for="${channel.id}"><a href="#/chat/channel/${channel.id}">${channel.name}</a></li>`,
);
}
}
APP.on("channels_updated", on_channels_updated );
APP.on( 'view_changed', async ( { previous, view, channel_id } ) => {
if ( view !== 'chat' ) {
return;
}
const previous_channel = typeof document.body.dataset.channel === 'string' ? document.body.dataset.channel : undefined;
if ( channel_id ) {
document.body.dataset.channel = channel_id;
}
else {
delete document.body.dataset.channel;
}
await APP.CHANNELS.update(); // don't force, but ensure we have channels
const target_channel_id = channel_id ?? APP.CHANNELS.CHANNEL_LIST[0]?.id;
const hash_target = `/chat` + ( target_channel_id ? `/channel/${ target_channel_id }` : '' );
if ( window.location.hash?.slice( 1 ) !== hash_target ) {
window.location.hash = hash_target;
}
});
function update_channel_indicators(event) {
document
.querySelectorAll("[data-channel-selector-for]")
.forEach((element) => element.classList.remove("active"));
const new_channel_id = event?.detail?.channel_id ?? document.body.dataset.channel;
if (new_channel_id) {
document
.querySelectorAll(`[data-channel-selector-for="${new_channel_id}"]`)
.forEach((element) => element.classList.add("active"));
}
for ( const watch of APP.user_watches ) {
// find the channel indicator for this watch
// if there is new stuff - TODO implement a HEAD for getting latest event id?
// add a class of 'new-content'
}
}
APP.on("channels_updated", update_channel_indicators);
APP.on("channel_changed", update_channel_indicators);
APP.on("user_logged_in", update_channel_indicators);
document.addEventListener(
"contextmenu",
(event) => {
if (!event.target?.closest("#sidebar")) {
return;
}
const channel_selector = event.target.closest("li.channel");
if (!channel_selector) {
return;
}
event.preventDefault();
const context_menu = document.getElementById("sidebar-context-menu");
context_menu.dataset.prepare = true;
const position = get_best_coords_for_popup({
target_element: channel_selector,
popup: {
width: context_menu.getBoundingClientRect().width,
height: context_menu.getBoundingClientRect().height,
},
offset: {
x: 4,
y: 4,
},
});
context_menu.style.left = position.x + "px";
context_menu.style.top = position.y + "px";
context_menu.dataset.show = true;
},
false,
);
document.addEventListener("click", (event) => {
if (!event.target?.closest("#sidebar-context-menu")) {
const context_menu = document.getElementById("sidebar-context-menu");
delete context_menu.dataset.show;
delete context_menu.dataset.prepare;
}
});
APP.on( 'view_changed', ( {previous, view} ) => {
const sidebar_dynamic_container = document.getElementById( 'sidebar-dynamic-container');
if ( !sidebar_dynamic_container ) {
console.error( 'could not get #sidebar-dynamic-container' );
return;
}
if ( view !== 'chat' && previous === 'chat' ) {
sidebar_dynamic_container.innerHTML = '';
delete document.body.dataset.channel;
return;
}
else if ( view !== 'chat' ) {
return;
}
const template = document.getElementById( 'channel-list-template');
sidebar_dynamic_container.innerHTML = template.innerHTML.trim();
APP.CHANNELS.update(true);
});
</script>
<style>
#channel-list-container #channel-creation-container {
margin-top: 0.5rem;
}
#channel-list-container #channel-creation-container #toggle-channel-creation-form-button {
transform: scale(0.8);
}
#channel-list-container .channel-list {
list-style-type: none;
margin-left: 1rem;
}
#channel-list-container .channel-list > li.channel a:before {
position: absolute;
left: -1.75rem;
top: 0;
font-weight: bold;
font-size: x-large;
content: "#";
color: var(--text);
}
#channel-list-container .channel-list > li.channel a {
position: relative;
display: block;
width: 100%;
min-height: 1.5rem;
line-height: 1.5rem;
font-weight: bold;
font-size: large;
margin-left: 1.75rem;
text-decoration: none;
margin-bottom: 0.75rem;
}
#channel-list-container .channel-list > li.channel.active a {
color: var(--accent);
}
</style>
<template id="channel-list-template">
<div id="channel-list-container">
<div style="margin-bottom: 1rem">
<span class="title">channels</span>
</div>
<ul id="channel-list" class="channel-list"></ul>
<div id="channel-creation-container" data-requires-permission="channels.create">
<button
id="toggle-channel-creation-form-button"
onclick="((event) => {
event.preventDefault();
const channel_create_form = document.getElementById( 'channel-create' );
channel_create_form.style[ 'height' ] = channel_create_form.style[ 'height' ] === '5rem' ? '0' : '5rem';
})(event)"
>
<div class="icon plus"></div>
</button>
<form
id="channel-create"
data-smart="true"
action="/api/channels"
method="POST"
style="
margin-top: 1rem;
width: 100%;
overflow: hidden;
height: 0;
overflow: hidden;
transition: all 0.5s;
"
on_reply="(new_channel) => {
APP.CHANNELS.update(true);
document.getElementById('new-channel-name-input').value = '';
document.getElementById('channel-create').style['height'] = '0';
window.location.hash = `/chat/channel/${new_channel.id}`;
}"
>
<input
id="new-channel-name-input"
type="text"
name="name"
value=""
placeholder="new channel"
/>
<input type="submit" hidden />
</form>
</div>
</div>
</template>

View file

@ -12,7 +12,7 @@
</label> </label>
<div class="tab-content"> <div class="tab-content">
<style> <style>
<!-- #include file="./chat.css" --> <!-- #include "./chat.css" -->
</style> </style>
<script src="/js/external/mimetypes.js" type="text/javascript"></script> <script src="/js/external/mimetypes.js" type="text/javascript"></script>
<script src="/js/external/punycode.js" type="text/javascript"></script> <script src="/js/external/punycode.js" type="text/javascript"></script>
@ -21,8 +21,8 @@
<div <div
id="chat-content" id="chat-content"
data-feed data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.chat.read' ) !== -1" data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.chat' ) !== -1 && document.body.dataset.channel"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=chat,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }" data-source="/api/channels/${ document.body.dataset.channel }/events?type=chat,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true" data-longpolling="true"
data-reverse="true" data-reverse="true"
data-insert="append" data-insert="append"
@ -32,7 +32,7 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); }); APP.on("channel_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
const time_tick_tock_timeout = 60_000; const time_tick_tock_timeout = 60_000;
@ -109,6 +109,7 @@
<img <img
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}" src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
alt="user avatar" alt="user avatar"
loading="lazy"
/> />
</div> </div>
<div class="username-container"> <div class="username-container">
@ -147,7 +148,8 @@
<form <form
id="chat-entry" id="chat-entry"
data-smart="true" data-smart="true"
data-requires-permission="topics.chat.write" action="/api/events"
data-requires-permission="events.write.chat"
method="POST" method="POST"
class="post-creation-form collapsible" class="post-creation-form collapsible"
style=" style="
@ -158,15 +160,6 @@
on_reply="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }" on_reply="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }" on_parsed="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
> >
<script>
{
const form = document.currentScript.closest("form");
APP.on( "topic_changed", ({ topic_id }) => {
form.action = topic_id ? `/api/topics/${topic_id}/events` : "";
});
}
</script>
<input type="hidden" name="type" value="chat" /> <input type="hidden" name="type" value="chat" />
<input <input
@ -188,6 +181,12 @@
generator="() => { return APP.user?.id; }" generator="() => { return APP.user?.id; }"
/> />
<input
type="hidden"
name="channel"
generator="() => { return document.body.dataset.channel; }"
/>
<input <input
type="hidden" type="hidden"
name="timestamps.created" name="timestamps.created"
@ -233,3 +232,4 @@
</div> </div>
</div> </div>
</div> </div>
<!-- #include "./channel_sidebar.html" -->

View file

@ -109,14 +109,14 @@
</label> </label>
<div class="tab-content"> <div class="tab-content">
<div id="essays-container" class="container"> <div id="essays-container" class="container">
<!-- #include file="./README.md" --> <!-- #include "./README.md" -->
<!-- #include file="./new_essay.html" --> <!-- #include "./new_essay.html" -->
<div <div
id="essays-list" id="essays-list"
data-feed data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.essays.read' ) !== -1" data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.essay' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=essay,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }" data-source="/api/events?type=essay,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true" data-longpolling="true"
data-reverse="true" data-reverse="true"
data-insert="prepend" data-insert="prepend"
@ -126,7 +126,6 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
feed.__target_element = (item) => { feed.__target_element = (item) => {
@ -171,6 +170,7 @@
${context.essay.data?.media?.length ? ${context.essay.data?.media?.length ?
context.essay.data.media.map(function(url) { return `<img context.essay.data.media.map(function(url) { return `<img
src="${url}" src="${url}"
loading="lazy"
/>` }).join('\n') : ''} />` }).join('\n') : ''}
</div> </div>
@ -179,6 +179,7 @@
<img <img
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}" src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
alt="user avatar" alt="user avatar"
loading="lazy"
/> />
</div> </div>
<div class="username-container"> <div class="username-container">

View file

@ -22,7 +22,7 @@
display: inline-block; display: inline-block;
} }
</style> </style>
<div class="new-essay-container" data-requires-permission="topics.essays.create"> <div class="new-essay-container" data-requires-permission="events.create.essay">
<label> <label>
<input type="checkbox" collapse-toggle /> <input type="checkbox" collapse-toggle />
<i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i> <i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i>
@ -31,8 +31,8 @@
<form <form
data-smart="true" data-smart="true"
method="POST" method="POST"
action="/api/events"
class="essay-creation-form collapsible" class="essay-creation-form collapsible"
url="/api/topics/${ document.body.dataset.topic }/events"
style=" style="
margin-top: 1rem margin-top: 1rem
width: 100%; width: 100%;

View file

@ -11,6 +11,6 @@
<div class="label">Exchange</div></label <div class="label">Exchange</div></label
> >
<div class="tab-content"> <div class="tab-content">
<!-- #include file="./README.md" --> <!-- #include "./README.md" -->
</div> </div>
</div> </div>

View file

@ -140,13 +140,13 @@
<div class="label">Forum</div></label <div class="label">Forum</div></label
> >
<div class="tab-content forum-container"> <div class="tab-content forum-container">
<!-- #include file="./README.md" --> <!-- #include "./README.md" -->
<div <div
id="posts-list" id="posts-list"
data-feed data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.essays.read' ) !== -1" data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.post' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=post,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }" data-source="/api/events?type=post,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true" data-longpolling="true"
data-reverse="true" data-reverse="true"
data-insert="prepend" data-insert="prepend"
@ -156,7 +156,6 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
feed.__target_element = (item) => { feed.__target_element = (item) => {
@ -209,6 +208,7 @@
<div class="media-preview-container"> <div class="media-preview-container">
<img <img
src="/images/placeholders/${String((context.post_datetime.ms % 9) + 1).padStart(2, '0')}.svg" src="/images/placeholders/${String((context.post_datetime.ms % 9) + 1).padStart(2, '0')}.svg"
loading="lazy"
/> />
</div> </div>
@ -217,6 +217,7 @@
<img <img
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}" src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
alt="user avatar" alt="user avatar"
loading="lazy"
/> />
</div> </div>
<div class="username-container"> <div class="username-container">
@ -233,7 +234,7 @@
</div> </div>
<div class="reactions-container"></div> <div class="reactions-container"></div>
<button class="icon more" commandfor="eventactionspopover"></button> <button class="icon more" commandfor="eventactionspopover"></button>
<!-- #include file="./new_post.html" --> <!-- #include "./new_post.html" -->
<div class="replies-container"></div> <div class="replies-container"></div>
</div> </div>
</template> </template>
@ -250,6 +251,6 @@
</template> </template>
</div> </div>
<!-- #include file="./new_post.html" --> <!-- #include "./new_post.html" -->
</div> </div>
</div> </div>

View file

@ -6,15 +6,15 @@
</label> </label>
<form <form
data-smart="true" data-smart="true"
data-requires-permission="topics.posts.create" data-requires-permission="events.create.post"
method="POST" method="POST"
action="/api/events"
class="post-creation-form collapsible" class="post-creation-form collapsible"
style=" style="
margin-top: 1rem margin-top: 1rem
width: 100%; width: 100%;
transition: all 0.5s; transition: all 0.5s;
" "
url="/api/topics/${ document.body.dataset.topic }/events"
on_reply="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }" on_reply="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }" on_parsed="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
> >

View file

@ -12,6 +12,6 @@
<div class="label">Home</div> <div class="label">Home</div>
</label> </label>
<div class="tab-content"> <div class="tab-content">
<!-- #include file="./README.md" --> <!-- #include "./README.md" -->
</div> </div>
</div> </div>

55
public/tabs/map/map.html Normal file
View file

@ -0,0 +1,55 @@
<link rel="stylesheet" href="/js/external/leaflet/leaflet.css"/>
<script src="/js/external/leaflet/leaflet.js"></script>
<script type="module">
var map = L.map('map').setView([33.88,-118.13], 13);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
APP.on( "view_changed", ({ view }) => {
if ( view === 'map' ) {
map.invalidateSize();
}
});
</script>
<style>
#map-tab .tab-content {
overflow: hidden;
}
#map {
position: absolute;
top: 4em;
left: 4em;
right: 4em;
bottom: 4em;
}
@media screen and (max-width: 640px) {
#map {
top: 1em;
left: 1em;
right: 1em;
bottom: 1em;
}
}
</style>
<div id="map-tab" class="tab">
<input
type="radio"
name="top-level-tabs"
id="map-tab-tab-input"
class="tab-switch"
data-view="map"
/>
<label for="map-tab-tab-input" class="tab-label"
><div class="icon map-pin"></div>
<div class="label">Map</div>
</label>
<div class="tab-content">
<div id="map"></div>
</div>
</div>

View file

@ -1,3 +1,3 @@
# Resources # Resources
Resources should be a wiki for organizing community knowledge on a topic. Resources should be a wiki for organizing community knowledge.

View file

@ -10,5 +10,5 @@
><div class="icon resources"></div> ><div class="icon resources"></div>
<div class="label">Resources</div></label <div class="label">Resources</div></label
> >
<div class="tab-content"><!-- #include file="./README.md" --></div> <div class="tab-content"><!-- #include "./README.md" --></div>
</div> </div>

View file

@ -13,8 +13,9 @@
tab_switch.addEventListener("input", (event) => { tab_switch.addEventListener("input", (event) => {
const tab_selector = event.target; const tab_selector = event.target;
const view = tab_selector.dataset.view; const view = tab_selector.dataset.view;
if (view) { if (view) {
window.location.hash = `/topic/${document.body.dataset.topic}/${view}`; window.location.hash = `/${view}${ document.body.dataset.channel ? `/channel/${ document.body.dataset.channel }` : '' }`;
} }
}); });
} }
@ -110,28 +111,15 @@
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
.tab-label { .tab-label {
width: 3rem; width: 4rem;
}
.tab-label .label {
font-size: small;
}
}
@media screen and (max-width: 400px) {
.tab-label {
width: 2.5rem;
}
.tab-label .label {
font-size: 8px;
} }
} }
</style> </style>
<div class="tabs"> <div class="tabs">
<!-- #include file="./chat/chat.html" --> <!-- #include "./chat/chat.html" -->
<!-- #include file="./blurbs/blurbs.html" --> <!-- #include "./blurbs/blurbs.html" -->
<!-- #include file="./forum/forum.html" --> <!-- #include "./forum/forum.html" -->
<!-- #include file="./essays/essays.html" --> <!-- #include "./essays/essays.html" -->
<!-- #include "./map/map.html" -->
</div> </div>

View file

@ -10,5 +10,5 @@
><div class="icon work"></div> ><div class="icon work"></div>
<div class="label">Work</div> <div class="label">Work</div>
</label> </label>
<div class="tab-content"><!-- #include file="./README.md" --></div> <div class="tab-content"><!-- #include "./README.md" --></div>
</div> </div>

1
public/title.txt Normal file
View file

@ -0,0 +1 @@
autonomous.contact

View file

@ -3,7 +3,6 @@ import * as asserts from '@std/assert';
import { USER } from '../models/user.ts'; import { USER } from '../models/user.ts';
import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, random_username } from './helpers.ts'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, random_username } from './helpers.ts';
import { encodeBase64 } from '@std/encoding'; import { encodeBase64 } from '@std/encoding';
import { generateTotp } from '../utils/totp.ts';
Deno.test({ Deno.test({
name: 'API - USERS - Create', name: 'API - USERS - Create',
@ -39,7 +38,7 @@ Deno.test({
asserts.assert(info.session); asserts.assert(info.session);
asserts.assert(info.headers); asserts.assert(info.headers);
const user = info.user; const user: USER = info.user;
asserts.assertEquals(user.username, username); asserts.assertEquals(user.username, username);

View file

@ -2,9 +2,6 @@ import { api, API_CLIENT } from '../utils/api.ts';
import * as asserts from '@std/assert'; import * as asserts from '@std/assert';
import { USER } from '../models/user.ts'; import { USER } from '../models/user.ts';
import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, random_username } from './helpers.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({ Deno.test({
name: 'API - USERS - Update', name: 'API - USERS - Update',

View file

@ -44,7 +44,7 @@ Deno.test({
} }
}); });
const authed_user: USER | undefined = auth_response.user; const _authed_user: USER | undefined = auth_response.user;
const authed_session: Record<string, any> | undefined = auth_response.session; const authed_session: Record<string, any> | undefined = auth_response.session;
cookies.push({ cookies.push({

View file

@ -0,0 +1,103 @@
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';
Deno.test({
name: 'API - CHANNELS - Create',
permissions: {
env: true,
read: true,
write: true,
net: true
},
fn: async () => {
let test_server_info: EPHEMERAL_SERVER | null = null;
try {
test_server_info = await get_ephemeral_listen_server();
const client: API_CLIENT = api({
prefix: '/api',
hostname: test_server_info.hostname,
port: test_server_info.port
});
const root_user_info = await get_new_user(client);
try {
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 channel'
}
});
asserts.assert(root_user_channel);
} catch (error) {
const reason: string = (error as Error).cause as string ?? (error as Error).toString();
asserts.fail(reason);
}
const regular_user_info = await get_new_user(client, {}, root_user_info);
try {
const _permission_denied_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: 'this should not be allowed'
}
});
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, 'channels.create']);
try {
const _too_long_name_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: 'X'.repeat(1024)
}
});
asserts.fail('allowed creation of a channel with an excessively long name');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'invalid_channel_name');
}
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 channel'
}
});
asserts.assert(new_channel);
await delete_user(client, regular_user_info);
await delete_user(client, root_user_info);
} finally {
if (test_server_info) {
await test_server_info?.server?.stop();
}
}
}
});

View file

@ -1,84 +0,0 @@
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',
permissions: {
env: true,
read: true,
write: true,
net: true
},
fn: async () => {
let test_server_info: EPHEMERAL_SERVER | null = null;
try {
test_server_info = await get_ephemeral_listen_server();
const client: API_CLIENT = api({
prefix: '/api',
hostname: test_server_info.hostname,
port: test_server_info.port
});
const info = await get_new_user(client);
try {
const _permission_denied_topic = await client.fetch('/topics', {
method: 'POST',
headers: {
'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret)
},
json: {
name: 'this should not be allowed'
}
});
asserts.fail('allowed creation of a topic without topic creation permissions');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'permission_denied');
}
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'topics.create']);
try {
const _too_long_name_topic = await client.fetch('/topics', {
method: 'POST',
headers: {
'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret)
},
json: {
name: 'X'.repeat(1024)
}
});
asserts.fail('allowed creation of a topic with an excessively long name');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'invalid_topic_name');
}
const new_topic = await client.fetch('/topics', {
method: 'POST',
headers: {
'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret)
},
json: {
name: 'test topic'
}
});
asserts.assert(new_topic);
await delete_user(client, info);
} finally {
clear_topic_events_cache();
if (test_server_info) {
await test_server_info?.server?.stop();
}
}
}
});

View file

@ -2,10 +2,9 @@ import { api, API_CLIENT } from '../utils/api.ts';
import * as asserts from '@std/assert'; 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 { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts';
import { generateTotp } from '../utils/totp.ts'; import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({ Deno.test({
name: 'API - TOPICS - Update', name: 'API - CHANNELS - Update',
permissions: { permissions: {
env: true, env: true,
read: true, read: true,
@ -24,25 +23,25 @@ Deno.test({
const info = await get_new_user(client); 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', method: 'POST',
headers: { headers: {
'x-session_id': info.session.id, 'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret) 'x-totp': await generateTotp(info.session.secret)
}, },
json: { 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); const other_user_info = await get_new_user(client, {}, info);
try { 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', method: 'PUT',
headers: { headers: {
'x-session_id': other_user_info.session.id, '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) { } catch (error) {
asserts.assertEquals((error as Error).cause, 'permission_denied'); 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', method: 'PUT',
headers: { headers: {
'x-session_id': info.session.id, 'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret) 'x-totp': await generateTotp(info.session.secret)
}, },
json: { json: {
topic: 'this is a new topic', channel: 'this is a new channel',
permissions: { permissions: {
...new_topic.permissions, ...new_channel.permissions,
write: [...new_topic.permissions.write, other_user_info.user.id] write: [...new_channel.permissions.write, other_user_info.user.id]
} }
} }
}); });
asserts.assert(updated_by_owner_topic); asserts.assert(updated_by_owner_channel);
asserts.assertEquals(updated_by_owner_topic.topic, 'this is a new topic'); asserts.assertEquals(updated_by_owner_channel.channel, 'this is a new channel');
asserts.assertEquals(updated_by_owner_topic.permissions.write, [info.user.id, other_user_info.user.id]); 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', method: 'PUT',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
'x-totp': await generateTotp(other_user_info.session.secret) 'x-totp': await generateTotp(other_user_info.session.secret)
}, },
json: { json: {
topic: 'this is a newer topic' channel: 'this is a newer channel'
} }
}); });
asserts.assert(updated_by_other_user_topic); asserts.assert(updated_by_other_user_channel);
asserts.assertEquals(updated_by_other_user_topic.topic, 'this is a newer topic'); asserts.assertEquals(updated_by_other_user_channel.channel, 'this is a newer channel');
asserts.assertEquals(updated_by_other_user_topic.permissions.write, [info.user.id, other_user_info.user.id]); 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, other_user_info);
await delete_user(client, info); await delete_user(client, info);
} finally { } finally {
clear_topic_events_cache();
if (test_server_info) { if (test_server_info) {
await test_server_info?.server?.stop(); await test_server_info?.server?.stop();
} }

View file

@ -2,10 +2,9 @@ import { api, API_CLIENT } from '../utils/api.ts';
import * as asserts from '@std/assert'; 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 { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts';
import { generateTotp } from '../utils/totp.ts'; import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({ Deno.test({
name: 'API - TOPICS - Delete', name: 'API - CHANNELS - Delete',
permissions: { permissions: {
env: true, env: true,
read: true, read: true,
@ -24,22 +23,22 @@ Deno.test({
const info = await get_new_user(client); 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', method: 'POST',
headers: { headers: {
'x-session_id': info.session.id, 'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret) 'x-totp': await generateTotp(info.session.secret)
}, },
json: { 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', method: 'DELETE',
headers: { headers: {
'x-session_id': info.session.id, '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); await delete_user(client, info);
} finally { } finally {
clear_topic_events_cache();
if (test_server_info) { if (test_server_info) {
await test_server_info?.server?.stop(); await test_server_info?.server?.stop();
} }

View file

@ -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 { 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 { api, API_CLIENT } from '../utils/api.ts';
import { generateTotp } from '../utils/totp.ts'; import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({ Deno.test({
name: 'API - TOPICS - EVENTS - Create', name: 'API - CHANNELS - EVENTS - Create',
permissions: { permissions: {
env: true, env: true,
read: true, read: true,
@ -24,25 +23,27 @@ Deno.test({
const owner_info = await get_new_user(client); 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', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret) 'x-totp': await generateTotp(owner_info.session.secret)
}, },
json: { json: {
name: 'test events topic', name: 'test events channel',
permissions: { 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', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -50,6 +51,7 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
foo: 'bar' foo: 'bar'
} }
@ -61,7 +63,7 @@ Deno.test({
const other_user_info = await get_new_user(client, {}, owner_info); const other_user_info = await get_new_user(client, {}, owner_info);
try { try {
const _permission_denied_topic = await client.fetch(`/topics/${topic.id}/events`, { const _permission_denied_channel = await client.fetch(`/events`, {
method: 'POST', method: 'POST',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
@ -69,19 +71,20 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
other_user: true 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) { } catch (error) {
asserts.assertEquals((error as Error).cause, 'permission_denied'); asserts.assertEquals((error as Error).cause, 'permission_denied');
} }
// make the topic public write // make the channel public write
const updated_by_owner_topic = await client.fetch(`/topics/${topic.id}`, { const updated_by_owner_channel = await client.fetch(`/channels/${channel.id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -89,16 +92,18 @@ Deno.test({
}, },
json: { json: {
permissions: { permissions: {
...topic.permissions, ...channel.permissions,
write_events: [] events: {
write: []
}
} }
} }
}); });
asserts.assert(updated_by_owner_topic); asserts.assert(updated_by_owner_channel);
asserts.assertEquals(updated_by_owner_topic.permissions.write_events, []); 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', method: 'POST',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
@ -106,6 +111,7 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
other_user: true other_user: true
} }
@ -117,7 +123,6 @@ Deno.test({
await delete_user(client, other_user_info); await delete_user(client, other_user_info);
await delete_user(client, owner_info); await delete_user(client, owner_info);
} finally { } finally {
clear_topic_events_cache();
if (test_server_info) { if (test_server_info) {
await test_server_info?.server?.stop(); await test_server_info?.server?.stop();
} }

View file

@ -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 { 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 { api, API_CLIENT } from '../utils/api.ts';
import { generateTotp } from '../utils/totp.ts'; import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({ Deno.test({
name: 'API - TOPICS - EVENTS - Get', name: 'API - CHANNELS - EVENTS - Get',
permissions: { permissions: {
env: true, env: true,
read: true, read: true,
@ -29,25 +28,25 @@ Deno.test({
const owner_info = await get_new_user(client); 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', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret) 'x-totp': await generateTotp(owner_info.session.secret)
}, },
json: { json: {
name: 'test get events topic' name: 'test get events channel'
} }
}); });
asserts.assert(topic); asserts.assert(channel);
const NUM_INITIAL_EVENTS = 5; const NUM_INITIAL_EVENTS = 5;
const events_initial_batch: any[] = []; const events_initial_batch: any[] = [];
for (let i = 0; i < NUM_INITIAL_EVENTS; ++i) { 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', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -55,6 +54,7 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
i i
} }
@ -69,7 +69,7 @@ Deno.test({
const other_user_info = await get_new_user(client, {}, owner_info); 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', method: 'GET',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
@ -82,7 +82,7 @@ Deno.test({
const newest_event = events_from_server[0]; const newest_event = events_from_server[0];
asserts.assert(newest_event); 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', method: 'GET',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
@ -92,7 +92,7 @@ Deno.test({
const wait_and_then_create_an_event = new Promise((resolve) => { const wait_and_then_create_an_event = new Promise((resolve) => {
setTimeout(async () => { setTimeout(async () => {
await client.fetch(`/topics/${topic.id}/events`, { await client.fetch(`/events`, {
method: 'POST', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -100,6 +100,7 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
i: 12345 i: 12345
} }
@ -120,7 +121,6 @@ Deno.test({
await delete_user(client, other_user_info); await delete_user(client, other_user_info);
await delete_user(client, owner_info); await delete_user(client, owner_info);
} finally { } finally {
clear_topic_events_cache();
if (test_server_info) { if (test_server_info) {
await test_server_info.server.stop(); await test_server_info.server.stop();
} }

View file

@ -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 { 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 { api, API_CLIENT } from '../utils/api.ts';
import { generateTotp } from '../utils/totp.ts'; import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({ Deno.test({
name: 'API - TOPICS - EVENTS - Update', name: 'API - CHANNELS - EVENTS - Update',
permissions: { permissions: {
env: true, env: true,
read: true, read: true,
@ -24,22 +23,22 @@ Deno.test({
const owner_info = await get_new_user(client); 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', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret) 'x-totp': await generateTotp(owner_info.session.secret)
}, },
json: { 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', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -47,6 +46,7 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
foo: 'bar' foo: 'bar'
} }
@ -55,7 +55,7 @@ Deno.test({
asserts.assert(event_from_owner); 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', method: 'GET',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -65,25 +65,23 @@ Deno.test({
asserts.assertEquals(fetched_event_from_owner, event_from_owner); 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', method: 'PUT',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret) 'x-totp': await generateTotp(owner_info.session.secret)
}, },
json: { json: {
type: 'other', meta: {
data: {
foo: 'baz' foo: 'baz'
} }
} }
}); });
asserts.assertNotEquals(updated_event_from_owner, event_from_owner); asserts.assertNotEquals(updated_event_from_owner, event_from_owner);
asserts.assertEquals(updated_event_from_owner.type, 'other'); asserts.assertEquals(updated_event_from_owner.meta?.foo, 'baz');
asserts.assertEquals(updated_event_from_owner.data.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', method: 'GET',
headers: { headers: {
'x-session_id': owner_info.session.id, '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 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', method: 'POST',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
@ -105,6 +103,7 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
other_user: true other_user: true
} }
@ -113,7 +112,7 @@ Deno.test({
asserts.assert(event_from_other_user); 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', method: 'GET',
headers: { headers: {
'x-session_id': other_user_info.session.id, '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); 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', method: 'PUT',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
'x-totp': await generateTotp(other_user_info.session.secret) 'x-totp': await generateTotp(other_user_info.session.secret)
}, },
json: { json: {
type: 'other',
data: { data: {
other_user: 'bloop' other_user: 'bloop'
} }
@ -138,10 +136,9 @@ Deno.test({
}); });
asserts.assertNotEquals(updated_event_from_other_user, event_from_other_user); 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'); 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', method: 'GET',
headers: { headers: {
'x-session_id': other_user_info.session.id, '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.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); 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', method: 'PUT',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -161,33 +158,38 @@ Deno.test({
}, },
json: { json: {
permissions: { permissions: {
...topic.permissions, ...channel.permissions,
write_events: [owner_info.user.id] 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 { try {
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
'x-totp': await generateTotp(other_user_info.session.secret) 'x-totp': await generateTotp(other_user_info.session.secret)
}, },
json: { 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) { } catch (error) {
asserts.assertEquals((error as Error).cause, 'permission_denied'); asserts.assertEquals((error as Error).cause, 'permission_denied');
} }
try { try {
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'x-session_id': other_user_info.session.id, '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) { } catch (error) {
asserts.assertEquals((error as Error).cause, 'permission_denied'); 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', method: 'PUT',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -208,15 +210,18 @@ Deno.test({
}, },
json: { json: {
permissions: { permissions: {
...topic.permissions, ...channel.permissions,
write_events: [] 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', method: 'DELETE',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
@ -226,7 +231,7 @@ Deno.test({
asserts.assertEquals(delete_other_user_event_response.deleted, true); 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', method: 'DELETE',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -239,7 +244,6 @@ Deno.test({
await delete_user(client, other_user_info); await delete_user(client, other_user_info);
await delete_user(client, owner_info); await delete_user(client, owner_info);
} finally { } finally {
clear_topic_events_cache();
if (test_server_info) { if (test_server_info) {
await test_server_info?.server?.stop(); await test_server_info?.server?.stop();
} }

View file

@ -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 { 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 { api, API_CLIENT } from '../utils/api.ts';
import { generateTotp } from '../utils/totp.ts'; import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({ Deno.test({
name: 'API - TOPICS - EVENTS - Update (APPEND_ONLY_EVENTS)', name: 'API - CHANNELS - EVENTS - Update (APPEND_ONLY_EVENTS)',
permissions: { permissions: {
env: true, env: true,
read: true, read: true,
@ -27,22 +26,22 @@ Deno.test({
const owner_info = await get_new_user(client); 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', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret) 'x-totp': await generateTotp(owner_info.session.secret)
}, },
json: { 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', method: 'POST',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -50,6 +49,7 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
foo: 'bar' foo: 'bar'
} }
@ -58,7 +58,7 @@ Deno.test({
asserts.assert(event_from_owner); 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', method: 'GET',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
@ -69,24 +69,26 @@ Deno.test({
asserts.assertEquals(fetched_event_from_owner, event_from_owner); asserts.assertEquals(fetched_event_from_owner, event_from_owner);
try { try {
await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { await client.fetch(`/events/${event_from_owner.id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'x-session_id': owner_info.session.id, 'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret) 'x-totp': await generateTotp(owner_info.session.secret)
}, },
json: { 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) { } catch (error) {
asserts.assertEquals((error as Error).cause, 'append_only_events'); asserts.assertEquals((error as Error).cause, 'append_only_events');
} }
try { try {
await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, { await client.fetch(`/events/${event_from_owner.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'x-session_id': owner_info.session.id, '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) { } catch (error) {
asserts.assertEquals((error as Error).cause, 'append_only_events'); asserts.assertEquals((error as Error).cause, 'append_only_events');
} }
const other_user_info = await get_new_user(client, {}, owner_info); 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', method: 'POST',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
@ -109,6 +111,7 @@ Deno.test({
}, },
json: { json: {
type: 'test', type: 'test',
channel: channel.id,
data: { data: {
other_user: true other_user: true
} }
@ -117,7 +120,7 @@ Deno.test({
asserts.assert(event_from_other_user); 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', method: 'GET',
headers: { headers: {
'x-session_id': other_user_info.session.id, '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); asserts.assertEquals(fetched_event_from_other_user, event_from_other_user);
try { try {
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'x-session_id': other_user_info.session.id, 'x-session_id': other_user_info.session.id,
'x-totp': await generateTotp(other_user_info.session.secret) 'x-totp': await generateTotp(other_user_info.session.secret)
}, },
json: { 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) { } catch (error) {
asserts.assertEquals((error as Error).cause, 'append_only_events'); asserts.assertEquals((error as Error).cause, 'append_only_events');
} }
try { try {
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, { await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
'x-session_id': other_user_info.session.id, '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) { } catch (error) {
asserts.assertEquals((error as Error).cause, 'append_only_events'); asserts.assertEquals((error as Error).cause, 'append_only_events');
} }
@ -163,7 +168,6 @@ Deno.test({
} finally { } finally {
Deno.env.delete('APPEND_ONLY_EVENTS'); Deno.env.delete('APPEND_ONLY_EVENTS');
clear_topic_events_cache();
if (test_server_info) { if (test_server_info) {
await test_server_info?.server?.stop(); await test_server_info?.server?.stop();
} }

View file

@ -1,8 +1,7 @@
import { api, API_CLIENT } from '../utils/api.ts'; import { api, API_CLIENT } from '../utils/api.ts';
import * as asserts from '@std/assert'; 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, set_user_permissions } from './helpers.ts'; import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, random_username, set_user_permissions } from './helpers.ts';
import { Cookie, getSetCookies } from '@std/http/cookie'; import { Cookie } from '@std/http/cookie';
import { generateTotp } from '../utils/totp.ts'; import { generateTotp } from '../utils/totp.ts';
import * as fs from '@std/fs'; import * as fs from '@std/fs';
import * as path from '@std/path'; import * as path from '@std/path';
@ -136,55 +135,11 @@ Deno.test({
port: test_server_info.port port: test_server_info.port
}); });
const username = random_username(); const root_user_info = await get_new_user(client);
const password = 'password'; asserts.assert(root_user_info);
const user_creation_response: Record<string, any> = await client.fetch('/users', { const regular_user_info = await get_new_user(client, {}, root_user_info);
method: 'POST', asserts.assert(regular_user_info);
json: {
username,
password
}
});
asserts.assert(user_creation_response?.user);
asserts.assert(user_creation_response?.session);
let cookies: Cookie[] = [];
const auth_response: any = await client.fetch('/auth', {
method: 'POST',
json: {
username,
password: 'password'
},
done: (response) => {
cookies = getSetCookies(response.headers);
}
});
const user: USER | undefined = auth_response.user;
asserts.assert(user);
asserts.assert(user.id);
const session: Record<string, any> | undefined = auth_response.session;
asserts.assert(session);
cookies.push({
name: 'totp',
value: await generateTotp(session?.secret ?? ''),
maxAge: 30,
expires: Date.now() + 30_000,
path: '/'
});
const headers_for_upload_request = new Headers();
for (const cookie of cookies) {
headers_for_upload_request.append(`x-${cookie.name}`, cookie.value);
}
headers_for_upload_request.append(
'cookie',
cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join('; ')
);
const upload_body = new FormData(); const upload_body = new FormData();
upload_body.append( upload_body.append(
@ -196,7 +151,10 @@ Deno.test({
`http://${test_server_info.hostname}:${test_server_info.port}/files/test_uploading_to_root_dir.txt`, `http://${test_server_info.hostname}:${test_server_info.port}/files/test_uploading_to_root_dir.txt`,
{ {
method: 'PUT', method: 'PUT',
headers: headers_for_upload_request, headers: {
'x-session_id': regular_user_info.session.id,
'x-totp': await generateTotp(regular_user_info.session.secret)
},
body: upload_body body: upload_body
} }
); );
@ -204,13 +162,16 @@ Deno.test({
asserts.assert(!disallowed_upload_response.ok); asserts.assert(!disallowed_upload_response.ok);
await disallowed_upload_response.text(); await disallowed_upload_response.text();
await set_user_permissions(client, user, session, [...user.permissions, 'files.write.all']); await set_user_permissions(client, regular_user_info.user, regular_user_info.session, [...regular_user_info.user.permissions, 'files.write.all']);
const allowed_upload_response = await fetch( const allowed_upload_response = await fetch(
`http://${test_server_info.hostname}:${test_server_info.port}/files/test_uploading_to_root_dir.txt`, `http://${test_server_info.hostname}:${test_server_info.port}/files/test_uploading_to_root_dir.txt`,
{ {
method: 'PUT', method: 'PUT',
headers: headers_for_upload_request, headers: {
'x-session_id': regular_user_info.session.id,
'x-totp': await generateTotp(regular_user_info.session.secret)
},
body: upload_body body: upload_body
} }
); );

30
utils/object_helpers.ts Normal file
View file

@ -0,0 +1,30 @@
export function flatten(obj: Record<string, any>, path?: string, result?: Record<string, any>) {
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<string, any>) {
const result: Record<string, any> = {};
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;
}

View file

@ -1,12 +1,14 @@
import { getCookies } from '@std/http/cookie'; import { getCookies } from '@std/http/cookie';
import { SESSIONS } from '../models/session.ts'; import { SESSIONS } from '../models/session.ts';
import { verifyTotp } from './totp.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 * as CANNED_RESPONSES from './canned_responses.ts';
import { EVENT } from '../models/event.ts';
export type PRECHECK = (req: Request, meta: Record<string, any>) => Promise<Response | undefined> | Response | undefined; export type PRECHECK = (req: Request, meta: Record<string, any>) => Promise<Response | undefined> | Response | undefined;
export type PRECHECK_TABLE = Record<string, PRECHECK[]>; export type PRECHECK_TABLE = Record<string, PRECHECK[]>;
export const AUTHED_BEFORE_COOKIE_ID: string = Deno.env.get('AUTHED_BEFORE_COOKIE_ID') ?? 'authed_before';
export const SESSION_ID_TOKEN: string = Deno.env.get('SESSION_ID_TOKEN') ?? 'session_id'; export const SESSION_ID_TOKEN: string = Deno.env.get('SESSION_ID_TOKEN') ?? 'session_id';
export const SESSION_SECRET_TOKEN: string = Deno.env.get('SESSION_SECRET_TOKEN') ?? 'session_secret'; export const SESSION_SECRET_TOKEN: string = Deno.env.get('SESSION_SECRET_TOKEN') ?? 'session_secret';
export const TOTP_TOKEN: string = Deno.env.get('TOTP_TOKEN') ?? 'totp'; export const TOTP_TOKEN: string = Deno.env.get('TOTP_TOKEN') ?? 'totp';
@ -41,3 +43,7 @@ export function require_user(
return CANNED_RESPONSES.permission_denied(); 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');
}

View file

@ -7,9 +7,10 @@ import { decodeBase32 } from '@std/encoding';
* *
* @ignore * @ignore
*/ */
export function counterToBuffer(counter: number): DataView { export function counterToBuffer(counter: number): ArrayBuffer {
const buffer = new DataView(new ArrayBuffer(8)); const buffer = new ArrayBuffer(8);
buffer.setBigUint64(0, BigInt(counter), false); const view = new DataView(buffer);
view.setBigUint64(0, BigInt(counter), false);
return buffer; return buffer;
} }
@ -23,7 +24,7 @@ export async function generateHmacSha1(
): Promise<Uint8Array> { ): Promise<Uint8Array> {
const importedKey = await crypto.subtle.importKey( const importedKey = await crypto.subtle.importKey(
'raw', 'raw',
key, new Uint8Array(key),
{ name: 'HMAC', hash: 'SHA-1' }, { name: 'HMAC', hash: 'SHA-1' },
false, false,
['sign'] ['sign']