Compare commits

..

11 commits

80 changed files with 31375 additions and 1360 deletions

4
.gitignore vendored
View file

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

View file

@ -1,13 +1,13 @@
# autonomous.contact
Bringing the BBS back.
A hub for communities as a single service with no required external dependencies.
## TODO
These are in no particular order. Pull requests updating this section welcome for
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] sign up
- [X] check for logged in user session
@ -21,14 +21,14 @@ feature discussions.
- [X] logout button
- [ ] profile editing
- [X] avatar uploads
- [X] chat topics
- [X] chat channels
- [X] chat messages
- [ ] membership and presence
- [ ] add memberships to topics
- [ ] add memberships to channels
- [ ] join to get notifications
- [ ] join for additional permissions
- [ ] filters for allowing joining a topic based on criteria on the user?
- [ ] display topic members somehwere
- [ ] filters for allowing joining a channel based on criteria on the user?
- [ ] display channel members somehwere
- [ ] emit presence events on join/leave
- [ ] display user presence
- [ ] chat message actions
@ -88,7 +88,7 @@ feature discussions.
- [ ] if web notifications are enabled, emit on events
- [ ] ability to mute
- [ ] users
- [ ] topics
- [ ] channels
- [ ] tags (#tags?)
- [ ] admin panel
- [ ] 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": {
"exclude": ["tests/data/"]
"exclude": [
"tests/data/"
]
},
"compilerOptions": {},
"fmt": {
"include": ["**/*.ts"],
"include": [
"**/*.ts"
],
"options": {
"useTabs": true,
"lineWidth": 180,
@ -25,22 +29,28 @@
}
},
"lint": {
"include": ["**/*.ts"],
"include": [
"**/*.ts"
],
"rules": {
"tags": ["recommended"],
"exclude": ["no-explicit-any"]
"tags": [
"recommended"
],
"exclude": [
"no-explicit-any"
]
}
},
"imports": {
"@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.1.0",
"@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.2.4",
"@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0",
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.13.0",
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.16.0",
"@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/fs": "jsr:@std/fs@^1.0.19",
"@std/http": "jsr:@std/http@^1.0.21",
"@std/fs": "jsr:@std/fs@^1.0.22",
"@std/http": "jsr:@std/http@^1.0.23",
"@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",
"specifiers": {
"jsr:@andyburke/fsdb@^1.1.0": "1.1.0",
"jsr:@andyburke/fsdb@^1.2.4": "1.2.4",
"jsr:@andyburke/lurid@0.2": "0.2.0",
"jsr:@andyburke/serverus@0.13": "0.13.0",
"jsr:@andyburke/serverus@0.16": "0.16.0",
"jsr:@da/bcrypt@*": "1.0.1",
"jsr:@da/bcrypt@^1.0.1": "1.0.1",
"jsr:@std/assert@^1.0.15": "1.0.15",
"jsr:@std/cli@^1.0.19": "1.0.23",
"jsr:@std/cli@^1.0.20": "1.0.23",
"jsr:@std/cli@^1.0.21": "1.0.23",
"jsr:@std/cli@^1.0.23": "1.0.23",
"jsr:@std/assert@^1.0.17": "1.0.17",
"jsr:@std/cli@^1.0.19": "1.0.25",
"jsr:@std/cli@^1.0.20": "1.0.25",
"jsr:@std/cli@^1.0.21": "1.0.25",
"jsr:@std/cli@^1.0.25": "1.0.25",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.6": "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.19": "1.0.19",
"jsr:@std/fs@^1.0.18": "1.0.22",
"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/http@^1.0.20": "1.0.21",
"jsr:@std/http@^1.0.21": "1.0.21",
"jsr:@std/internal@^1.0.10": "1.0.12",
"jsr:@std/http@^1.0.20": "1.0.23",
"jsr:@std/http@^1.0.23": "1.0.23",
"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/net@^1.0.6": "1.0.6",
"jsr:@std/path@^1.1.0": "1.1.2",
"jsr:@std/path@^1.1.1": "1.1.2",
"jsr:@std/path@^1.1.2": "1.1.2",
"jsr:@std/streams@^1.0.13": "1.0.13",
"jsr:@std/path@^1.1.0": "1.1.4",
"jsr:@std/path@^1.1.1": "1.1.4",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/streams@^1.0.16": "1.0.16",
"npm:@types/node@*": "22.15.15"
},
"jsr": {
"@andyburke/fsdb@1.1.0": {
"integrity": "ad2d062672137ca96df19df032b51f1c7aa3133c973a0b86eb8eaab3b4c2d47b",
"@andyburke/fsdb@1.2.4": {
"integrity": "3437078a5627d4c72d677e41c20293a47d58a3af19eda72869a12acb011064d2",
"dependencies": [
"jsr:@std/cli@^1.0.20",
"jsr:@std/fs@^1.0.18",
@ -45,8 +45,8 @@
"jsr:@std/cli@^1.0.19"
]
},
"@andyburke/serverus@0.13.0": {
"integrity": "73f451e1b68cd9be3938333b06290bfeab275361453559f40dfeab19dc4ad6d7",
"@andyburke/serverus@0.16.0": {
"integrity": "625fc3f08ddc377beb86b282d603ca6154cf38e136d916ec19a87ae4c4ed86d5",
"dependencies": [
"jsr:@std/cli@^1.0.21",
"jsr:@std/fmt@^1.0.6",
@ -59,14 +59,14 @@
"@da/bcrypt@1.0.1": {
"integrity": "d2172d3acbcff52e0465557a1a48b1ff1c92df08c90712dae5372255a8c45eb3"
},
"@std/assert@1.0.15": {
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
"@std/assert@1.0.17": {
"integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe",
"dependencies": [
"jsr:@std/internal@^1.0.12"
"jsr:@std/internal"
]
},
"@std/cli@1.0.23": {
"integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca"
"@std/cli@1.0.25": {
"integrity": "1f85051b370c97a7a9dfc6ba626e7ed57a91bea8c081597276d1e78d929d8c91"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
@ -74,27 +74,27 @@
"@std/fmt@1.0.8": {
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
},
"@std/fs@1.0.19": {
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
"@std/fs@1.0.22": {
"integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308",
"dependencies": [
"jsr:@std/internal@^1.0.9",
"jsr:@std/path@^1.1.1"
"jsr:@std/internal",
"jsr:@std/path@^1.1.4"
]
},
"@std/html@1.0.5": {
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
},
"@std/http@1.0.21": {
"integrity": "abb5c747651ee6e3ea6139858fd9b1810d2c97f53a5e6722f3b6d27a6d263edc",
"@std/http@1.0.23": {
"integrity": "6634e9e034c589bf35101c1b5ee5bbf052a5987abca20f903e58bdba85c80dee",
"dependencies": [
"jsr:@std/cli@^1.0.23",
"jsr:@std/cli@^1.0.25",
"jsr:@std/encoding",
"jsr:@std/fmt@^1.0.8",
"jsr:@std/fs@^1.0.19",
"jsr:@std/fs@^1.0.21",
"jsr:@std/html",
"jsr:@std/media-types",
"jsr:@std/net",
"jsr:@std/path@^1.1.2",
"jsr:@std/path@^1.1.4",
"jsr:@std/streams"
]
},
@ -107,14 +107,14 @@
"@std/net@1.0.6": {
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
},
"@std/path@1.1.2": {
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal@^1.0.10"
"jsr:@std/internal"
]
},
"@std/streams@1.0.13": {
"integrity": "772d208cd0d3e5dac7c1d9e6cdb25842846d136eea4a41a62e44ed4ab0c8dd9e"
"@std/streams@1.0.16": {
"integrity": "85030627befb1767c60d4f65cb30fa2f94af1d6ee6e5b2515b76157a542e89c4"
}
},
"npm": {
@ -133,16 +133,16 @@
},
"workspace": {
"dependencies": [
"jsr:@andyburke/fsdb@^1.1.0",
"jsr:@andyburke/fsdb@^1.2.4",
"jsr:@andyburke/lurid@0.2",
"jsr:@andyburke/serverus@0.13",
"jsr:@andyburke/serverus@0.16",
"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/fs@^1.0.19",
"jsr:@std/http@^1.0.21",
"jsr:@std/fs@^1.0.22",
"jsr:@std/http@^1.0.23",
"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_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers';
import { EMOJIS } from '../public/js/emojis/en.ts';
@ -16,7 +16,9 @@ import { EMOJIS } from '../public/js/emojis/en.ts';
* @property {string} creator_id - id of the source user
* @property {string} type - event type
* @property {string} [parent_id] - optional parent event id
* @property {string[]} [tags] - optional event tags
* @property {string} [channel] - optional channel
* @property {string} [topic] - optional topic
* @property {string[]} [tags] - optional tags
* @property {Record<string,any>} [data] - optional data payload of the event
* @property {TIMESTAMPS} timestamps - timestamps that will be set by the server
*/
@ -25,6 +27,8 @@ export type EVENT = {
creator_id: string;
type: string;
parent_id?: string;
channel?: string;
topic?: string;
tags?: string[];
data?: Record<string, any>;
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?
export function VALIDATE_EVENT(event: EVENT) {
const errors: any[] = [];
@ -111,6 +110,8 @@ export function VALIDATE_EVENT(event: EVENT) {
});
}
break;
case 'presence':
break;
case 'reaction':
if (typeof event.parent_id !== 'string') {
errors.push({
@ -148,78 +149,76 @@ export function VALIDATE_EVENT(event: EVENT) {
return errors.length ? errors : undefined;
}
const TOPIC_EVENT_ID_MATCHER = /^(?<event_type>.*):(?<event_id>.*)$/;
const EVENT_ID_EXTRACTOR = /^(?<event_type>.*):(?<event_id>.*)$/;
const TOPIC_EVENTS: Record<string, TOPIC_EVENT_CACHE_ENTRY> = {};
export function get_events_collection_for_topic(topic_id: string): FSDB_COLLECTION<EVENT> {
TOPIC_EVENTS[topic_id] = TOPIC_EVENTS[topic_id] ?? {
collection: new FSDB_COLLECTION<EVENT>({
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;
function smart_event_id_organizer(id: string) {
const [event_type, event_id] = id.split(':', 2);
const event_dirs = by_lurid(event_id).slice(0, -1);
return [event_type, ...event_dirs, `${id}.json`];
}
export function clear_topic_events_cache() {
for (const [topic_id, cached] of Object.entries(TOPIC_EVENTS)) {
if (cached.eviction_timeout) {
clearTimeout(cached.eviction_timeout);
export const EVENTS = new FSDB_COLLECTION<EVENT>({
name: `events`,
id_field: 'id',
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 { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers';
/**
* @typedef {object} WATCH_TYPE_INFO
* @property {boolean} ignored if true, this type should NOT produce any indications or notifications
* @property {string} last_id_seen the last event id the user has seen for this event type
* @property {string} last_id_notified the last event id the user was notified about for this type
*/
export type WATCH_TYPE_INFO = {
ignored: boolean;
last_id_seen: string;
last_id_notified: string;
};
/**
* @typedef {object} WATCH_TIMESTAMPS
* @property {string} created the created date of the watch
* @property {string} updated the last updated date, usually coinciding with the last seen id being changed
*/
export type WATCH_TIMESTAMPS = {
created: string;
updated: string;
};
/**
* WATCH
*
* @property {string} id - lurid (stable)
* @property {string} creator_id - user id of the watch creator
* @property {string} topic_id - the topic_id being watched
* @property {[WATCH_TYPE_INFO]} types - information for types being watched within this topic
* @property {string} [type] - a filter for event type
* @property {string} [parent_id] - a filter for event parent_id
* @property {string} [channel] - a filter for event channel
* @property {string} [topic] - a filter for event topic
* @property {string[]} [tags] - a filter for event tags
* @property {Record<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 {WATCH_TIMESTAMPS} timestamps - timestamps for the watch
*/
@ -35,13 +33,16 @@ export type WATCH_TYPE_INFO = {
export type WATCH = {
id: string;
creator_id: string;
topic_id: string;
types: [WATCH_TYPE_INFO];
type?: string;
parent_id?: string;
channel?: string;
topic?: string;
tags?: string[];
data?: Record<string, any>;
last_id_seen: string;
last_id_notified?: string;
meta?: Record<string, any>;
timestamps: {
created: string;
updated: string;
};
timestamps: WATCH_TIMESTAMPS;
};
export const WATCHES = new FSDB_COLLECTION<WATCH>({
@ -56,11 +57,44 @@ export const WATCHES = new FSDB_COLLECTION<WATCH>({
organize: by_lurid
}),
topic_id: new FSDB_INDEXER_SYMLINKS<WATCH>({
name: 'topic_id',
field: 'topic_id',
type: new FSDB_INDEXER_SYMLINKS<WATCH>({
name: 'type',
field: 'type',
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 { encodeBase64 } from '@std/encoding/base64';
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 { 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
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 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(`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 parse_body from '../../../../utils/bodyparser.ts';
import * as CANNED_RESPONSES from '../../../../utils/canned_responses.ts';
import { TOPIC, TOPICS } from '../../../../models/topic.ts';
import { CHANNEL, CHANNELS } from '../../../../models/channel.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/topics/:id - Get a topic
// GET /api/channels/:id - Get a channel
PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record<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"
const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null;
const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null;
if (!topic) {
if (!channel) {
return CANNED_RESPONSES.not_found();
}
meta.topic = topic;
const topic_is_public = topic.permissions.read.length === 0;
const user_has_read_for_topic = topic_is_public || topic.permissions.read.includes(meta.user.id);
meta.channel = channel;
const channel_is_public = channel.permissions.read.length === 0;
const user_has_read_for_channel = channel_is_public || channel.permissions.read.includes(meta.user.id);
if (!user_has_read_for_topic) {
if (!user_has_read_for_channel) {
return CANNED_RESPONSES.permission_denied();
}
}];
export function GET(_req: Request, meta: Record<string, any>): Response {
return Response.json(meta.topic, {
return Response.json(meta.channel, {
status: 200
});
}
// PUT /api/topics/:id - Update topic
// PUT /api/channels/:id - Update channel
PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record<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"
const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null;
const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null;
if (!topic) {
if (!channel) {
return CANNED_RESPONSES.not_found();
}
meta.topic = topic;
const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id);
meta.channel = channel;
const user_has_write_for_channel = channel.permissions.write.includes(meta.user.id);
if (!user_has_write_for_topic) {
if (!user_has_write_for_channel) {
return CANNED_RESPONSES.permission_denied();
}
}];
@ -54,16 +54,16 @@ export async function PUT(req: Request, meta: Record<string, any>): Promise<Resp
try {
const body = await parse_body(req);
const updated = {
...meta.topic,
...meta.channel,
...body,
id: meta.topic.id,
id: meta.channel.id,
timestamps: {
created: meta.topic.timestamps.created,
created: meta.channel.timestamps.created,
updated: now
}
};
await TOPICS.update(updated);
await CHANNELS.update(updated);
return Response.json(updated, {
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 = [
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"
const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null;
const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null;
if (!topic) {
if (!channel) {
return CANNED_RESPONSES.not_found();
}
meta.topic = topic;
const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id);
meta.channel = channel;
const user_has_write_for_channel = channel.permissions.write.includes(meta.user.id);
if (!user_has_write_for_topic) {
if (!user_has_write_for_channel) {
return CANNED_RESPONSES.permission_denied();
}
}
];
export async function DELETE(_req: Request, meta: Record<string, any>): Promise<Response> {
await TOPICS.delete(meta.topic);
await CHANNELS.delete(meta.channel);
return Response.json({
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 * as CANNED_RESPONSES from '../../../utils/canned_responses.ts';
import { PRECHECK_TABLE } from '../../../utils/prechecks.ts';
import { TOPIC, TOPICS } from '../../../models/topic.ts';
import { WALK_ENTRY } from '@andyburke/fsdb';
import { CHANNEL, CHANNELS } from '../../../models/channel.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/topics - get topics
// GET /api/channels - get channels
PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<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();
}
}];
export async function GET(_req: Request, meta: Record<string, any>): Promise<Response> {
const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100);
const topics = (await TOPICS.all({
limit,
filter: (entry: WALK_ENTRY<TOPIC>) => {
// 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());
const channels = (await CHANNELS.all({
limit
})).map((channel_entry) => channel_entry.load());
return Response.json(topics, {
return Response.json(channels, {
status: 200
});
}
// POST /api/topics - Create a topic
// POST /api/channels - Create a channel
PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record<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();
}
}];
@ -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) {
return Response.json({
error: {
cause: 'missing_topic_name',
message: 'You must specify a unique name for a topic.'
cause: 'missing_channel_name',
message: 'You must specify a unique name for a channel.'
}
}, {
status: 400
@ -60,8 +54,8 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
if (body.name.length > 64) {
return Response.json({
error: {
cause: 'invalid_topic_name',
message: 'topic names must be 64 characters or fewer.'
cause: 'invalid_channel_name',
message: 'channel names must be 64 characters or fewer.'
}
}, {
status: 400
@ -70,29 +64,31 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
const normalized_name = body.name.toLowerCase();
const existing_topic = (await TOPICS.find({
const existing_channel = (await CHANNELS.find({
name: normalized_name
})).shift();
if (existing_topic) {
if (existing_channel) {
return Response.json({
error: {
cause: 'topic_name_conflict',
message: 'There is already a topic with this name.'
cause: 'channel_name_conflict',
message: 'There is already a channel with this name.'
}
}, {
status: 400
});
}
const topic: TOPIC = {
const channel: CHANNEL = {
...body,
id: lurid(),
creator_id: meta.user.id,
permissions: {
read: (body.permissions?.read ?? []),
write: (body.permissions?.write ?? [meta.user.id]),
read_events: (body.permissions?.read_events ?? []),
write_events: (body.permissions?.write_events ?? [])
events: {
read: (body.permissions?.events?.read ?? []),
write: (body.permissions?.events?.write ?? [])
}
},
timestamps: {
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
});
} 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 { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts';
import { TOPIC, TOPICS } from '../../../../../models/topic.ts';
import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts';
import { EVENT, get_events_collection_for_topic, VALIDATE_EVENT } from '../../../../../models/event.ts';
import parse_body from '../../../../../utils/bodyparser.ts';
import { FSDB_COLLECTION, FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb';
import { WATCH, WATCHES } from '../../../../../models/watch.ts';
import { get_session, get_user, PRECHECK_TABLE, require_user, user_has_write_permission_for_event } from '../../../utils/prechecks.ts';
import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts';
import { EVENT, EVENTS, VALIDATE_EVENT } from '../../../models/event.ts';
import parse_body from '../../../utils/bodyparser.ts';
import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb';
import { WATCH, WATCHES } from '../../../models/watch.ts';
import { flatten } from '../../../utils/object_helpers.ts';
import { CHANNEL, CHANNELS } from '../../../models/channel.ts';
export const PRECHECKS: PRECHECK_TABLE = {};
// GET /api/topics/:topic_id/events - get topic events
// GET /api/events - get events
// query parameters:
// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted)
PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record<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_public = meta.topic.permissions.read_events.length === 0;
const user_has_read_events_for_topic = user_has_read_for_topic &&
(topic_events_are_public || meta.topic.permissions.read_events.includes(meta.user.id));
if (!user_has_read_events_for_topic) {
return CANNED_RESPONSES.permission_denied();
}
}];
PRECHECKS.GET = [get_session, get_user, require_user];
export async function GET(request: Request, meta: Record<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 key = sort_name as keyof typeof sorts;
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> = {
...(meta.query ?? {}),
limit: Math.min(parseInt(meta.query?.limit ?? '10', 10), 1_000),
limit: Math.min(parseInt(meta.query?.limit ?? '100', 10), 1_000),
offset: Math.max(parseInt(meta.query?.offset ?? '0', 10), 0),
sort,
filter: (entry: WALK_ENTRY<EVENT>) => {
@ -82,8 +61,9 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
'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())
.filter((event) => typeof event.channel === 'undefined') // channel events must be queried via the channel's api
.sort((lhs_item: EVENT, rhs_item: EVENT) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created));
// long-polling support
@ -96,7 +76,7 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
results.push(create_event.item);
clearTimeout(timeout);
events.off('create', on_create);
EVENTS.off('create', on_create);
return resolve(Response.json(results, {
status: 200,
@ -105,20 +85,20 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
}
const timeout = setTimeout(() => {
events.off('create', on_create);
EVENTS.off('create', on_create);
return resolve(Response.json(results, {
status: 200,
headers
}));
}, 60_000); // 60 seconds
events.on('create', on_create);
EVENTS.on('create', on_create);
request.signal.addEventListener('abort', () => {
events.off('create', on_create);
EVENTS.off('create', on_create);
clearTimeout(timeout);
reject(new Error('request aborted'));
});
Deno.addSignalListener('SIGINT', () => {
events.off('create', on_create);
EVENTS.off('create', on_create);
clearTimeout(timeout);
return resolve(Response.json(results, {
status: 200,
@ -134,52 +114,78 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
});
}
async function update_watches(topic: TOPIC, event: EVENT) {
async function update_watches(event: EVENT) {
const limit = 100;
let more_to_process;
let offset = 0;
do {
const watches: WATCH[] = (await WATCHES.find({
topic_id: topic.id
}, {
const watches: WATCH[] = (await WATCHES.all({
limit,
offset
})).map((entry) => entry.load());
// TODO: look at the watch .types[] and send notifications
for (const watch of watches) {
if (typeof watch.type === 'string' && event.type !== watch.type) {
continue;
}
if (typeof watch.parent_id === 'string' && event.parent_id !== watch.parent_id) {
continue;
}
if (typeof watch.channel === 'string' && event.channel !== watch.channel) {
continue;
}
if (typeof watch.topic === 'string' && event.topic !== watch.topic) {
continue;
}
if (typeof watch.tags !== 'undefined' && !watch.tags.every((tag) => event.tags?.includes(tag))) {
continue;
}
if (typeof watch.data !== 'undefined') {
const event_data = flatten(event.data ?? {});
for (const [key, value] of Object.entries(flatten(watch.data))) {
const matcher = new RegExp(value);
if (!matcher.test(event_data[key])) {
continue;
}
}
}
if (event.id < watch.last_id_seen) {
continue;
}
watch.last_id_seen = event.id;
// TODO: send a notification
console.dir({
notification: {
watch,
event
}
});
}
offset += watches.length;
more_to_process = watches.length === limit;
} while (more_to_process);
}
// POST /api/topics/:topic_id/events - Create an event
PRECHECKS.POST = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? '';
// POST /api/events - Create an event
PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
const user_can_create_events = meta.user.permissions.some((permission: string) => permission.indexOf('events.create') === 0);
// lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same"
const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null;
if (!topic) {
return CANNED_RESPONSES.not_found();
}
meta.topic = topic;
const topic_is_public: boolean = meta.topic.permissions.read.length === 0;
const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id);
const topic_events_are_publicly_writable = meta.topic.permissions.write_events.length === 0;
const user_has_write_events_for_topic = user_has_read_for_topic &&
(topic_events_are_publicly_writable || meta.topic.permissions.write_events.includes(meta.user.id));
if (!user_has_write_events_for_topic) {
if (!user_can_create_events) {
return CANNED_RESPONSES.permission_denied();
}
}];
export async function POST(req: Request, meta: Record<string, any>): Promise<Response> {
try {
const events: FSDB_COLLECTION<EVENT> = get_events_collection_for_topic(meta.topic.id);
const now = new Date().toISOString();
const body = await parse_body(req);
@ -204,11 +210,33 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
});
}
console.dir({
event
});
if (!user_has_write_permission_for_event(meta.user, 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, {
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 = {};
// 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> => {
const watch_id: string = meta.params?.watch_id?.toLowerCase().trim() ?? '';
@ -69,7 +69,7 @@ PRECHECKS.DELETE = [
return CANNED_RESPONSES.not_found();
}
meta.topic = watch;
meta.watch = watch;
const user_owns_watch = watch.creator_id === meta.user.id;
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 parse_body from '../../../../../utils/bodyparser.ts';
import lurid from '@andyburke/lurid';
import { TOPICS } from '../../../../../models/topic.ts';
import { CHANNELS } from '../../../../../models/channel.ts';
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 (!topic) {
return Response.json({
errors: [{
cause: 'invalid_topic_id',
message: 'Could not find a topic with id: ' + watch.topic_id
}]
}, {
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
});
if (watch.channel) {
const channel = await CHANNELS.get(watch.channel);
if (!channel) {
return Response.json({
errors: [{
cause: 'invalid_channel_id',
message: 'Could not find a channel with id: ' + watch.channel
}]
}, {
status: 400
});
}
}
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 { INVITE_CODE, INVITE_CODES } from '../../../models/invites.ts';
// TODO: figure out a better solution for doling out permissions
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',
'invites.create',
'invites.read.own',
'self.read',
'self.write',
'signups.read.own',
'topics.read',
'topics.blurbs.create',
'topics.blurbs.read',
'topics.blurbs.write',
'topics.chat.write',
'topics.chat.read',
'topics.essays.create',
'topics.essays.read',
'topics.essays.write',
'topics.posts.create',
'topics.posts.write',
'topics.posts.read',
'users.read',
'watches.create.own',
'watches.read.own',
@ -39,10 +46,16 @@ const DEFAULT_USER_PERMISSIONS: string[] = [
// TODO: figure out a better solution for doling out permissions
const DEFAULT_SUPERUSER_PERMISSIONS: string[] = [
...DEFAULT_USER_PERMISSIONS,
'topics.create',
'topics.delete',
'topics.write',
...DEFAULT_USER_PERMISSIONS,
'channels.create',
'channels.delete',
'channels.write',
'files.write.all',
'invites.read.all',
'signups.read.all',
'users.write',
'watches.read.all',
'watches.write.all'
];
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);
}
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) {
return Response.json({
error: {

View file

@ -16,7 +16,7 @@
--border-highlight: hsl(from var(--base-color) h 50% 75%);
--icon-scale: 1;
--border-radius: 12px;
--border-radius: 4px;
}
@media (prefers-color-scheme: light) {
@ -169,6 +169,15 @@ body {
/* fixed height? */
}
#background-container {
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
z-index: -1;
}
main {
position: relative;
width: 100%;
@ -362,21 +371,21 @@ button.primary {
body[data-perms*="self.read"] [data-requires-permission="self.read"],
body[data-perms*="self.write"] [data-requires-permission="self.write"],
body[data-perms*="topics.create"] [data-requires-permission="topics.create"],
body[data-perms*="topics.read"] [data-requires-permission="topics.read"],
body[data-perms*="topics.write"] [data-requires-permission="topics.write"],
body[data-perms*="topics.blurbs.create"] [data-requires-permission="topics.blurbs.create"],
body[data-perms*="topics.blurbs.read"] [data-requires-permission="topics.blurbs.read"],
body[data-perms*="topics.blurbs.write"] [data-requires-permission="topics.blurbs.write"],
body[data-perms*="topics.chat.create"] [data-requires-permission="topics.chat.create"],
body[data-perms*="topics.chat.read"] [data-requires-permission="topics.chat.read"],
body[data-perms*="topics.chat.write"] [data-requires-permission="topics.chat.write"],
body[data-perms*="topics.essays.create"] [data-requires-permission="topics.essays.create"],
body[data-perms*="topics.essays.read"] [data-requires-permission="topics.essays.read"],
body[data-perms*="topics.essays.write"] [data-requires-permission="topics.essays.write"],
body[data-perms*="topics.posts.create"] [data-requires-permission="topics.posts.create"],
body[data-perms*="topics.posts.read"] [data-requires-permission="topics.posts.read"],
body[data-perms*="topics.posts.write"] [data-requires-permission="topics.posts.write"],
body[data-perms*="channels.create"] [data-requires-permission="channels.create"],
body[data-perms*="channels.read"] [data-requires-permission="channels.read"],
body[data-perms*="channels.write"] [data-requires-permission="channels.write"],
body[data-perms*="events.create.blurb"] [data-requires-permission="events.create.blurb"],
body[data-perms*="events.read.blurb"] [data-requires-permission="events.read.blurb"],
body[data-perms*="events.write.blurb"] [data-requires-permission="events.write.blurb"],
body[data-perms*="events.create.chat"] [data-requires-permission="events.create.chat"],
body[data-perms*="events.read.chat"] [data-requires-permission="events.read.chat"],
body[data-perms*="events.write.chat"] [data-requires-permission="events.write.chat"],
body[data-perms*="events.create.essay"] [data-requires-permission="events.create.essay"],
body[data-perms*="events.read.essay"] [data-requires-permission="events.read.essay"],
body[data-perms*="events.write.essay"] [data-requires-permission="events.write.essay"],
body[data-perms*="events.create.post"] [data-requires-permission="events.create.post"],
body[data-perms*="events.read.post"] [data-requires-permission="events.read.post"],
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.write"] [data-requires-permission="users.write"],
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;
}
.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 */
.icon.skip-back {
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>
<meta charset="UTF-8" />
<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="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="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="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 -->
<script type="text/javascript"><!-- #include file="./js/_utils.js" --></script>
<script type="text/javascript"><!-- #include file="./js/api.js" --></script>
<script type="text/javascript"><!-- #include file="./js/app.js" --></script>
<script type="text/javascript"><!-- #include "./js/_utils.js" --></script>
<script type="text/javascript"><!-- #include "./js/api.js" --></script>
<script type="text/javascript"><!-- #include "./js/app.js" --></script>
<!-- everything else -->
<script src="./js/audioplayer.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/external/md_to_html.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/image.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/mov.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/tidal.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/emojis/en.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/locationchange.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/smartfeeds.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/totp.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/debounce.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/gif.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/mkv.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/spotify.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/youtube.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/htmlify.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/reactions.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/textareaenhancements.js" type="text/javascript"></script>
<script src="/js/totp.js" type="text/javascript"></script>
</head>
<body>
<!-- #include file="./signup_login_wall.html" -->
<div id="background-container"></div>
<!-- #include "./signup_login_wall.html" -->
<main>
<!-- #include file="./sidebar/sidebar.html" -->
<!-- #include "./sidebar/sidebar.html" -->
<!-- #include file="./tabs/tabs.html" -->
<!-- #include "./tabs/tabs.html" -->
</main>
</body>
</html>

View file

@ -5,10 +5,13 @@ const api = {
...__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(
/^(?:.*;)?\s*session_id\s*=\s*([^;]+)(?:.*)?$/,
) || [, null])[1];
// FIXME: this will break with different server settings
// TODO: this wasn't really intended to be persisted in a cookie
const session_secret = (document.cookie.match(
/^(?:.*;)?\s*session_secret\s*=\s*([^;]+)(?:.*)?$/,

View file

@ -1,7 +1,8 @@
const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)?/gm;
const UPDATE_TOPICS_FREQUENCY = 60_000;
const HASH_EXTRACTOR = /^\#\/(?<view>\w+)(?:\/channel\/(?<channel_id>[A-Za-z\-]+)\/?)?/gm;
const UPDATE_CHANNELS_FREQUENCY = 60_000;
const APP = {
user: undefined,
user_servers: [],
user_watches: [],
@ -51,61 +52,39 @@ const APP = {
extract_url_hash_info: async function () {
HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls
const {
groups: { topic_id, view },
groups: { view, channel_id },
} = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? {
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) {
const previous = document.body.dataset.view;
document.body.dataset.view = view;
console.dir({
view_changed: {
detail: {
previous,
view,
},
},
});
const previous = typeof document.body.dataset.view === 'string' ? document.body.dataset.view : undefined;
if ( view ) {
document.body.dataset.view = view;
}
else {
delete document.body.dataset.view;
}
this._emit( 'view_changed', {
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.TOPICS.update );
window.addEventListener("locationchange", this.CHANNELS.update );
this.check_if_logged_in();
this.extract_url_hash_info();
this._emit( 'load', this );
},
update_user: async function( updated_user ) {
const user = this.user = updated_user;
update_user: async function( user ) {
this.user = user;
document.body.dataset.user = JSON.stringify(user);
document.body.dataset.perms = user.permissions.join(":");
this.TOPICS.update();
this.CHANNELS.update();
this.user_servers = [];
try {
@ -223,59 +203,54 @@ const APP = {
},
},
TOPICS: {
_last_topic_update: undefined,
_update_topics_timeout: undefined,
TOPIC_LIST: [],
CHANNELS: {
_last_channel_update: undefined,
_update_channels_timeout: undefined,
CHANNEL_LIST: [],
update: async () => {
update: async ( force = false ) => {
const now = new Date();
const time_since_last_update = now - (APP.TOPICS._last_topic_update ?? 0);
if (time_since_last_update < UPDATE_TOPICS_FREQUENCY / 2) {
const time_since_last_update = now - (APP.CHANNELS._last_channel_update ?? 0);
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;
}
if (APP.TOPICS._update_topics_timeout) {
clearTimeout(APP.TOPICS._update_topics_timeout);
APP.TOPICS._update_topics_timeout = undefined;
if (APP.CHANNELS._update_channels_timeout) {
clearTimeout(APP.CHANNELS._update_channels_timeout);
APP.CHANNELS._update_channels_timeout = undefined;
}
try {
const topics_response = await api.fetch("/api/topics");
if (topics_response.ok) {
const new_topics = await topics_response.json();
const has_differences =
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
);
});
const channels_response = await api.fetch("/api/channels");
if (channels_response.ok) {
const new_channels = await channels_response.json();
APP.CHANNELS.CHANNEL_LIST = [...new_channels];
if (has_differences) {
APP.TOPICS.TOPIC_LIST = [...new_topics];
APP._emit( 'channels_updated', {
channels: APP.CHANNELS.CHANNEL_LIST
});
APP._emit( 'topics_updated', {
topics: APP.TOPICS.TOPIC_LIST
});
}
APP.TOPICS._last_topic_update = now;
APP.CHANNELS._last_channel_update = now;
}
} catch (error) {
console.error(error);
}
APP.TOPICS._update_topics_timeout = setTimeout(
APP.TOPICS.update,
UPDATE_TOPICS_FREQUENCY,
APP.CHANNELS._update_channels_timeout = setTimeout(
APP.CHANNELS.update,
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();
},
},
};
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

View file

@ -139,6 +139,7 @@ document.addEventListener("DOMContentLoaded", () => {
<div class="icon close" onclick="clear_reactions_popup()"></div>
<form
id="reactions-selection-form"
action="/api/events"
data-smart="true"
method="POST"
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; }"
/>
<input
type="hidden"
name="channel"
generator="() => { return document.body.dataset.channel; }"
/>
<input
type="hidden"
name="timestamps.created"
@ -198,12 +205,6 @@ document.addEventListener("DOMContentLoaded", () => {
document.body.appendChild(reactions_popup);
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_parent_id_input = reactions_popup_form.querySelector('[name="parent_id"]');

View file

@ -1,38 +1,4 @@
<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() {
document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove());
}
@ -106,50 +72,6 @@
<button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button>
</div>`;
}
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>
<style type="text/css">
@ -165,6 +87,11 @@
border-right: 1px solid var(--border-subtle);
}
#sidebar .profile-container {
width: 100%;
max-width: 100%;
}
#sidebar #sidebar-context-menu {
display: none;
visibility: hidden;
@ -284,46 +211,6 @@
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 {
position: fixed;
z-index: 1000;
@ -497,7 +384,7 @@
<template id="server-list-entry-template">
<div class="server-list-entry">
<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>
</a>
</div>
@ -760,68 +647,7 @@
<button class="primary">Log Out</button>
</form>
<div class="topics-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 id="sidebar-dynamic-container">
</div>
</div>
</div>

View file

@ -14,11 +14,24 @@
background: var(--bg);
visibility: visible;
opacity: 1;
transition: all 0.33s;
transition: all 0.33s ease;
animation: slideIn 0.4s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(1.2);
}
to {
opacity: 1;
transform: scale(1.0);
}
}
#login-tab .tab-content {
min-height: 17rem;
overflow: hidden;
}
#signup-tab .tab-content {
@ -48,52 +61,17 @@
}
</style>
<!-- #include file="./signup_pitch.md" -->
<!-- #include "./files/settings/signup_pitch.html" or "./files/settings/signup_pitch.md" or "./signup_pitch.default.md" -->
<div class="limiter">
<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">
<input
type="radio"
name="signup-login-tabs"
id="signup-tab-input"
class="tab-switch"
checked="checked"
/>
<label for="signup-tab-input" class="tab-label">
<div class="label">Sign Up</div>
@ -105,12 +83,7 @@
const form = document.currentScript.closest("form");
form.on_reply = (response) => {
const user = response.user;
document.body.dataset.user = JSON.stringify(user);
document.body.dataset.perms = user.permissions.join(":");
document.dispatchEvent(
new CustomEvent("user_logged_in", { detail: { user } }),
);
APP.login( user );
};
}
</script>
@ -140,6 +113,7 @@
id="signup-invite-code"
type="text"
name="invite_code"
required
/>
<label class="placeholder" for="signup-invite-code">invite code</label>
</div>
@ -147,6 +121,53 @@
</form>
</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>
<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>

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.

View file

@ -141,15 +141,15 @@
</label>
<div class="tab-content">
<div id="blurbs-container" class="container">
<!-- #include file="./README.md" -->
<!-- #include "./README.md" -->
<!-- #include file="./new_blurb.html" -->
<!-- #include "./new_blurb.html" -->
<div
id="blurbs-list"
data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.blurbs.read' ) !== -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-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.blurb' ) !== -1"
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-reverse="true"
data-insert="prepend"
@ -159,7 +159,6 @@
{
const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
feed.__target_element = (item) => {
@ -210,12 +209,12 @@
data-blurb_id="${context.blurb.id}"
data-temp_id="${context.blurb.meta?.temp_id ?? ""}">
<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 class="info-container">
<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 class="username-container">
<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="reactions-container"></div>
<button class="icon more" commandfor="eventactionspopover"></button>
<!-- #include file="./new_blurb.html" -->
<!-- #include "./new_blurb.html" -->
<div class="replies-container"></div>
</div>
</template>

View file

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

View file

@ -1,3 +1,3 @@
# 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="tab-content">
<!-- #include file="./README.md" -->
<!-- #include "./README.md" -->
</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>
<div class="tab-content">
<style>
<!-- #include file="./chat.css" -->
<!-- #include "./chat.css" -->
</style>
<script src="/js/external/mimetypes.js" type="text/javascript"></script>
<script src="/js/external/punycode.js" type="text/javascript"></script>
@ -21,8 +21,8 @@
<div
id="chat-content"
data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.chat.read' ) !== -1"
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-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.chat' ) !== -1 && document.body.dataset.channel"
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-reverse="true"
data-insert="append"
@ -32,7 +32,7 @@
{
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(); });
const time_tick_tock_timeout = 60_000;
@ -109,6 +109,7 @@
<img
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
alt="user avatar"
loading="lazy"
/>
</div>
<div class="username-container">
@ -147,7 +148,8 @@
<form
id="chat-entry"
data-smart="true"
data-requires-permission="topics.chat.write"
action="/api/events"
data-requires-permission="events.write.chat"
method="POST"
class="post-creation-form collapsible"
style="
@ -158,15 +160,6 @@
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'); }"
>
<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
@ -188,6 +181,12 @@
generator="() => { return APP.user?.id; }"
/>
<input
type="hidden"
name="channel"
generator="() => { return document.body.dataset.channel; }"
/>
<input
type="hidden"
name="timestamps.created"
@ -233,3 +232,4 @@
</div>
</div>
</div>
<!-- #include "./channel_sidebar.html" -->

View file

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

View file

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

View file

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

View file

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

View file

@ -6,15 +6,15 @@
</label>
<form
data-smart="true"
data-requires-permission="topics.posts.create"
data-requires-permission="events.create.post"
method="POST"
action="/api/events"
class="post-creation-form collapsible"
style="
margin-top: 1rem
width: 100%;
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_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>
</label>
<div class="tab-content">
<!-- #include file="./README.md" -->
<!-- #include "./README.md" -->
</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 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="label">Resources</div></label
>
<div class="tab-content"><!-- #include file="./README.md" --></div>
<div class="tab-content"><!-- #include "./README.md" --></div>
</div>

View file

@ -13,8 +13,9 @@
tab_switch.addEventListener("input", (event) => {
const tab_selector = event.target;
const view = tab_selector.dataset.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,21 @@
@media screen and (max-width: 800px) {
.tab-label {
width: 3rem;
}
.tab-label .label {
font-size: small;
width: 4rem;
}
}
@media screen and (max-width: 400px) {
.tab-label {
width: 2.5rem;
}
.tab-label .label {
font-size: 8px;
width: 3rem;
}
}
</style>
<div class="tabs">
<!-- #include file="./chat/chat.html" -->
<!-- #include file="./blurbs/blurbs.html" -->
<!-- #include file="./forum/forum.html" -->
<!-- #include file="./essays/essays.html" -->
<!-- #include "./chat/chat.html" -->
<!-- #include "./blurbs/blurbs.html" -->
<!-- #include "./forum/forum.html" -->
<!-- #include "./essays/essays.html" -->
<!-- #include "./map/map.html" -->
</div>

View file

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

View file

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

View file

@ -2,9 +2,6 @@ import { api, API_CLIENT } from '../utils/api.ts';
import * as asserts from '@std/assert';
import { USER } from '../models/user.ts';
import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, random_username } from './helpers.ts';
import { Cookie, getSetCookies } from '@std/http/cookie';
import { encodeBase64 } from '@std/encoding';
import { generateTotp } from '../utils/totp.ts';
Deno.test({
name: 'API - USERS - Update',

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;
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 { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts';
import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({
name: 'API - TOPICS - Update',
name: 'API - CHANNELS - Update',
permissions: {
env: true,
read: true,
@ -24,25 +23,25 @@ Deno.test({
const info = await get_new_user(client);
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'topics.create']);
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'channels.create']);
const new_topic = await client.fetch('/topics', {
const new_channel = await client.fetch('/channels', {
method: 'POST',
headers: {
'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret)
},
json: {
name: 'test update topic'
name: 'test update channel'
}
});
asserts.assert(new_topic);
asserts.assert(new_channel);
const other_user_info = await get_new_user(client, {}, info);
try {
const _permission_denied_topic = await client.fetch(`/topics/${new_topic.id}`, {
const _permission_denied_channel = await client.fetch(`/channels/${new_channel.id}`, {
method: 'PUT',
headers: {
'x-session_id': other_user_info.session.id,
@ -53,49 +52,48 @@ Deno.test({
}
});
asserts.fail('allowed updating a topic owned by someone else');
asserts.fail('allowed updating a channel owned by someone else');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'permission_denied');
}
const updated_by_owner_topic = await client.fetch(`/topics/${new_topic.id}`, {
const updated_by_owner_channel = await client.fetch(`/channels/${new_channel.id}`, {
method: 'PUT',
headers: {
'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret)
},
json: {
topic: 'this is a new topic',
channel: 'this is a new channel',
permissions: {
...new_topic.permissions,
write: [...new_topic.permissions.write, other_user_info.user.id]
...new_channel.permissions,
write: [...new_channel.permissions.write, other_user_info.user.id]
}
}
});
asserts.assert(updated_by_owner_topic);
asserts.assertEquals(updated_by_owner_topic.topic, 'this is a new topic');
asserts.assertEquals(updated_by_owner_topic.permissions.write, [info.user.id, other_user_info.user.id]);
asserts.assert(updated_by_owner_channel);
asserts.assertEquals(updated_by_owner_channel.channel, 'this is a new channel');
asserts.assertEquals(updated_by_owner_channel.permissions.write, [info.user.id, other_user_info.user.id]);
const updated_by_other_user_topic = await client.fetch(`/topics/${new_topic.id}`, {
const updated_by_other_user_channel = await client.fetch(`/channels/${new_channel.id}`, {
method: 'PUT',
headers: {
'x-session_id': other_user_info.session.id,
'x-totp': await generateTotp(other_user_info.session.secret)
},
json: {
topic: 'this is a newer topic'
channel: 'this is a newer channel'
}
});
asserts.assert(updated_by_other_user_topic);
asserts.assertEquals(updated_by_other_user_topic.topic, 'this is a newer topic');
asserts.assertEquals(updated_by_other_user_topic.permissions.write, [info.user.id, other_user_info.user.id]);
asserts.assert(updated_by_other_user_channel);
asserts.assertEquals(updated_by_other_user_channel.channel, 'this is a newer channel');
asserts.assertEquals(updated_by_other_user_channel.permissions.write, [info.user.id, other_user_info.user.id]);
await delete_user(client, other_user_info);
await delete_user(client, info);
} finally {
clear_topic_events_cache();
if (test_server_info) {
await test_server_info?.server?.stop();
}

View file

@ -2,10 +2,9 @@ import { api, API_CLIENT } from '../utils/api.ts';
import * as asserts from '@std/assert';
import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, set_user_permissions } from './helpers.ts';
import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({
name: 'API - TOPICS - Delete',
name: 'API - CHANNELS - Delete',
permissions: {
env: true,
read: true,
@ -24,22 +23,22 @@ Deno.test({
const info = await get_new_user(client);
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'topics.create']);
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'channels.create']);
const new_topic = await client.fetch('/topics', {
const new_channel = await client.fetch('/channels', {
method: 'POST',
headers: {
'x-session_id': info.session.id,
'x-totp': await generateTotp(info.session.secret)
},
json: {
name: 'test delete topic'
name: 'test delete channel'
}
});
asserts.assert(new_topic);
asserts.assert(new_channel);
const deleted_topic = await client.fetch(`/topics/${new_topic.id}`, {
const deleted_channel = await client.fetch(`/channels/${new_channel.id}`, {
method: 'DELETE',
headers: {
'x-session_id': info.session.id,
@ -47,11 +46,10 @@ Deno.test({
}
});
asserts.assert(deleted_topic);
asserts.assert(deleted_channel);
await delete_user(client, info);
} finally {
clear_topic_events_cache();
if (test_server_info) {
await test_server_info?.server?.stop();
}

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

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

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 { api, API_CLIENT } from '../utils/api.ts';
import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({
name: 'API - TOPICS - EVENTS - Update',
name: 'API - CHANNELS - EVENTS - Update',
permissions: {
env: true,
read: true,
@ -24,22 +23,22 @@ Deno.test({
const owner_info = await get_new_user(client);
await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']);
await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'channels.create']);
const topic = await client.fetch('/topics', {
const channel = await client.fetch('/channels', {
method: 'POST',
headers: {
'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret)
},
json: {
name: 'test update events topic'
name: 'test update events channel'
}
});
asserts.assert(topic);
asserts.assert(channel);
const event_from_owner = await client.fetch(`/topics/${topic.id}/events`, {
const event_from_owner = await client.fetch(`/events`, {
method: 'POST',
headers: {
'x-session_id': owner_info.session.id,
@ -47,6 +46,7 @@ Deno.test({
},
json: {
type: 'test',
channel: channel.id,
data: {
foo: 'bar'
}
@ -55,7 +55,7 @@ Deno.test({
asserts.assert(event_from_owner);
const fetched_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
const fetched_event_from_owner = await client.fetch(`/channels/${channel.id}/events/${event_from_owner.id}`, {
method: 'GET',
headers: {
'x-session_id': owner_info.session.id,
@ -65,25 +65,23 @@ Deno.test({
asserts.assertEquals(fetched_event_from_owner, event_from_owner);
const updated_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
const updated_event_from_owner = await client.fetch(`/events/${event_from_owner.id}`, {
method: 'PUT',
headers: {
'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret)
},
json: {
type: 'other',
data: {
meta: {
foo: 'baz'
}
}
});
asserts.assertNotEquals(updated_event_from_owner, event_from_owner);
asserts.assertEquals(updated_event_from_owner.type, 'other');
asserts.assertEquals(updated_event_from_owner.data.foo, 'baz');
asserts.assertEquals(updated_event_from_owner.meta?.foo, 'baz');
const fetched_updated_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
const fetched_updated_event_from_owner = await client.fetch(`/channels/${channel.id}/events/${event_from_owner.id}`, {
method: 'GET',
headers: {
'x-session_id': owner_info.session.id,
@ -97,7 +95,7 @@ Deno.test({
const other_user_info = await get_new_user(client, {}, owner_info);
const event_from_other_user = await client.fetch(`/topics/${topic.id}/events`, {
const event_from_other_user = await client.fetch(`/events`, {
method: 'POST',
headers: {
'x-session_id': other_user_info.session.id,
@ -105,6 +103,7 @@ Deno.test({
},
json: {
type: 'test',
channel: channel.id,
data: {
other_user: true
}
@ -113,7 +112,7 @@ Deno.test({
asserts.assert(event_from_other_user);
const fetched_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
const fetched_event_from_other_user = await client.fetch(`/channels/${channel.id}/events/${event_from_other_user.id}`, {
method: 'GET',
headers: {
'x-session_id': other_user_info.session.id,
@ -123,14 +122,13 @@ Deno.test({
asserts.assertEquals(fetched_event_from_other_user, event_from_other_user);
const updated_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
const updated_event_from_other_user = await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'PUT',
headers: {
'x-session_id': other_user_info.session.id,
'x-totp': await generateTotp(other_user_info.session.secret)
},
json: {
type: 'other',
data: {
other_user: 'bloop'
}
@ -138,10 +136,9 @@ Deno.test({
});
asserts.assertNotEquals(updated_event_from_other_user, event_from_other_user);
asserts.assertEquals(updated_event_from_other_user.type, 'other');
asserts.assertEquals(updated_event_from_other_user.data.other_user, 'bloop');
const fetched_updated_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
const fetched_updated_event_from_other_user = await client.fetch(`/channels/${channel.id}/events/${event_from_other_user.id}`, {
method: 'GET',
headers: {
'x-session_id': other_user_info.session.id,
@ -153,7 +150,7 @@ Deno.test({
asserts.assertNotEquals(fetched_updated_event_from_other_user, fetched_event_from_other_user);
asserts.assertEquals(fetched_updated_event_from_other_user, updated_event_from_other_user);
const updated_by_owner_topic = await client.fetch(`/topics/${topic.id}`, {
const updated_by_owner_channel = await client.fetch(`/channels/${channel.id}`, {
method: 'PUT',
headers: {
'x-session_id': owner_info.session.id,
@ -161,33 +158,38 @@ Deno.test({
},
json: {
permissions: {
...topic.permissions,
write_events: [owner_info.user.id]
...channel.permissions,
events: {
read: [],
write: [owner_info.user.id]
}
}
}
});
asserts.assertEquals(updated_by_owner_topic.permissions.write_events, [owner_info.user.id]);
asserts.assertEquals(updated_by_owner_channel.permissions.events.write, [owner_info.user.id]);
try {
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'PUT',
headers: {
'x-session_id': other_user_info.session.id,
'x-totp': await generateTotp(other_user_info.session.secret)
},
json: {
type: 'new'
data: {
other_user: 'glop'
}
}
});
asserts.fail('allowed updating an event in a topic with a write_events allowed only by owner');
asserts.fail('allowed updating an event in a channel with a events.write allowed only by owner');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'permission_denied');
}
try {
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'DELETE',
headers: {
'x-session_id': other_user_info.session.id,
@ -195,12 +197,12 @@ Deno.test({
}
});
asserts.fail('allowed deleting an event in a topic with a write_events allowed only by owner');
asserts.fail('allowed deleting an event in a channel with a events.write allowed only by owner');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'permission_denied');
}
const publicly_writable_topic = await client.fetch(`/topics/${topic.id}`, {
const publicly_writable_channel = await client.fetch(`/channels/${channel.id}`, {
method: 'PUT',
headers: {
'x-session_id': owner_info.session.id,
@ -208,15 +210,18 @@ Deno.test({
},
json: {
permissions: {
...topic.permissions,
write_events: []
...channel.permissions,
events: {
read: [],
write: []
}
}
}
});
asserts.assertEquals(publicly_writable_topic.permissions.write_events, []);
asserts.assertEquals(publicly_writable_channel.permissions.events.write, []);
const delete_other_user_event_response = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
const delete_other_user_event_response = await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'DELETE',
headers: {
'x-session_id': other_user_info.session.id,
@ -226,7 +231,7 @@ Deno.test({
asserts.assertEquals(delete_other_user_event_response.deleted, true);
const delete_owner_event_response = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
const delete_owner_event_response = await client.fetch(`/events/${event_from_owner.id}`, {
method: 'DELETE',
headers: {
'x-session_id': owner_info.session.id,
@ -239,7 +244,6 @@ Deno.test({
await delete_user(client, other_user_info);
await delete_user(client, owner_info);
} finally {
clear_topic_events_cache();
if (test_server_info) {
await test_server_info?.server?.stop();
}

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 { api, API_CLIENT } from '../utils/api.ts';
import { generateTotp } from '../utils/totp.ts';
import { clear_topic_events_cache } from '../models/event.ts';
Deno.test({
name: 'API - TOPICS - EVENTS - Update (APPEND_ONLY_EVENTS)',
name: 'API - CHANNELS - EVENTS - Update (APPEND_ONLY_EVENTS)',
permissions: {
env: true,
read: true,
@ -27,22 +26,22 @@ Deno.test({
const owner_info = await get_new_user(client);
await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']);
await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'channels.create']);
const topic = await client.fetch('/topics', {
const channel = await client.fetch('/channels', {
method: 'POST',
headers: {
'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret)
},
json: {
name: 'test update events topic in append only mode'
name: 'test update events channel in append only mode'
}
});
asserts.assert(topic);
asserts.assert(channel);
const event_from_owner = await client.fetch(`/topics/${topic.id}/events`, {
const event_from_owner = await client.fetch(`/events`, {
method: 'POST',
headers: {
'x-session_id': owner_info.session.id,
@ -50,6 +49,7 @@ Deno.test({
},
json: {
type: 'test',
channel: channel.id,
data: {
foo: 'bar'
}
@ -58,7 +58,7 @@ Deno.test({
asserts.assert(event_from_owner);
const fetched_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
const fetched_event_from_owner = await client.fetch(`/channels/${channel.id}/events/${event_from_owner.id}`, {
method: 'GET',
headers: {
'x-session_id': owner_info.session.id,
@ -69,24 +69,26 @@ Deno.test({
asserts.assertEquals(fetched_event_from_owner, event_from_owner);
try {
await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
await client.fetch(`/events/${event_from_owner.id}`, {
method: 'PUT',
headers: {
'x-session_id': owner_info.session.id,
'x-totp': await generateTotp(owner_info.session.secret)
},
json: {
type: 'new'
meta: {
foo: 'bar'
}
}
});
asserts.fail('allowed updating an event in a topic with APPEND_ONLY_EVENTS on');
asserts.fail('allowed updating an event in a channel with APPEND_ONLY_EVENTS on');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'append_only_events');
}
try {
await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
await client.fetch(`/events/${event_from_owner.id}`, {
method: 'DELETE',
headers: {
'x-session_id': owner_info.session.id,
@ -94,14 +96,14 @@ Deno.test({
}
});
asserts.fail('allowed deleting an event in a topic with APPEND_ONLY_EVENTS on');
asserts.fail('allowed deleting an event in a channel with APPEND_ONLY_EVENTS on');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'append_only_events');
}
const other_user_info = await get_new_user(client, {}, owner_info);
const event_from_other_user = await client.fetch(`/topics/${topic.id}/events`, {
const event_from_other_user = await client.fetch(`/events`, {
method: 'POST',
headers: {
'x-session_id': other_user_info.session.id,
@ -109,6 +111,7 @@ Deno.test({
},
json: {
type: 'test',
channel: channel.id,
data: {
other_user: true
}
@ -117,7 +120,7 @@ Deno.test({
asserts.assert(event_from_other_user);
const fetched_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
const fetched_event_from_other_user = await client.fetch(`/channels/${channel.id}/events/${event_from_other_user.id}`, {
method: 'GET',
headers: {
'x-session_id': other_user_info.session.id,
@ -128,24 +131,26 @@ Deno.test({
asserts.assertEquals(fetched_event_from_other_user, event_from_other_user);
try {
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'PUT',
headers: {
'x-session_id': other_user_info.session.id,
'x-totp': await generateTotp(other_user_info.session.secret)
},
json: {
type: 'new'
meta: {
foo: 'bar'
}
}
});
asserts.fail('allowed updating an event in a topic with APPEND_ONLY_EVENTS on');
asserts.fail('allowed updating an event in a channel with APPEND_ONLY_EVENTS on');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'append_only_events');
}
try {
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
await client.fetch(`/events/${event_from_other_user.id}`, {
method: 'DELETE',
headers: {
'x-session_id': other_user_info.session.id,
@ -153,7 +158,7 @@ Deno.test({
}
});
asserts.fail('allowed deleting an event in a topic with APPEND_ONLY_EVENTS on');
asserts.fail('allowed deleting an event in a channel with APPEND_ONLY_EVENTS on');
} catch (error) {
asserts.assertEquals((error as Error).cause, 'append_only_events');
}
@ -163,7 +168,6 @@ Deno.test({
} finally {
Deno.env.delete('APPEND_ONLY_EVENTS');
clear_topic_events_cache();
if (test_server_info) {
await test_server_info?.server?.stop();
}

View file

@ -1,8 +1,7 @@
import { api, API_CLIENT } from '../utils/api.ts';
import * as asserts from '@std/assert';
import { USER } from '../models/user.ts';
import { delete_user, EPHEMERAL_SERVER, get_ephemeral_listen_server, get_new_user, random_username, 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 * as fs from '@std/fs';
import * as path from '@std/path';
@ -136,55 +135,11 @@ Deno.test({
port: test_server_info.port
});
const username = random_username();
const password = 'password';
const root_user_info = await get_new_user(client);
asserts.assert(root_user_info);
const user_creation_response: Record<string, any> = await client.fetch('/users', {
method: 'POST',
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 regular_user_info = await get_new_user(client, {}, root_user_info);
asserts.assert(regular_user_info);
const upload_body = new FormData();
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`,
{
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
}
);
@ -204,13 +162,16 @@ Deno.test({
asserts.assert(!disallowed_upload_response.ok);
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(
`http://${test_server_info.hostname}:${test_server_info.port}/files/test_uploading_to_root_dir.txt`,
{
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
}
);

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 { SESSIONS } from '../models/session.ts';
import { verifyTotp } from './totp.ts';
import { USERS } from '../models/user.ts';
import { USER, USERS } from '../models/user.ts';
import * as CANNED_RESPONSES from './canned_responses.ts';
import { EVENT } from '../models/event.ts';
export type PRECHECK = (req: Request, meta: Record<string, any>) => Promise<Response | undefined> | Response | undefined;
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_SECRET_TOKEN: string = Deno.env.get('SESSION_SECRET_TOKEN') ?? 'session_secret';
export const TOTP_TOKEN: string = Deno.env.get('TOTP_TOKEN') ?? 'totp';
@ -41,3 +43,7 @@ export function require_user(
return CANNED_RESPONSES.permission_denied();
}
}
export function user_has_write_permission_for_event(user: USER, event: EVENT) {
return user.permissions.includes('events.create.' + event.type) || (Deno.env.get('DENO_ENV') === 'test' && event.type === 'test');
}

View file

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