Compare commits
No commits in common. "dev" and "tr-fixes" have entirely different histories.
80 changed files with 1378 additions and 31393 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,4 +1,4 @@
|
|||
data/
|
||||
.fsdb*
|
||||
.fsdb
|
||||
public/files/*
|
||||
.vscode/*
|
||||
14
README.md
14
README.md
|
|
@ -1,13 +1,13 @@
|
|||
# autonomous.contact
|
||||
|
||||
A hub for communities as a single service with no required external dependencies.
|
||||
Bringing the BBS back.
|
||||
|
||||
## TODO
|
||||
|
||||
These are in no particular order. Pull requests updating this section welcome for
|
||||
feature discussions.
|
||||
|
||||
- [X] the core is a stream of events
|
||||
- [X] should everything be an event in a topic?
|
||||
- [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 channels
|
||||
- [X] chat topics
|
||||
- [X] chat messages
|
||||
- [ ] membership and presence
|
||||
- [ ] add memberships to channels
|
||||
- [ ] add memberships to topics
|
||||
- [ ] join to get notifications
|
||||
- [ ] join for additional permissions
|
||||
- [ ] filters for allowing joining a channel based on criteria on the user?
|
||||
- [ ] display channel members somehwere
|
||||
- [ ] filters for allowing joining a topic based on criteria on the user?
|
||||
- [ ] display topic 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
|
||||
- [ ] channels
|
||||
- [ ] topics
|
||||
- [ ] tags (#tags?)
|
||||
- [ ] admin panel
|
||||
- [ ] add invite code generation
|
||||
|
|
|
|||
32
deno.json
32
deno.json
|
|
@ -11,15 +11,11 @@
|
|||
"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,
|
||||
|
|
@ -29,28 +25,22 @@
|
|||
}
|
||||
},
|
||||
"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.2.4",
|
||||
"@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.1.0",
|
||||
"@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0",
|
||||
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.16.0",
|
||||
"@andyburke/serverus": "jsr:@andyburke/serverus@^0.13.0",
|
||||
"@da/bcrypt": "jsr:@da/bcrypt@^1.0.1",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.17",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.15",
|
||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.22",
|
||||
"@std/http": "jsr:@std/http@^1.0.23",
|
||||
"@std/fs": "jsr:@std/fs@^1.0.19",
|
||||
"@std/http": "jsr:@std/http@^1.0.21",
|
||||
"@std/media-types": "jsr:@std/media-types@^1.1.0",
|
||||
"@std/path": "jsr:@std/path@^1.1.4"
|
||||
"@std/path": "jsr:@std/path@^1.1.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
deno.lock
generated
92
deno.lock
generated
|
|
@ -1,38 +1,38 @@
|
|||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@andyburke/fsdb@^1.2.4": "1.2.4",
|
||||
"jsr:@andyburke/fsdb@^1.1.0": "1.1.0",
|
||||
"jsr:@andyburke/lurid@0.2": "0.2.0",
|
||||
"jsr:@andyburke/serverus@0.16": "0.16.0",
|
||||
"jsr:@andyburke/serverus@0.13": "0.13.0",
|
||||
"jsr:@da/bcrypt@*": "1.0.1",
|
||||
"jsr:@da/bcrypt@^1.0.1": "1.0.1",
|
||||
"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/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/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.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/fs@^1.0.18": "1.0.19",
|
||||
"jsr:@std/fs@^1.0.19": "1.0.19",
|
||||
"jsr:@std/html@^1.0.5": "1.0.5",
|
||||
"jsr:@std/http@^1.0.20": "1.0.23",
|
||||
"jsr:@std/http@^1.0.23": "1.0.23",
|
||||
"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/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.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",
|
||||
"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",
|
||||
"npm:@types/node@*": "22.15.15"
|
||||
},
|
||||
"jsr": {
|
||||
"@andyburke/fsdb@1.2.4": {
|
||||
"integrity": "3437078a5627d4c72d677e41c20293a47d58a3af19eda72869a12acb011064d2",
|
||||
"@andyburke/fsdb@1.1.0": {
|
||||
"integrity": "ad2d062672137ca96df19df032b51f1c7aa3133c973a0b86eb8eaab3b4c2d47b",
|
||||
"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.16.0": {
|
||||
"integrity": "625fc3f08ddc377beb86b282d603ca6154cf38e136d916ec19a87ae4c4ed86d5",
|
||||
"@andyburke/serverus@0.13.0": {
|
||||
"integrity": "73f451e1b68cd9be3938333b06290bfeab275361453559f40dfeab19dc4ad6d7",
|
||||
"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.17": {
|
||||
"integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe",
|
||||
"@std/assert@1.0.15": {
|
||||
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
"jsr:@std/internal@^1.0.12"
|
||||
]
|
||||
},
|
||||
"@std/cli@1.0.25": {
|
||||
"integrity": "1f85051b370c97a7a9dfc6ba626e7ed57a91bea8c081597276d1e78d929d8c91"
|
||||
"@std/cli@1.0.23": {
|
||||
"integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca"
|
||||
},
|
||||
"@std/encoding@1.0.10": {
|
||||
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||
|
|
@ -74,27 +74,27 @@
|
|||
"@std/fmt@1.0.8": {
|
||||
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
|
||||
},
|
||||
"@std/fs@1.0.22": {
|
||||
"integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308",
|
||||
"@std/fs@1.0.19": {
|
||||
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal",
|
||||
"jsr:@std/path@^1.1.4"
|
||||
"jsr:@std/internal@^1.0.9",
|
||||
"jsr:@std/path@^1.1.1"
|
||||
]
|
||||
},
|
||||
"@std/html@1.0.5": {
|
||||
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
|
||||
},
|
||||
"@std/http@1.0.23": {
|
||||
"integrity": "6634e9e034c589bf35101c1b5ee5bbf052a5987abca20f903e58bdba85c80dee",
|
||||
"@std/http@1.0.21": {
|
||||
"integrity": "abb5c747651ee6e3ea6139858fd9b1810d2c97f53a5e6722f3b6d27a6d263edc",
|
||||
"dependencies": [
|
||||
"jsr:@std/cli@^1.0.25",
|
||||
"jsr:@std/cli@^1.0.23",
|
||||
"jsr:@std/encoding",
|
||||
"jsr:@std/fmt@^1.0.8",
|
||||
"jsr:@std/fs@^1.0.21",
|
||||
"jsr:@std/fs@^1.0.19",
|
||||
"jsr:@std/html",
|
||||
"jsr:@std/media-types",
|
||||
"jsr:@std/net",
|
||||
"jsr:@std/path@^1.1.4",
|
||||
"jsr:@std/path@^1.1.2",
|
||||
"jsr:@std/streams"
|
||||
]
|
||||
},
|
||||
|
|
@ -107,14 +107,14 @@
|
|||
"@std/net@1.0.6": {
|
||||
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
|
||||
},
|
||||
"@std/path@1.1.4": {
|
||||
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
|
||||
"@std/path@1.1.2": {
|
||||
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
"jsr:@std/internal@^1.0.10"
|
||||
]
|
||||
},
|
||||
"@std/streams@1.0.16": {
|
||||
"integrity": "85030627befb1767c60d4f65cb30fa2f94af1d6ee6e5b2515b76157a542e89c4"
|
||||
"@std/streams@1.0.13": {
|
||||
"integrity": "772d208cd0d3e5dac7c1d9e6cdb25842846d136eea4a41a62e44ed4ab0c8dd9e"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
|
|
@ -133,16 +133,16 @@
|
|||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@andyburke/fsdb@^1.2.4",
|
||||
"jsr:@andyburke/fsdb@^1.1.0",
|
||||
"jsr:@andyburke/lurid@0.2",
|
||||
"jsr:@andyburke/serverus@0.16",
|
||||
"jsr:@andyburke/serverus@0.13",
|
||||
"jsr:@da/bcrypt@^1.0.1",
|
||||
"jsr:@std/assert@^1.0.17",
|
||||
"jsr:@std/assert@^1.0.15",
|
||||
"jsr:@std/encoding@^1.0.10",
|
||||
"jsr:@std/fs@^1.0.22",
|
||||
"jsr:@std/http@^1.0.23",
|
||||
"jsr:@std/fs@^1.0.19",
|
||||
"jsr:@std/http@^1.0.21",
|
||||
"jsr:@std/media-types@^1.1.0",
|
||||
"jsr:@std/path@^1.1.4"
|
||||
"jsr:@std/path@^1.1.2"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,91 +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} 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
|
||||
})
|
||||
}
|
||||
});
|
||||
153
models/event.ts
153
models/event.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { by_lurid } from '@andyburke/fsdb/organizers';
|
||||
import { by_character, 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,9 +16,7 @@ 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} [channel] - optional channel
|
||||
* @property {string} [topic] - optional topic
|
||||
* @property {string[]} [tags] - optional tags
|
||||
* @property {string[]} [tags] - optional event tags
|
||||
* @property {Record<string,any>} [data] - optional data payload of the event
|
||||
* @property {TIMESTAMPS} timestamps - timestamps that will be set by the server
|
||||
*/
|
||||
|
|
@ -27,8 +25,6 @@ export type EVENT = {
|
|||
creator_id: string;
|
||||
type: string;
|
||||
parent_id?: string;
|
||||
channel?: string;
|
||||
topic?: string;
|
||||
tags?: string[];
|
||||
data?: Record<string, any>;
|
||||
timestamps: {
|
||||
|
|
@ -37,6 +33,11 @@ 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[] = [];
|
||||
|
|
@ -110,8 +111,6 @@ export function VALIDATE_EVENT(event: EVENT) {
|
|||
});
|
||||
}
|
||||
break;
|
||||
case 'presence':
|
||||
break;
|
||||
case 'reaction':
|
||||
if (typeof event.parent_id !== 'string') {
|
||||
errors.push({
|
||||
|
|
@ -149,76 +148,78 @@ export function VALIDATE_EVENT(event: EVENT) {
|
|||
return errors.length ? errors : undefined;
|
||||
}
|
||||
|
||||
const EVENT_ID_EXTRACTOR = /^(?<event_type>.*):(?<event_id>.*)$/;
|
||||
const TOPIC_EVENT_ID_MATCHER = /^(?<event_type>.*):(?<event_id>.*)$/;
|
||||
|
||||
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`];
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
export function clear_topic_events_cache() {
|
||||
for (const [topic_id, cached] of Object.entries(TOPIC_EVENTS)) {
|
||||
if (cached.eviction_timeout) {
|
||||
clearTimeout(cached.eviction_timeout);
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
delete TOPIC_EVENTS[topic_id];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
87
models/topic.ts
Normal file
87
models/topic.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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
|
||||
})
|
||||
}
|
||||
});
|
||||
|
|
@ -2,30 +2,32 @@ 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} [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 {string} topic_id - the topic_id being watched
|
||||
* @property {[WATCH_TYPE_INFO]} types - information for types being watched within this topic
|
||||
* @property {Record<string,any>} [meta] - optional metadata about the watch
|
||||
* @property {WATCH_TIMESTAMPS} timestamps - timestamps for the watch
|
||||
*/
|
||||
|
|
@ -33,16 +35,13 @@ export type WATCH_TIMESTAMPS = {
|
|||
export type WATCH = {
|
||||
id: string;
|
||||
creator_id: string;
|
||||
type?: string;
|
||||
parent_id?: string;
|
||||
channel?: string;
|
||||
topic?: string;
|
||||
tags?: string[];
|
||||
data?: Record<string, any>;
|
||||
last_id_seen: string;
|
||||
last_id_notified?: string;
|
||||
topic_id: string;
|
||||
types: [WATCH_TYPE_INFO];
|
||||
meta?: Record<string, any>;
|
||||
timestamps: WATCH_TIMESTAMPS;
|
||||
timestamps: {
|
||||
created: string;
|
||||
updated: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const WATCHES = new FSDB_COLLECTION<WATCH>({
|
||||
|
|
@ -57,44 +56,11 @@ export const WATCHES = new FSDB_COLLECTION<WATCH>({
|
|||
organize: by_lurid
|
||||
}),
|
||||
|
||||
type: new FSDB_INDEXER_SYMLINKS<WATCH>({
|
||||
name: 'type',
|
||||
field: 'type',
|
||||
topic_id: new FSDB_INDEXER_SYMLINKS<WATCH>({
|
||||
name: 'topic_id',
|
||||
field: 'topic_id',
|
||||
to_many: true,
|
||||
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
|
||||
organize: by_lurid
|
||||
})
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ 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 { AUTHED_BEFORE_COOKIE_ID, get_session, get_user, PRECHECK_TABLE, require_user, SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts';
|
||||
import { 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 = {};
|
||||
|
|
@ -207,7 +206,6 @@ 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
# /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`.
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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
|
||||
});
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
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
|
||||
});
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
# /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.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
## PUT /api/events/:event_id
|
||||
|
||||
Update an event.
|
||||
|
||||
## DELETE /api/events/:event_id
|
||||
|
||||
Delete an event.
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
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
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# /api/events
|
||||
|
||||
Interact with events.
|
||||
|
||||
## GET /api/events
|
||||
|
||||
Get events.
|
||||
|
||||
## POST /api/events
|
||||
|
||||
Create an event.
|
||||
23
public/api/topics/:topic_id/README.md
Normal file
23
public/api/topics/:topic_id/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# /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`.
|
||||
166
public/api/topics/:topic_id/events/:event_id/index.ts
Normal file
166
public/api/topics/:topic_id/events/:event_id/index.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
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
|
||||
});
|
||||
}
|
||||
15
public/api/topics/:topic_id/events/README.md
Normal file
15
public/api/topics/:topic_id/events/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# /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.
|
||||
|
|
@ -1,21 +1,42 @@
|
|||
import lurid from '@andyburke/lurid';
|
||||
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';
|
||||
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';
|
||||
|
||||
export const PRECHECKS: PRECHECK_TABLE = {};
|
||||
|
||||
// GET /api/events - get events
|
||||
// GET /api/topics/:topic_id/events - get topic 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];
|
||||
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();
|
||||
}
|
||||
}];
|
||||
export async function GET(request: Request, meta: Record<string, any>): Promise<Response> {
|
||||
const sorts = EVENTS.sorts;
|
||||
const events: FSDB_COLLECTION<EVENT> = get_events_collection_for_topic(meta.topic.id);
|
||||
|
||||
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];
|
||||
|
|
@ -32,7 +53,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 ?? '100', 10), 1_000),
|
||||
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>) => {
|
||||
|
|
@ -61,9 +82,8 @@ 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
|
||||
|
|
@ -76,7 +96,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,
|
||||
|
|
@ -85,20 +105,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,
|
||||
|
|
@ -114,78 +134,52 @@ export async function GET(request: Request, meta: Record<string, any>): Promise<
|
|||
});
|
||||
}
|
||||
|
||||
async function update_watches(event: EVENT) {
|
||||
async function update_watches(topic: TOPIC, event: EVENT) {
|
||||
const limit = 100;
|
||||
|
||||
let more_to_process;
|
||||
let offset = 0;
|
||||
do {
|
||||
const watches: WATCH[] = (await WATCHES.all({
|
||||
const watches: WATCH[] = (await WATCHES.find({
|
||||
topic_id: topic.id
|
||||
}, {
|
||||
limit,
|
||||
offset
|
||||
})).map((entry) => entry.load());
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
// TODO: look at the watch .types[] and send notifications
|
||||
|
||||
offset += watches.length;
|
||||
more_to_process = watches.length === limit;
|
||||
} while (more_to_process);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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() ?? '';
|
||||
|
||||
if (!user_can_create_events) {
|
||||
// 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 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);
|
||||
|
|
@ -210,33 +204,11 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
|
|||
});
|
||||
}
|
||||
|
||||
if (!user_has_write_permission_for_event(meta.user, event)) {
|
||||
return CANNED_RESPONSES.permission_denied();
|
||||
}
|
||||
console.dir({
|
||||
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);
|
||||
await events.create(event);
|
||||
|
||||
return Response.json(event, {
|
||||
status: 201
|
||||
|
|
@ -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 { CHANNEL, CHANNELS } from '../../../../models/channel.ts';
|
||||
import { TOPIC, TOPICS } from '../../../../models/topic.ts';
|
||||
|
||||
export const PRECHECKS: PRECHECK_TABLE = {};
|
||||
|
||||
// GET /api/channels/:id - Get a channel
|
||||
// GET /api/topics/:id - Get a topic
|
||||
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() ?? '';
|
||||
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 channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null;
|
||||
const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null;
|
||||
|
||||
if (!channel) {
|
||||
if (!topic) {
|
||||
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);
|
||||
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);
|
||||
|
||||
if (!user_has_read_for_channel) {
|
||||
if (!user_has_read_for_topic) {
|
||||
return CANNED_RESPONSES.permission_denied();
|
||||
}
|
||||
}];
|
||||
export function GET(_req: Request, meta: Record<string, any>): Response {
|
||||
return Response.json(meta.channel, {
|
||||
return Response.json(meta.topic, {
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
|
||||
// PUT /api/channels/:id - Update channel
|
||||
// PUT /api/topics/:id - Update topic
|
||||
PRECHECKS.PUT = [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() ?? '';
|
||||
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 channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null;
|
||||
const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null;
|
||||
|
||||
if (!channel) {
|
||||
if (!topic) {
|
||||
return CANNED_RESPONSES.not_found();
|
||||
}
|
||||
|
||||
meta.channel = channel;
|
||||
const user_has_write_for_channel = channel.permissions.write.includes(meta.user.id);
|
||||
meta.topic = topic;
|
||||
const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id);
|
||||
|
||||
if (!user_has_write_for_channel) {
|
||||
if (!user_has_write_for_topic) {
|
||||
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.channel,
|
||||
...meta.topic,
|
||||
...body,
|
||||
id: meta.channel.id,
|
||||
id: meta.topic.id,
|
||||
timestamps: {
|
||||
created: meta.channel.timestamps.created,
|
||||
created: meta.topic.timestamps.created,
|
||||
updated: now
|
||||
}
|
||||
};
|
||||
|
||||
await CHANNELS.update(updated);
|
||||
await TOPICS.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/channels/:id - Delete channel
|
||||
// DELETE /api/topics/:id - Delete topic
|
||||
PRECHECKS.DELETE = [
|
||||
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() ?? '';
|
||||
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 channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null;
|
||||
const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null;
|
||||
|
||||
if (!channel) {
|
||||
if (!topic) {
|
||||
return CANNED_RESPONSES.not_found();
|
||||
}
|
||||
|
||||
meta.channel = channel;
|
||||
const user_has_write_for_channel = channel.permissions.write.includes(meta.user.id);
|
||||
meta.topic = topic;
|
||||
const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id);
|
||||
|
||||
if (!user_has_write_for_channel) {
|
||||
if (!user_has_write_for_topic) {
|
||||
return CANNED_RESPONSES.permission_denied();
|
||||
}
|
||||
}
|
||||
];
|
||||
export async function DELETE(_req: Request, meta: Record<string, any>): Promise<Response> {
|
||||
await CHANNELS.delete(meta.channel);
|
||||
await TOPICS.delete(meta.topic);
|
||||
|
||||
return Response.json({
|
||||
deleted: true
|
||||
28
public/api/topics/README.md
Normal file
28
public/api/topics/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# /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.
|
||||
|
|
@ -3,34 +3,40 @@ 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 { CHANNEL, CHANNELS } from '../../../models/channel.ts';
|
||||
import { TOPIC, TOPICS } from '../../../models/topic.ts';
|
||||
import { WALK_ENTRY } from '@andyburke/fsdb';
|
||||
|
||||
export const PRECHECKS: PRECHECK_TABLE = {};
|
||||
|
||||
// GET /api/channels - get channels
|
||||
// GET /api/topics - get topics
|
||||
PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
|
||||
const can_read_channels = meta.user.permissions.includes('channels.read');
|
||||
const can_read_topics = meta.user.permissions.includes('topics.read');
|
||||
|
||||
if (!can_read_channels) {
|
||||
if (!can_read_topics) {
|
||||
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 channels = (await CHANNELS.all({
|
||||
limit
|
||||
})).map((channel_entry) => channel_entry.load());
|
||||
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());
|
||||
|
||||
return Response.json(channels, {
|
||||
return Response.json(topics, {
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/channels - Create a channel
|
||||
// POST /api/topics - Create a topic
|
||||
PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record<string, any>): Response | undefined => {
|
||||
const can_create_channels = meta.user.permissions.includes('channels.create');
|
||||
const can_create_topics = meta.user.permissions.includes('topics.create');
|
||||
|
||||
if (!can_create_channels) {
|
||||
if (!can_create_topics) {
|
||||
return CANNED_RESPONSES.permission_denied();
|
||||
}
|
||||
}];
|
||||
|
|
@ -43,8 +49,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_channel_name',
|
||||
message: 'You must specify a unique name for a channel.'
|
||||
cause: 'missing_topic_name',
|
||||
message: 'You must specify a unique name for a topic.'
|
||||
}
|
||||
}, {
|
||||
status: 400
|
||||
|
|
@ -54,8 +60,8 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
|
|||
if (body.name.length > 64) {
|
||||
return Response.json({
|
||||
error: {
|
||||
cause: 'invalid_channel_name',
|
||||
message: 'channel names must be 64 characters or fewer.'
|
||||
cause: 'invalid_topic_name',
|
||||
message: 'topic names must be 64 characters or fewer.'
|
||||
}
|
||||
}, {
|
||||
status: 400
|
||||
|
|
@ -64,31 +70,29 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
|
|||
|
||||
const normalized_name = body.name.toLowerCase();
|
||||
|
||||
const existing_channel = (await CHANNELS.find({
|
||||
const existing_topic = (await TOPICS.find({
|
||||
name: normalized_name
|
||||
})).shift();
|
||||
if (existing_channel) {
|
||||
if (existing_topic) {
|
||||
return Response.json({
|
||||
error: {
|
||||
cause: 'channel_name_conflict',
|
||||
message: 'There is already a channel with this name.'
|
||||
cause: 'topic_name_conflict',
|
||||
message: 'There is already a topic with this name.'
|
||||
}
|
||||
}, {
|
||||
status: 400
|
||||
});
|
||||
}
|
||||
|
||||
const channel: CHANNEL = {
|
||||
const topic: TOPIC = {
|
||||
...body,
|
||||
id: lurid(),
|
||||
creator_id: meta.user.id,
|
||||
permissions: {
|
||||
read: (body.permissions?.read ?? []),
|
||||
write: (body.permissions?.write ?? [meta.user.id]),
|
||||
events: {
|
||||
read: (body.permissions?.events?.read ?? []),
|
||||
write: (body.permissions?.events?.write ?? [])
|
||||
}
|
||||
read_events: (body.permissions?.read_events ?? []),
|
||||
write_events: (body.permissions?.write_events ?? [])
|
||||
},
|
||||
timestamps: {
|
||||
created: now,
|
||||
|
|
@ -97,9 +101,9 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
|
|||
}
|
||||
};
|
||||
|
||||
await CHANNELS.create(channel);
|
||||
await TOPICS.create(topic);
|
||||
|
||||
return Response.json(channel, {
|
||||
return Response.json(topic, {
|
||||
status: 201
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -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 watch
|
||||
// PUT /api/users/:user_id/watches/:watch_id - Update topic
|
||||
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.watch = watch;
|
||||
meta.topic = watch;
|
||||
const user_owns_watch = watch.creator_id === meta.user.id;
|
||||
|
||||
if (!user_owns_watch) {
|
||||
|
|
|
|||
|
|
@ -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 { CHANNELS } from '../../../../../models/channel.ts';
|
||||
import { TOPICS } from '../../../../../models/topic.ts';
|
||||
|
||||
export const PRECHECKS: PRECHECK_TABLE = {};
|
||||
|
||||
|
|
@ -99,18 +99,34 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
|
|||
}
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
await WATCHES.create(watch);
|
||||
|
|
|
|||
|
|
@ -10,34 +10,27 @@ 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',
|
||||
|
|
@ -46,16 +39,10 @@ const DEFAULT_USER_PERMISSIONS: string[] = [
|
|||
|
||||
// TODO: figure out a better solution for doling out permissions
|
||||
const DEFAULT_SUPERUSER_PERMISSIONS: string[] = [
|
||||
...DEFAULT_USER_PERMISSIONS,
|
||||
'channels.create',
|
||||
'channels.delete',
|
||||
'channels.write',
|
||||
'files.write.all',
|
||||
'invites.read.all',
|
||||
'signups.read.all',
|
||||
'users.write',
|
||||
'watches.read.all',
|
||||
'watches.write.all'
|
||||
...DEFAULT_USER_PERMISSIONS,
|
||||
'topics.create',
|
||||
'topics.delete',
|
||||
'topics.write',
|
||||
];
|
||||
|
||||
export const PRECHECKS: PRECHECK_TABLE = {};
|
||||
|
|
@ -161,7 +148,7 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
|
|||
await INVITE_CODES.create(root_invite_code);
|
||||
}
|
||||
|
||||
const secret_code = root_invite_code_secret ?? submitted_invite_code; // if it's the first user, use the autogen code, ignore anything they submit
|
||||
const secret_code = submitted_invite_code ?? root_invite_code_secret;
|
||||
if (typeof secret_code !== 'string' || secret_code.length < 3) {
|
||||
return Response.json({
|
||||
error: {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
--border-highlight: hsl(from var(--base-color) h 50% 75%);
|
||||
|
||||
--icon-scale: 1;
|
||||
--border-radius: 4px;
|
||||
--border-radius: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
|
@ -169,15 +169,6 @@ body {
|
|||
/* fixed height? */
|
||||
}
|
||||
|
||||
#background-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
@ -371,21 +362,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*="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*="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*="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"] {
|
||||
|
|
@ -1691,32 +1682,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello World - foo</h1>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,57 +3,54 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title><!-- #include "./files/settings/title.txt" or "./title.txt" --></title>
|
||||
<title>autonomous.contact</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="/files/custom.css"></link>
|
||||
<link rel="stylesheet" href="./base.css"></link>
|
||||
|
||||
<!-- inlining these to force them to be scoped for everything else -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<div id="background-container"></div>
|
||||
|
||||
<!-- #include "./signup_login_wall.html" -->
|
||||
<!-- #include file="./signup_login_wall.html" -->
|
||||
|
||||
<main>
|
||||
<!-- #include "./sidebar/sidebar.html" -->
|
||||
<!-- #include file="./sidebar/sidebar.html" -->
|
||||
|
||||
<!-- #include "./tabs/tabs.html" -->
|
||||
<!-- #include file="./tabs/tabs.html" -->
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,13 +5,10 @@ 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*([^;]+)(?:.*)?$/,
|
||||
|
|
|
|||
147
public/js/app.js
147
public/js/app.js
|
|
@ -1,8 +1,7 @@
|
|||
const HASH_EXTRACTOR = /^\#\/(?<view>\w+)(?:\/channel\/(?<channel_id>[A-Za-z\-]+)\/?)?/gm;
|
||||
const UPDATE_CHANNELS_FREQUENCY = 60_000;
|
||||
const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)?/gm;
|
||||
const UPDATE_TOPICS_FREQUENCY = 60_000;
|
||||
|
||||
const APP = {
|
||||
user: undefined,
|
||||
user_servers: [],
|
||||
user_watches: [],
|
||||
|
||||
|
|
@ -52,39 +51,61 @@ 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: { view, channel_id },
|
||||
groups: { topic_id, view },
|
||||
} = 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 = typeof document.body.dataset.view === 'string' ? document.body.dataset.view : undefined;
|
||||
if ( view ) {
|
||||
document.body.dataset.view = view;
|
||||
}
|
||||
else {
|
||||
delete document.body.dataset.view;
|
||||
}
|
||||
const previous = document.body.dataset.view;
|
||||
document.body.dataset.view = view;
|
||||
|
||||
console.dir({
|
||||
view_changed: {
|
||||
detail: {
|
||||
previous,
|
||||
view,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this._emit( 'view_changed', {
|
||||
previous,
|
||||
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
|
||||
view
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -121,20 +142,19 @@ const APP = {
|
|||
}
|
||||
|
||||
window.addEventListener("locationchange", this.extract_url_hash_info.bind( this ));
|
||||
window.addEventListener("locationchange", this.CHANNELS.update );
|
||||
window.addEventListener("locationchange", this.TOPICS.update );
|
||||
|
||||
this.check_if_logged_in();
|
||||
this.extract_url_hash_info();
|
||||
this._emit( 'load', this );
|
||||
},
|
||||
|
||||
update_user: async function( user ) {
|
||||
this.user = user;
|
||||
|
||||
update_user: async function( updated_user ) {
|
||||
const user = this.user = updated_user;
|
||||
document.body.dataset.user = JSON.stringify(user);
|
||||
document.body.dataset.perms = user.permissions.join(":");
|
||||
|
||||
this.CHANNELS.update();
|
||||
this.TOPICS.update();
|
||||
|
||||
this.user_servers = [];
|
||||
try {
|
||||
|
|
@ -203,54 +223,59 @@ const APP = {
|
|||
},
|
||||
},
|
||||
|
||||
CHANNELS: {
|
||||
_last_channel_update: undefined,
|
||||
_update_channels_timeout: undefined,
|
||||
CHANNEL_LIST: [],
|
||||
TOPICS: {
|
||||
_last_topic_update: undefined,
|
||||
_update_topics_timeout: undefined,
|
||||
TOPIC_LIST: [],
|
||||
|
||||
update: async ( force = false ) => {
|
||||
update: async () => {
|
||||
const now = new Date();
|
||||
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 ) {
|
||||
const time_since_last_update = now - (APP.TOPICS._last_topic_update ?? 0);
|
||||
if (time_since_last_update < UPDATE_TOPICS_FREQUENCY / 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (APP.CHANNELS._update_channels_timeout) {
|
||||
clearTimeout(APP.CHANNELS._update_channels_timeout);
|
||||
APP.CHANNELS._update_channels_timeout = undefined;
|
||||
if (APP.TOPICS._update_topics_timeout) {
|
||||
clearTimeout(APP.TOPICS._update_topics_timeout);
|
||||
APP.TOPICS._update_topics_timeout = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
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];
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
APP._emit( 'channels_updated', {
|
||||
channels: APP.CHANNELS.CHANNEL_LIST
|
||||
});
|
||||
if (has_differences) {
|
||||
APP.TOPICS.TOPIC_LIST = [...new_topics];
|
||||
|
||||
APP.CHANNELS._last_channel_update = now;
|
||||
APP._emit( 'topics_updated', {
|
||||
topics: APP.TOPICS.TOPIC_LIST
|
||||
});
|
||||
}
|
||||
|
||||
APP.TOPICS._last_topic_update = now;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
APP.CHANNELS._update_channels_timeout = setTimeout(
|
||||
APP.CHANNELS.update,
|
||||
UPDATE_CHANNELS_FREQUENCY,
|
||||
APP.TOPICS._update_topics_timeout = setTimeout(
|
||||
APP.TOPICS.update,
|
||||
UPDATE_TOPICS_FREQUENCY,
|
||||
);
|
||||
|
||||
// now that we have channels, make sure our url is all good
|
||||
// now that we have topics, 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';
|
||||
}
|
||||
});
|
||||
|
|
|
|||
BIN
public/js/external/leaflet/images/layers-2x.png
vendored
BIN
public/js/external/leaflet/images/layers-2x.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
BIN
public/js/external/leaflet/images/layers.png
vendored
BIN
public/js/external/leaflet/images/layers.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 696 B |
BIN
public/js/external/leaflet/images/marker-icon-2x.png
vendored
BIN
public/js/external/leaflet/images/marker-icon-2x.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
BIN
public/js/external/leaflet/images/marker-icon.png
vendored
BIN
public/js/external/leaflet/images/marker-icon.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
public/js/external/leaflet/images/marker-shadow.png
vendored
BIN
public/js/external/leaflet/images/marker-shadow.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 618 B |
14419
public/js/external/leaflet/leaflet-src.esm.js
vendored
14419
public/js/external/leaflet/leaflet-src.esm.js
vendored
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
14512
public/js/external/leaflet/leaflet-src.js
vendored
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
661
public/js/external/leaflet/leaflet.css
vendored
|
|
@ -1,661 +0,0 @@
|
|||
/* 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
6
public/js/external/leaflet/leaflet.js
vendored
File diff suppressed because one or more lines are too long
1
public/js/external/leaflet/leaflet.js.map
vendored
1
public/js/external/leaflet/leaflet.js.map
vendored
File diff suppressed because one or more lines are too long
|
|
@ -139,7 +139,6 @@ 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)); }"
|
||||
|
|
@ -168,12 +167,6 @@ 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"
|
||||
|
|
@ -205,6 +198,12 @@ 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"]');
|
||||
|
|
|
|||
|
|
@ -1,4 +1,38 @@
|
|||
<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());
|
||||
}
|
||||
|
|
@ -72,6 +106,50 @@
|
|||
<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">
|
||||
|
|
@ -87,11 +165,6 @@
|
|||
border-right: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
#sidebar .profile-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#sidebar #sidebar-context-menu {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
|
|
@ -211,6 +284,46 @@
|
|||
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;
|
||||
|
|
@ -384,7 +497,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 };` : '' }" loading="lazy" />
|
||||
<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 };` : '' }" />
|
||||
<div class="server-name">${ server.name ?? server.url }</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -647,7 +760,68 @@
|
|||
<button class="primary">Log Out</button>
|
||||
</form>
|
||||
|
||||
<div id="sidebar-dynamic-container">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,24 +14,11 @@
|
|||
background: var(--bg);
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
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);
|
||||
}
|
||||
transition: all 0.33s;
|
||||
}
|
||||
|
||||
#login-tab .tab-content {
|
||||
min-height: 17rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#signup-tab .tab-content {
|
||||
|
|
@ -61,72 +48,17 @@
|
|||
}
|
||||
</style>
|
||||
|
||||
<!-- #include "./files/settings/signup_pitch.html" or "./files/settings/signup_pitch.md" or "./signup_pitch.default.md" -->
|
||||
<!-- #include file="./signup_pitch.md" -->
|
||||
|
||||
<div class="limiter">
|
||||
<div class="tabs">
|
||||
<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>
|
||||
</label>
|
||||
<div class="tab-content">
|
||||
<form data-smart="true" data-method="POST" id="signup-form" action="/api/users">
|
||||
<script>
|
||||
{
|
||||
const form = document.currentScript.closest("form");
|
||||
form.on_reply = (response) => {
|
||||
const user = response.user;
|
||||
APP.login( user );
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<div>
|
||||
<input id="signup-username" type="text" name="username" required />
|
||||
<label class="placeholder" for="signup-username">username</label>
|
||||
</div>
|
||||
<div>
|
||||
<input id="signup-password" type="password" name="password" required />
|
||||
<label class="placeholder" for="signup-password">password</label>
|
||||
</div>
|
||||
<div>
|
||||
<script>
|
||||
APP.on( 'load', () => {
|
||||
const query = new URL(document.location.toString())
|
||||
.searchParams;
|
||||
const invite_code = query.get("invite_code");
|
||||
if (typeof invite_code === "string" && invite_code.length) {
|
||||
document.getElementById("signup-invite-code").value =
|
||||
decodeURIComponent(invite_code);
|
||||
|
||||
document.getElementById("signup-tab-input").checked = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<input
|
||||
id="signup-invite-code"
|
||||
type="text"
|
||||
name="invite_code"
|
||||
required
|
||||
/>
|
||||
<label class="placeholder" for="signup-invite-code">invite code</label>
|
||||
</div>
|
||||
<button id="signup-submit" type="submit" class="primary">Sign Up</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -156,18 +88,65 @@
|
|||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="signup-tab" class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
name="signup-login-tabs"
|
||||
id="signup-tab-input"
|
||||
class="tab-switch"
|
||||
/>
|
||||
<label for="signup-tab-input" class="tab-label">
|
||||
<div class="label">Sign Up</div>
|
||||
</label>
|
||||
<div class="tab-content">
|
||||
<form data-smart="true" data-method="POST" id="signup-form" action="/api/users">
|
||||
<script>
|
||||
{
|
||||
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 } }),
|
||||
);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<div>
|
||||
<input id="signup-username" type="text" name="username" required />
|
||||
<label class="placeholder" for="signup-username">username</label>
|
||||
</div>
|
||||
<div>
|
||||
<input id="signup-password" type="password" name="password" required />
|
||||
<label class="placeholder" for="signup-password">password</label>
|
||||
</div>
|
||||
<div>
|
||||
<script>
|
||||
APP.on( 'load', () => {
|
||||
const query = new URL(document.location.toString())
|
||||
.searchParams;
|
||||
const invite_code = query.get("invite_code");
|
||||
if (typeof invite_code === "string" && invite_code.length) {
|
||||
document.getElementById("signup-invite-code").value =
|
||||
decodeURIComponent(invite_code);
|
||||
|
||||
document.getElementById("signup-tab-input").checked = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<input
|
||||
id="signup-invite-code"
|
||||
type="text"
|
||||
name="invite_code"
|
||||
/>
|
||||
<label class="placeholder" for="signup-invite-code">invite code</label>
|
||||
</div>
|
||||
<button id="signup-submit" type="submit" class="primary">Sign Up</button>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Welcome!
|
||||
# verifiedhuman.network
|
||||
|
||||
## You're here because someone else thought you needed an invite.
|
||||
## You're here because someone else said you were a cool human.
|
||||
|
||||
### Use your invite code to gain access.
|
||||
|
||||
|
|
@ -141,15 +141,15 @@
|
|||
</label>
|
||||
<div class="tab-content">
|
||||
<div id="blurbs-container" class="container">
|
||||
<!-- #include "./README.md" -->
|
||||
<!-- #include file="./README.md" -->
|
||||
|
||||
<!-- #include "./new_blurb.html" -->
|
||||
<!-- #include file="./new_blurb.html" -->
|
||||
|
||||
<div
|
||||
id="blurbs-list"
|
||||
data-feed
|
||||
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-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-longpolling="true"
|
||||
data-reverse="true"
|
||||
data-insert="prepend"
|
||||
|
|
@ -159,6 +159,7 @@
|
|||
{
|
||||
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,12 +210,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 url ? `<img src='${url}' loading="lazy"/>` : ''; }).join('\n') : ''}
|
||||
${context.blurb.data?.media?.length ? context.blurb.data.media.map(function(url) { return `<img src='${url}' />`; }).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" loading="lazy" />
|
||||
<img src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}" alt="user avatar" />
|
||||
</div>
|
||||
<div class="username-container">
|
||||
<span class="username">${context.creator.username}</span>
|
||||
|
|
@ -227,7 +228,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 "./new_blurb.html" -->
|
||||
<!-- #include file="./new_blurb.html" -->
|
||||
<div class="replies-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
<div class="new-blurb-container" data-requires-permission="events.create.blurb">
|
||||
<div class="new-blurb-container" data-requires-permission="topics.blurbs.create">
|
||||
<label>
|
||||
<input type="checkbox" collapse-toggle />
|
||||
<i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i>
|
||||
|
|
@ -33,7 +33,6 @@
|
|||
</label>
|
||||
<form
|
||||
data-smart="true"
|
||||
action="/api/events"
|
||||
method="POST"
|
||||
class="blurb-creation-form collapsible"
|
||||
style="
|
||||
|
|
@ -41,6 +40,7 @@
|
|||
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'); }"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# Calendar
|
||||
|
||||
The calendar should help people coordinate events.
|
||||
The calendar should help people coordinate events around a topic.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@
|
|||
<div class="label">Calendar</div></label
|
||||
>
|
||||
<div class="tab-content">
|
||||
<!-- #include "./README.md" -->
|
||||
<!-- #include file="./README.md" -->
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,232 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
</label>
|
||||
<div class="tab-content">
|
||||
<style>
|
||||
<!-- #include "./chat.css" -->
|
||||
<!-- #include file="./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( '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-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-longpolling="true"
|
||||
data-reverse="true"
|
||||
data-insert="append"
|
||||
|
|
@ -32,7 +32,7 @@
|
|||
{
|
||||
const feed = document.currentScript.closest("[data-feed]");
|
||||
|
||||
APP.on("channel_changed", () => { feed.__reset && feed.__reset(); });
|
||||
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
|
||||
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
|
||||
|
||||
const time_tick_tock_timeout = 60_000;
|
||||
|
|
@ -109,7 +109,6 @@
|
|||
<img
|
||||
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
|
||||
alt="user avatar"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="username-container">
|
||||
|
|
@ -148,8 +147,7 @@
|
|||
<form
|
||||
id="chat-entry"
|
||||
data-smart="true"
|
||||
action="/api/events"
|
||||
data-requires-permission="events.write.chat"
|
||||
data-requires-permission="topics.chat.write"
|
||||
method="POST"
|
||||
class="post-creation-form collapsible"
|
||||
style="
|
||||
|
|
@ -160,6 +158,15 @@
|
|||
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
|
||||
|
|
@ -181,12 +188,6 @@
|
|||
generator="() => { return APP.user?.id; }"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="channel"
|
||||
generator="() => { return document.body.dataset.channel; }"
|
||||
/>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name="timestamps.created"
|
||||
|
|
@ -232,4 +233,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #include "./channel_sidebar.html" -->
|
||||
|
|
|
|||
|
|
@ -109,14 +109,14 @@
|
|||
</label>
|
||||
<div class="tab-content">
|
||||
<div id="essays-container" class="container">
|
||||
<!-- #include "./README.md" -->
|
||||
<!-- #include "./new_essay.html" -->
|
||||
<!-- #include file="./README.md" -->
|
||||
<!-- #include file="./new_essay.html" -->
|
||||
|
||||
<div
|
||||
id="essays-list"
|
||||
data-feed
|
||||
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-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-longpolling="true"
|
||||
data-reverse="true"
|
||||
data-insert="prepend"
|
||||
|
|
@ -126,6 +126,7 @@
|
|||
{
|
||||
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) => {
|
||||
|
|
@ -170,7 +171,6 @@
|
|||
${context.essay.data?.media?.length ?
|
||||
context.essay.data.media.map(function(url) { return `<img
|
||||
src="${url}"
|
||||
loading="lazy"
|
||||
/>` }).join('\n') : ''}
|
||||
</div>
|
||||
|
||||
|
|
@ -179,7 +179,6 @@
|
|||
<img
|
||||
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
|
||||
alt="user avatar"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="username-container">
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
<div class="new-essay-container" data-requires-permission="events.create.essay">
|
||||
<div class="new-essay-container" data-requires-permission="topics.essays.create">
|
||||
<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%;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@
|
|||
<div class="label">Exchange</div></label
|
||||
>
|
||||
<div class="tab-content">
|
||||
<!-- #include "./README.md" -->
|
||||
<!-- #include file="./README.md" -->
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -140,13 +140,13 @@
|
|||
<div class="label">Forum</div></label
|
||||
>
|
||||
<div class="tab-content forum-container">
|
||||
<!-- #include "./README.md" -->
|
||||
<!-- #include file="./README.md" -->
|
||||
|
||||
<div
|
||||
id="posts-list"
|
||||
data-feed
|
||||
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-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-longpolling="true"
|
||||
data-reverse="true"
|
||||
data-insert="prepend"
|
||||
|
|
@ -156,6 +156,7 @@
|
|||
{
|
||||
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) => {
|
||||
|
|
@ -208,7 +209,6 @@
|
|||
<div class="media-preview-container">
|
||||
<img
|
||||
src="/images/placeholders/${String((context.post_datetime.ms % 9) + 1).padStart(2, '0')}.svg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -217,7 +217,6 @@
|
|||
<img
|
||||
src="${context.creator.meta?.avatar ?? '/images/default_avatar.gif'}"
|
||||
alt="user avatar"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="username-container">
|
||||
|
|
@ -234,7 +233,7 @@
|
|||
</div>
|
||||
<div class="reactions-container"></div>
|
||||
<button class="icon more" commandfor="eventactionspopover"></button>
|
||||
<!-- #include "./new_post.html" -->
|
||||
<!-- #include file="./new_post.html" -->
|
||||
<div class="replies-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -251,6 +250,6 @@
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<!-- #include "./new_post.html" -->
|
||||
<!-- #include file="./new_post.html" -->
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,15 +6,15 @@
|
|||
</label>
|
||||
<form
|
||||
data-smart="true"
|
||||
data-requires-permission="events.create.post"
|
||||
data-requires-permission="topics.posts.create"
|
||||
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'); }"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,6 @@
|
|||
<div class="label">Home</div>
|
||||
</label>
|
||||
<div class="tab-content">
|
||||
<!-- #include "./README.md" -->
|
||||
<!-- #include file="./README.md" -->
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
<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: '© <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>
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
# Resources
|
||||
|
||||
Resources should be a wiki for organizing community knowledge.
|
||||
Resources should be a wiki for organizing community knowledge on a topic.
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@
|
|||
><div class="icon resources"></div>
|
||||
<div class="label">Resources</div></label
|
||||
>
|
||||
<div class="tab-content"><!-- #include "./README.md" --></div>
|
||||
<div class="tab-content"><!-- #include file="./README.md" --></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,9 +13,8 @@
|
|||
tab_switch.addEventListener("input", (event) => {
|
||||
const tab_selector = event.target;
|
||||
const view = tab_selector.dataset.view;
|
||||
|
||||
if (view) {
|
||||
window.location.hash = `/${view}${ document.body.dataset.channel ? `/channel/${ document.body.dataset.channel }` : '' }`;
|
||||
window.location.hash = `/topic/${document.body.dataset.topic}/${view}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -111,21 +110,28 @@
|
|||
|
||||
@media screen and (max-width: 800px) {
|
||||
.tab-label {
|
||||
width: 4rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.tab-label .label {
|
||||
font-size: small;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.tab-label {
|
||||
width: 3rem;
|
||||
width: 2.5rem;
|
||||
}
|
||||
|
||||
.tab-label .label {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="tabs">
|
||||
<!-- #include "./chat/chat.html" -->
|
||||
<!-- #include "./blurbs/blurbs.html" -->
|
||||
<!-- #include "./forum/forum.html" -->
|
||||
<!-- #include "./essays/essays.html" -->
|
||||
<!-- #include "./map/map.html" -->
|
||||
<!-- #include file="./chat/chat.html" -->
|
||||
<!-- #include file="./blurbs/blurbs.html" -->
|
||||
<!-- #include file="./forum/forum.html" -->
|
||||
<!-- #include file="./essays/essays.html" -->
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,5 +10,5 @@
|
|||
><div class="icon work"></div>
|
||||
<div class="label">Work</div>
|
||||
</label>
|
||||
<div class="tab-content"><!-- #include "./README.md" --></div>
|
||||
<div class="tab-content"><!-- #include file="./README.md" --></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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',
|
||||
|
|
@ -38,7 +39,7 @@ Deno.test({
|
|||
asserts.assert(info.session);
|
||||
asserts.assert(info.headers);
|
||||
|
||||
const user: USER = info.user;
|
||||
const user = info.user;
|
||||
|
||||
asserts.assertEquals(user.username, username);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ 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',
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -1,103 +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';
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
84
tests/04_create_topic.test.ts
Normal file
84
tests/04_create_topic.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -2,9 +2,10 @@ 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 - CHANNELS - Update',
|
||||
name: 'API - TOPICS - Update',
|
||||
permissions: {
|
||||
env: true,
|
||||
read: true,
|
||||
|
|
@ -23,25 +24,25 @@ Deno.test({
|
|||
|
||||
const info = await get_new_user(client);
|
||||
|
||||
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'channels.create']);
|
||||
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'topics.create']);
|
||||
|
||||
const new_channel = await client.fetch('/channels', {
|
||||
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 update channel'
|
||||
name: 'test update topic'
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assert(new_channel);
|
||||
asserts.assert(new_topic);
|
||||
|
||||
const other_user_info = await get_new_user(client, {}, info);
|
||||
|
||||
try {
|
||||
const _permission_denied_channel = await client.fetch(`/channels/${new_channel.id}`, {
|
||||
const _permission_denied_topic = await client.fetch(`/topics/${new_topic.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -52,48 +53,49 @@ Deno.test({
|
|||
}
|
||||
});
|
||||
|
||||
asserts.fail('allowed updating a channel owned by someone else');
|
||||
asserts.fail('allowed updating a topic owned by someone else');
|
||||
} catch (error) {
|
||||
asserts.assertEquals((error as Error).cause, 'permission_denied');
|
||||
}
|
||||
|
||||
const updated_by_owner_channel = await client.fetch(`/channels/${new_channel.id}`, {
|
||||
const updated_by_owner_topic = await client.fetch(`/topics/${new_topic.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': info.session.id,
|
||||
'x-totp': await generateTotp(info.session.secret)
|
||||
},
|
||||
json: {
|
||||
channel: 'this is a new channel',
|
||||
topic: 'this is a new topic',
|
||||
permissions: {
|
||||
...new_channel.permissions,
|
||||
write: [...new_channel.permissions.write, other_user_info.user.id]
|
||||
...new_topic.permissions,
|
||||
write: [...new_topic.permissions.write, 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]);
|
||||
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]);
|
||||
|
||||
const updated_by_other_user_channel = await client.fetch(`/channels/${new_channel.id}`, {
|
||||
const updated_by_other_user_topic = await client.fetch(`/topics/${new_topic.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
'x-totp': await generateTotp(other_user_info.session.secret)
|
||||
},
|
||||
json: {
|
||||
channel: 'this is a newer channel'
|
||||
topic: 'this is a newer topic'
|
||||
}
|
||||
});
|
||||
|
||||
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]);
|
||||
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]);
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ 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 - CHANNELS - Delete',
|
||||
name: 'API - TOPICS - Delete',
|
||||
permissions: {
|
||||
env: true,
|
||||
read: true,
|
||||
|
|
@ -23,22 +24,22 @@ Deno.test({
|
|||
|
||||
const info = await get_new_user(client);
|
||||
|
||||
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'channels.create']);
|
||||
await set_user_permissions(client, info.user, info.session, [...info.user.permissions, 'topics.create']);
|
||||
|
||||
const new_channel = await client.fetch('/channels', {
|
||||
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 delete channel'
|
||||
name: 'test delete topic'
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assert(new_channel);
|
||||
asserts.assert(new_topic);
|
||||
|
||||
const deleted_channel = await client.fetch(`/channels/${new_channel.id}`, {
|
||||
const deleted_topic = await client.fetch(`/topics/${new_topic.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-session_id': info.session.id,
|
||||
|
|
@ -46,10 +47,11 @@ Deno.test({
|
|||
}
|
||||
});
|
||||
|
||||
asserts.assert(deleted_channel);
|
||||
asserts.assert(deleted_topic);
|
||||
|
||||
await delete_user(client, info);
|
||||
} finally {
|
||||
clear_topic_events_cache();
|
||||
if (test_server_info) {
|
||||
await test_server_info?.server?.stop();
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ 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 - CHANNELS - EVENTS - Create',
|
||||
name: 'API - TOPICS - EVENTS - Create',
|
||||
permissions: {
|
||||
env: true,
|
||||
read: true,
|
||||
|
|
@ -23,27 +24,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, 'channels.create']);
|
||||
await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']);
|
||||
|
||||
const channel = await client.fetch('/channels', {
|
||||
const topic = await client.fetch('/topics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
'x-totp': await generateTotp(owner_info.session.secret)
|
||||
},
|
||||
json: {
|
||||
name: 'test events channel',
|
||||
name: 'test events topic',
|
||||
permissions: {
|
||||
events: {
|
||||
write: [owner_info.user.id]
|
||||
}
|
||||
write_events: [owner_info.user.id]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assert(channel);
|
||||
asserts.assert(topic);
|
||||
|
||||
const event_from_owner = await client.fetch(`/events`, {
|
||||
const event_from_owner = await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -51,7 +50,6 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
type: 'test',
|
||||
channel: channel.id,
|
||||
data: {
|
||||
foo: 'bar'
|
||||
}
|
||||
|
|
@ -63,7 +61,7 @@ Deno.test({
|
|||
const other_user_info = await get_new_user(client, {}, owner_info);
|
||||
|
||||
try {
|
||||
const _permission_denied_channel = await client.fetch(`/events`, {
|
||||
const _permission_denied_topic = await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -71,20 +69,19 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
type: 'test',
|
||||
channel: channel.id,
|
||||
data: {
|
||||
other_user: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
asserts.fail('allowed adding an event to a channel without permission');
|
||||
asserts.fail('allowed adding an event to a topic without permission');
|
||||
} catch (error) {
|
||||
asserts.assertEquals((error as Error).cause, 'permission_denied');
|
||||
}
|
||||
|
||||
// make the channel public write
|
||||
const updated_by_owner_channel = await client.fetch(`/channels/${channel.id}`, {
|
||||
// make the topic public write
|
||||
const updated_by_owner_topic = await client.fetch(`/topics/${topic.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -92,18 +89,16 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
permissions: {
|
||||
...channel.permissions,
|
||||
events: {
|
||||
write: []
|
||||
}
|
||||
...topic.permissions,
|
||||
write_events: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assert(updated_by_owner_channel);
|
||||
asserts.assertEquals(updated_by_owner_channel.permissions.events.write, []);
|
||||
asserts.assert(updated_by_owner_topic);
|
||||
asserts.assertEquals(updated_by_owner_topic.permissions.write_events, []);
|
||||
|
||||
const event_from_other_user = await client.fetch(`/events`, {
|
||||
const event_from_other_user = await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -111,7 +106,6 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
type: 'test',
|
||||
channel: channel.id,
|
||||
data: {
|
||||
other_user: true
|
||||
}
|
||||
|
|
@ -123,6 +117,7 @@ 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();
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ 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 - CHANNELS - EVENTS - Get',
|
||||
name: 'API - TOPICS - EVENTS - Get',
|
||||
permissions: {
|
||||
env: true,
|
||||
read: true,
|
||||
|
|
@ -28,25 +29,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, 'channels.create']);
|
||||
await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']);
|
||||
|
||||
const channel = await client.fetch('/channels', {
|
||||
const topic = await client.fetch('/topics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
'x-totp': await generateTotp(owner_info.session.secret)
|
||||
},
|
||||
json: {
|
||||
name: 'test get events channel'
|
||||
name: 'test get events topic'
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assert(channel);
|
||||
asserts.assert(topic);
|
||||
|
||||
const NUM_INITIAL_EVENTS = 5;
|
||||
const events_initial_batch: any[] = [];
|
||||
for (let i = 0; i < NUM_INITIAL_EVENTS; ++i) {
|
||||
const event = await client.fetch(`/events`, {
|
||||
const event = await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -54,7 +55,6 @@ 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(`/channels/${channel.id}/events`, {
|
||||
const events_from_server = await client.fetch(`/topics/${topic.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(`/channels/${channel.id}/events?wait=true&after_id=${newest_event.id.split(':', 2)[1]}`, {
|
||||
const long_poll_request_promise = client.fetch(`/topics/${topic.id}/events?wait=true&after_id=${newest_event.id}`, {
|
||||
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(`/events`, {
|
||||
await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -100,7 +100,6 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
type: 'test',
|
||||
channel: channel.id,
|
||||
data: {
|
||||
i: 12345
|
||||
}
|
||||
|
|
@ -121,6 +120,7 @@ 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();
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ 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 - CHANNELS - EVENTS - Update',
|
||||
name: 'API - TOPICS - EVENTS - Update',
|
||||
permissions: {
|
||||
env: true,
|
||||
read: true,
|
||||
|
|
@ -23,22 +24,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, 'channels.create']);
|
||||
await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']);
|
||||
|
||||
const channel = await client.fetch('/channels', {
|
||||
const topic = await client.fetch('/topics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
'x-totp': await generateTotp(owner_info.session.secret)
|
||||
},
|
||||
json: {
|
||||
name: 'test update events channel'
|
||||
name: 'test update events topic'
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assert(channel);
|
||||
asserts.assert(topic);
|
||||
|
||||
const event_from_owner = await client.fetch(`/events`, {
|
||||
const event_from_owner = await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -46,7 +47,6 @@ 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(`/channels/${channel.id}/events/${event_from_owner.id}`, {
|
||||
const fetched_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -65,23 +65,25 @@ Deno.test({
|
|||
|
||||
asserts.assertEquals(fetched_event_from_owner, event_from_owner);
|
||||
|
||||
const updated_event_from_owner = await client.fetch(`/events/${event_from_owner.id}`, {
|
||||
const updated_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
'x-totp': await generateTotp(owner_info.session.secret)
|
||||
},
|
||||
json: {
|
||||
meta: {
|
||||
type: 'other',
|
||||
data: {
|
||||
foo: 'baz'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assertNotEquals(updated_event_from_owner, event_from_owner);
|
||||
asserts.assertEquals(updated_event_from_owner.meta?.foo, 'baz');
|
||||
asserts.assertEquals(updated_event_from_owner.type, 'other');
|
||||
asserts.assertEquals(updated_event_from_owner.data.foo, 'baz');
|
||||
|
||||
const fetched_updated_event_from_owner = await client.fetch(`/channels/${channel.id}/events/${event_from_owner.id}`, {
|
||||
const fetched_updated_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -95,7 +97,7 @@ Deno.test({
|
|||
|
||||
const other_user_info = await get_new_user(client, {}, owner_info);
|
||||
|
||||
const event_from_other_user = await client.fetch(`/events`, {
|
||||
const event_from_other_user = await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -103,7 +105,6 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
type: 'test',
|
||||
channel: channel.id,
|
||||
data: {
|
||||
other_user: true
|
||||
}
|
||||
|
|
@ -112,7 +113,7 @@ Deno.test({
|
|||
|
||||
asserts.assert(event_from_other_user);
|
||||
|
||||
const fetched_event_from_other_user = await client.fetch(`/channels/${channel.id}/events/${event_from_other_user.id}`, {
|
||||
const fetched_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -122,13 +123,14 @@ Deno.test({
|
|||
|
||||
asserts.assertEquals(fetched_event_from_other_user, event_from_other_user);
|
||||
|
||||
const updated_event_from_other_user = await client.fetch(`/events/${event_from_other_user.id}`, {
|
||||
const updated_event_from_other_user = await client.fetch(`/topics/${topic.id}/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'
|
||||
}
|
||||
|
|
@ -136,9 +138,10 @@ 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(`/channels/${channel.id}/events/${event_from_other_user.id}`, {
|
||||
const fetched_updated_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -150,7 +153,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_channel = await client.fetch(`/channels/${channel.id}`, {
|
||||
const updated_by_owner_topic = await client.fetch(`/topics/${topic.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -158,38 +161,33 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
permissions: {
|
||||
...channel.permissions,
|
||||
events: {
|
||||
read: [],
|
||||
write: [owner_info.user.id]
|
||||
}
|
||||
...topic.permissions,
|
||||
write_events: [owner_info.user.id]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assertEquals(updated_by_owner_channel.permissions.events.write, [owner_info.user.id]);
|
||||
asserts.assertEquals(updated_by_owner_topic.permissions.write_events, [owner_info.user.id]);
|
||||
|
||||
try {
|
||||
await client.fetch(`/events/${event_from_other_user.id}`, {
|
||||
await client.fetch(`/topics/${topic.id}/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: {
|
||||
data: {
|
||||
other_user: 'glop'
|
||||
}
|
||||
type: 'new'
|
||||
}
|
||||
});
|
||||
|
||||
asserts.fail('allowed updating an event in a channel with a events.write allowed only by owner');
|
||||
asserts.fail('allowed updating an event in a topic with a write_events allowed only by owner');
|
||||
} catch (error) {
|
||||
asserts.assertEquals((error as Error).cause, 'permission_denied');
|
||||
}
|
||||
|
||||
try {
|
||||
await client.fetch(`/events/${event_from_other_user.id}`, {
|
||||
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -197,12 +195,12 @@ Deno.test({
|
|||
}
|
||||
});
|
||||
|
||||
asserts.fail('allowed deleting an event in a channel with a events.write allowed only by owner');
|
||||
asserts.fail('allowed deleting an event in a topic with a write_events allowed only by owner');
|
||||
} catch (error) {
|
||||
asserts.assertEquals((error as Error).cause, 'permission_denied');
|
||||
}
|
||||
|
||||
const publicly_writable_channel = await client.fetch(`/channels/${channel.id}`, {
|
||||
const publicly_writable_topic = await client.fetch(`/topics/${topic.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -210,18 +208,15 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
permissions: {
|
||||
...channel.permissions,
|
||||
events: {
|
||||
read: [],
|
||||
write: []
|
||||
}
|
||||
...topic.permissions,
|
||||
write_events: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assertEquals(publicly_writable_channel.permissions.events.write, []);
|
||||
asserts.assertEquals(publicly_writable_topic.permissions.write_events, []);
|
||||
|
||||
const delete_other_user_event_response = await client.fetch(`/events/${event_from_other_user.id}`, {
|
||||
const delete_other_user_event_response = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -231,7 +226,7 @@ Deno.test({
|
|||
|
||||
asserts.assertEquals(delete_other_user_event_response.deleted, true);
|
||||
|
||||
const delete_owner_event_response = await client.fetch(`/events/${event_from_owner.id}`, {
|
||||
const delete_owner_event_response = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -244,6 +239,7 @@ 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();
|
||||
}
|
||||
|
|
@ -2,9 +2,10 @@ 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 - CHANNELS - EVENTS - Update (APPEND_ONLY_EVENTS)',
|
||||
name: 'API - TOPICS - EVENTS - Update (APPEND_ONLY_EVENTS)',
|
||||
permissions: {
|
||||
env: true,
|
||||
read: true,
|
||||
|
|
@ -26,22 +27,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, 'channels.create']);
|
||||
await set_user_permissions(client, owner_info.user, owner_info.session, [...owner_info.user.permissions, 'topics.create']);
|
||||
|
||||
const channel = await client.fetch('/channels', {
|
||||
const topic = await client.fetch('/topics', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
'x-totp': await generateTotp(owner_info.session.secret)
|
||||
},
|
||||
json: {
|
||||
name: 'test update events channel in append only mode'
|
||||
name: 'test update events topic in append only mode'
|
||||
}
|
||||
});
|
||||
|
||||
asserts.assert(channel);
|
||||
asserts.assert(topic);
|
||||
|
||||
const event_from_owner = await client.fetch(`/events`, {
|
||||
const event_from_owner = await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -49,7 +50,6 @@ 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(`/channels/${channel.id}/events/${event_from_owner.id}`, {
|
||||
const fetched_event_from_owner = await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -69,26 +69,24 @@ Deno.test({
|
|||
asserts.assertEquals(fetched_event_from_owner, event_from_owner);
|
||||
|
||||
try {
|
||||
await client.fetch(`/events/${event_from_owner.id}`, {
|
||||
await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
'x-totp': await generateTotp(owner_info.session.secret)
|
||||
},
|
||||
json: {
|
||||
meta: {
|
||||
foo: 'bar'
|
||||
}
|
||||
type: 'new'
|
||||
}
|
||||
});
|
||||
|
||||
asserts.fail('allowed updating an event in a channel with APPEND_ONLY_EVENTS on');
|
||||
asserts.fail('allowed updating an event in a topic with APPEND_ONLY_EVENTS on');
|
||||
} catch (error) {
|
||||
asserts.assertEquals((error as Error).cause, 'append_only_events');
|
||||
}
|
||||
|
||||
try {
|
||||
await client.fetch(`/events/${event_from_owner.id}`, {
|
||||
await client.fetch(`/topics/${topic.id}/events/${event_from_owner.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-session_id': owner_info.session.id,
|
||||
|
|
@ -96,14 +94,14 @@ Deno.test({
|
|||
}
|
||||
});
|
||||
|
||||
asserts.fail('allowed deleting an event in a channel with APPEND_ONLY_EVENTS on');
|
||||
asserts.fail('allowed deleting an event in a topic 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(`/events`, {
|
||||
const event_from_other_user = await client.fetch(`/topics/${topic.id}/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -111,7 +109,6 @@ Deno.test({
|
|||
},
|
||||
json: {
|
||||
type: 'test',
|
||||
channel: channel.id,
|
||||
data: {
|
||||
other_user: true
|
||||
}
|
||||
|
|
@ -120,7 +117,7 @@ Deno.test({
|
|||
|
||||
asserts.assert(event_from_other_user);
|
||||
|
||||
const fetched_event_from_other_user = await client.fetch(`/channels/${channel.id}/events/${event_from_other_user.id}`, {
|
||||
const fetched_event_from_other_user = await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -131,26 +128,24 @@ Deno.test({
|
|||
asserts.assertEquals(fetched_event_from_other_user, event_from_other_user);
|
||||
|
||||
try {
|
||||
await client.fetch(`/events/${event_from_other_user.id}`, {
|
||||
await client.fetch(`/topics/${topic.id}/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: {
|
||||
meta: {
|
||||
foo: 'bar'
|
||||
}
|
||||
type: 'new'
|
||||
}
|
||||
});
|
||||
|
||||
asserts.fail('allowed updating an event in a channel with APPEND_ONLY_EVENTS on');
|
||||
asserts.fail('allowed updating an event in a topic with APPEND_ONLY_EVENTS on');
|
||||
} catch (error) {
|
||||
asserts.assertEquals((error as Error).cause, 'append_only_events');
|
||||
}
|
||||
|
||||
try {
|
||||
await client.fetch(`/events/${event_from_other_user.id}`, {
|
||||
await client.fetch(`/topics/${topic.id}/events/${event_from_other_user.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-session_id': other_user_info.session.id,
|
||||
|
|
@ -158,7 +153,7 @@ Deno.test({
|
|||
}
|
||||
});
|
||||
|
||||
asserts.fail('allowed deleting an event in a channel with APPEND_ONLY_EVENTS on');
|
||||
asserts.fail('allowed deleting an event in a topic with APPEND_ONLY_EVENTS on');
|
||||
} catch (error) {
|
||||
asserts.assertEquals((error as Error).cause, 'append_only_events');
|
||||
}
|
||||
|
|
@ -168,6 +163,7 @@ Deno.test({
|
|||
} finally {
|
||||
Deno.env.delete('APPEND_ONLY_EVENTS');
|
||||
|
||||
clear_topic_events_cache();
|
||||
if (test_server_info) {
|
||||
await test_server_info?.server?.stop();
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
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 } from '@std/http/cookie';
|
||||
import { Cookie, getSetCookies } from '@std/http/cookie';
|
||||
import { generateTotp } from '../utils/totp.ts';
|
||||
import * as fs from '@std/fs';
|
||||
import * as path from '@std/path';
|
||||
|
|
@ -135,11 +136,55 @@ Deno.test({
|
|||
port: test_server_info.port
|
||||
});
|
||||
|
||||
const root_user_info = await get_new_user(client);
|
||||
asserts.assert(root_user_info);
|
||||
const username = random_username();
|
||||
const password = 'password';
|
||||
|
||||
const regular_user_info = await get_new_user(client, {}, root_user_info);
|
||||
asserts.assert(regular_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 upload_body = new FormData();
|
||||
upload_body.append(
|
||||
|
|
@ -151,10 +196,7 @@ Deno.test({
|
|||
`http://${test_server_info.hostname}:${test_server_info.port}/files/test_uploading_to_root_dir.txt`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-session_id': regular_user_info.session.id,
|
||||
'x-totp': await generateTotp(regular_user_info.session.secret)
|
||||
},
|
||||
headers: headers_for_upload_request,
|
||||
body: upload_body
|
||||
}
|
||||
);
|
||||
|
|
@ -162,16 +204,13 @@ Deno.test({
|
|||
asserts.assert(!disallowed_upload_response.ok);
|
||||
await disallowed_upload_response.text();
|
||||
|
||||
await set_user_permissions(client, regular_user_info.user, regular_user_info.session, [...regular_user_info.user.permissions, 'files.write.all']);
|
||||
await set_user_permissions(client, user, session, [...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: {
|
||||
'x-session_id': regular_user_info.session.id,
|
||||
'x-totp': await generateTotp(regular_user_info.session.secret)
|
||||
},
|
||||
headers: headers_for_upload_request,
|
||||
body: upload_body
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,14 +1,12 @@
|
|||
import { getCookies } from '@std/http/cookie';
|
||||
import { SESSIONS } from '../models/session.ts';
|
||||
import { verifyTotp } from './totp.ts';
|
||||
import { USER, USERS } from '../models/user.ts';
|
||||
import { 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';
|
||||
|
|
@ -43,7 +41,3 @@ 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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@ import { decodeBase32 } from '@std/encoding';
|
|||
*
|
||||
* @ignore
|
||||
*/
|
||||
export function counterToBuffer(counter: number): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(8);
|
||||
const view = new DataView(buffer);
|
||||
view.setBigUint64(0, BigInt(counter), false);
|
||||
export function counterToBuffer(counter: number): DataView {
|
||||
const buffer = new DataView(new ArrayBuffer(8));
|
||||
buffer.setBigUint64(0, BigInt(counter), false);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
|
|
@ -24,7 +23,7 @@ export async function generateHmacSha1(
|
|||
): Promise<Uint8Array> {
|
||||
const importedKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new Uint8Array(key),
|
||||
key,
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue