diff --git a/.gitignore b/.gitignore index b74bc5a..639a628 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ data/ -.fsdb +.fsdb* public/files/* -.vscode/* \ No newline at end of file +.vscode/* diff --git a/README.md b/README.md index 4b7bc6b..0d8358c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # autonomous.contact -Bringing the BBS back. +A hub for communities as a single service with no required external dependencies. ## TODO These are in no particular order. Pull requests updating this section welcome for feature discussions. -- [X] should everything be an event in a topic? +- [X] the core is a stream of events - [X] get a first-pass podman/docker setup up - [X] sign up - [X] check for logged in user session @@ -21,14 +21,14 @@ feature discussions. - [X] logout button - [ ] profile editing - [X] avatar uploads -- [X] chat topics +- [X] chat channels - [X] chat messages - [ ] membership and presence - - [ ] add memberships to topics + - [ ] add memberships to channels - [ ] join to get notifications - [ ] join for additional permissions - - [ ] filters for allowing joining a topic based on criteria on the user? - - [ ] display topic members somehwere + - [ ] filters for allowing joining a channel based on criteria on the user? + - [ ] display channel members somehwere - [ ] emit presence events on join/leave - [ ] display user presence - [ ] chat message actions @@ -88,7 +88,7 @@ feature discussions. - [ ] if web notifications are enabled, emit on events - [ ] ability to mute - [ ] users - - [ ] topics + - [ ] channels - [ ] tags (#tags?) - [ ] admin panel - [ ] add invite code generation diff --git a/deno.json b/deno.json index 812bc80..a4b793c 100644 --- a/deno.json +++ b/deno.json @@ -11,11 +11,15 @@ "test": "DENO_ENV=test FSDB_ROOT=$PWD/tests/data/$(date --iso-8601=seconds) SERVERUS_ROOT=$PWD/public SERVERUS_PUT_PATHS_ALLOWED=./files SERVERUS_DELETE_PATHS_ALLOWED=./files deno test --allow-env --allow-read --allow-write --allow-net --allow-import --trace-leaks --fail-fast tests/" }, "test": { - "exclude": ["tests/data/"] + "exclude": [ + "tests/data/" + ] }, "compilerOptions": {}, "fmt": { - "include": ["**/*.ts"], + "include": [ + "**/*.ts" + ], "options": { "useTabs": true, "lineWidth": 180, @@ -25,22 +29,28 @@ } }, "lint": { - "include": ["**/*.ts"], + "include": [ + "**/*.ts" + ], "rules": { - "tags": ["recommended"], - "exclude": ["no-explicit-any"] + "tags": [ + "recommended" + ], + "exclude": [ + "no-explicit-any" + ] } }, "imports": { - "@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.1.0", + "@andyburke/fsdb": "jsr:@andyburke/fsdb@^1.2.4", "@andyburke/lurid": "jsr:@andyburke/lurid@^0.2.0", - "@andyburke/serverus": "jsr:@andyburke/serverus@^0.13.0", + "@andyburke/serverus": "jsr:@andyburke/serverus@^0.16.0", "@da/bcrypt": "jsr:@da/bcrypt@^1.0.1", - "@std/assert": "jsr:@std/assert@^1.0.15", + "@std/assert": "jsr:@std/assert@^1.0.17", "@std/encoding": "jsr:@std/encoding@^1.0.10", - "@std/fs": "jsr:@std/fs@^1.0.19", - "@std/http": "jsr:@std/http@^1.0.21", + "@std/fs": "jsr:@std/fs@^1.0.22", + "@std/http": "jsr:@std/http@^1.0.23", "@std/media-types": "jsr:@std/media-types@^1.1.0", - "@std/path": "jsr:@std/path@^1.1.2" + "@std/path": "jsr:@std/path@^1.1.4" } } diff --git a/deno.lock b/deno.lock index 6397343..0e06569 100644 --- a/deno.lock +++ b/deno.lock @@ -1,38 +1,38 @@ { "version": "5", "specifiers": { - "jsr:@andyburke/fsdb@^1.1.0": "1.1.0", + "jsr:@andyburke/fsdb@^1.2.4": "1.2.4", "jsr:@andyburke/lurid@0.2": "0.2.0", - "jsr:@andyburke/serverus@0.13": "0.13.0", + "jsr:@andyburke/serverus@0.16": "0.16.0", "jsr:@da/bcrypt@*": "1.0.1", "jsr:@da/bcrypt@^1.0.1": "1.0.1", - "jsr:@std/assert@^1.0.15": "1.0.15", - "jsr:@std/cli@^1.0.19": "1.0.23", - "jsr:@std/cli@^1.0.20": "1.0.23", - "jsr:@std/cli@^1.0.21": "1.0.23", - "jsr:@std/cli@^1.0.23": "1.0.23", + "jsr:@std/assert@^1.0.17": "1.0.17", + "jsr:@std/cli@^1.0.19": "1.0.25", + "jsr:@std/cli@^1.0.20": "1.0.25", + "jsr:@std/cli@^1.0.21": "1.0.25", + "jsr:@std/cli@^1.0.25": "1.0.25", "jsr:@std/encoding@^1.0.10": "1.0.10", "jsr:@std/fmt@^1.0.6": "1.0.8", "jsr:@std/fmt@^1.0.8": "1.0.8", - "jsr:@std/fs@^1.0.18": "1.0.19", - "jsr:@std/fs@^1.0.19": "1.0.19", + "jsr:@std/fs@^1.0.18": "1.0.22", + "jsr:@std/fs@^1.0.19": "1.0.22", + "jsr:@std/fs@^1.0.21": "1.0.22", + "jsr:@std/fs@^1.0.22": "1.0.22", "jsr:@std/html@^1.0.5": "1.0.5", - "jsr:@std/http@^1.0.20": "1.0.21", - "jsr:@std/http@^1.0.21": "1.0.21", - "jsr:@std/internal@^1.0.10": "1.0.12", + "jsr:@std/http@^1.0.20": "1.0.23", + "jsr:@std/http@^1.0.23": "1.0.23", "jsr:@std/internal@^1.0.12": "1.0.12", - "jsr:@std/internal@^1.0.9": "1.0.12", "jsr:@std/media-types@^1.1.0": "1.1.0", "jsr:@std/net@^1.0.6": "1.0.6", - "jsr:@std/path@^1.1.0": "1.1.2", - "jsr:@std/path@^1.1.1": "1.1.2", - "jsr:@std/path@^1.1.2": "1.1.2", - "jsr:@std/streams@^1.0.13": "1.0.13", + "jsr:@std/path@^1.1.0": "1.1.4", + "jsr:@std/path@^1.1.1": "1.1.4", + "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/streams@^1.0.16": "1.0.16", "npm:@types/node@*": "22.15.15" }, "jsr": { - "@andyburke/fsdb@1.1.0": { - "integrity": "ad2d062672137ca96df19df032b51f1c7aa3133c973a0b86eb8eaab3b4c2d47b", + "@andyburke/fsdb@1.2.4": { + "integrity": "3437078a5627d4c72d677e41c20293a47d58a3af19eda72869a12acb011064d2", "dependencies": [ "jsr:@std/cli@^1.0.20", "jsr:@std/fs@^1.0.18", @@ -45,8 +45,8 @@ "jsr:@std/cli@^1.0.19" ] }, - "@andyburke/serverus@0.13.0": { - "integrity": "73f451e1b68cd9be3938333b06290bfeab275361453559f40dfeab19dc4ad6d7", + "@andyburke/serverus@0.16.0": { + "integrity": "625fc3f08ddc377beb86b282d603ca6154cf38e136d916ec19a87ae4c4ed86d5", "dependencies": [ "jsr:@std/cli@^1.0.21", "jsr:@std/fmt@^1.0.6", @@ -59,14 +59,14 @@ "@da/bcrypt@1.0.1": { "integrity": "d2172d3acbcff52e0465557a1a48b1ff1c92df08c90712dae5372255a8c45eb3" }, - "@std/assert@1.0.15": { - "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", + "@std/assert@1.0.17": { + "integrity": "df5ebfffe77c03b3fa1401e11c762cc8f603d51021c56c4d15a8c7ab45e90dbe", "dependencies": [ - "jsr:@std/internal@^1.0.12" + "jsr:@std/internal" ] }, - "@std/cli@1.0.23": { - "integrity": "bf95b7a9425ba2af1ae5a6359daf58c508f2decf711a76ed2993cd352498ccca" + "@std/cli@1.0.25": { + "integrity": "1f85051b370c97a7a9dfc6ba626e7ed57a91bea8c081597276d1e78d929d8c91" }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" @@ -74,27 +74,27 @@ "@std/fmt@1.0.8": { "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" }, - "@std/fs@1.0.19": { - "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", + "@std/fs@1.0.22": { + "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308", "dependencies": [ - "jsr:@std/internal@^1.0.9", - "jsr:@std/path@^1.1.1" + "jsr:@std/internal", + "jsr:@std/path@^1.1.4" ] }, "@std/html@1.0.5": { "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" }, - "@std/http@1.0.21": { - "integrity": "abb5c747651ee6e3ea6139858fd9b1810d2c97f53a5e6722f3b6d27a6d263edc", + "@std/http@1.0.23": { + "integrity": "6634e9e034c589bf35101c1b5ee5bbf052a5987abca20f903e58bdba85c80dee", "dependencies": [ - "jsr:@std/cli@^1.0.23", + "jsr:@std/cli@^1.0.25", "jsr:@std/encoding", "jsr:@std/fmt@^1.0.8", - "jsr:@std/fs@^1.0.19", + "jsr:@std/fs@^1.0.21", "jsr:@std/html", "jsr:@std/media-types", "jsr:@std/net", - "jsr:@std/path@^1.1.2", + "jsr:@std/path@^1.1.4", "jsr:@std/streams" ] }, @@ -107,14 +107,14 @@ "@std/net@1.0.6": { "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" }, - "@std/path@1.1.2": { - "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", "dependencies": [ - "jsr:@std/internal@^1.0.10" + "jsr:@std/internal" ] }, - "@std/streams@1.0.13": { - "integrity": "772d208cd0d3e5dac7c1d9e6cdb25842846d136eea4a41a62e44ed4ab0c8dd9e" + "@std/streams@1.0.16": { + "integrity": "85030627befb1767c60d4f65cb30fa2f94af1d6ee6e5b2515b76157a542e89c4" } }, "npm": { @@ -133,16 +133,16 @@ }, "workspace": { "dependencies": [ - "jsr:@andyburke/fsdb@^1.1.0", + "jsr:@andyburke/fsdb@^1.2.4", "jsr:@andyburke/lurid@0.2", - "jsr:@andyburke/serverus@0.13", + "jsr:@andyburke/serverus@0.16", "jsr:@da/bcrypt@^1.0.1", - "jsr:@std/assert@^1.0.15", + "jsr:@std/assert@^1.0.17", "jsr:@std/encoding@^1.0.10", - "jsr:@std/fs@^1.0.19", - "jsr:@std/http@^1.0.21", + "jsr:@std/fs@^1.0.22", + "jsr:@std/http@^1.0.23", "jsr:@std/media-types@^1.1.0", - "jsr:@std/path@^1.1.2" + "jsr:@std/path@^1.1.4" ] } } diff --git a/models/channel.ts b/models/channel.ts new file mode 100644 index 0000000..cd8c011 --- /dev/null +++ b/models/channel.ts @@ -0,0 +1,91 @@ +import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; +import { FSDB_COLLECTION } from '@andyburke/fsdb'; +import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; + +/** + * @typedef {object} CHANNEL_EVENT_PERMISSIONS + * @property {string[]} read a list of user_ids with read permission for the channel events + * @property {string[]} write a list of user_ids with write permission for the channel events + */ + +/** + * @typedef {object} CHANNEL_PERMISSIONS + * @property {string[]} read a list of user_ids with read permission for the channel + * @property {string[]} write a list of user_ids with write permission for the channel + * @property {CHANNEL_EVENT_PERMISSIONS} events + */ + +/** + * @typedef {object} CHANNEL_TIMESTAMPS + * @property {string} created when the channel was created + * @property {string} updated the last time the channel was updated + * @property {string} [archived] an option time the channel was archived + */ + +/** + * CHANNEL + * + * @property {string} id - lurid (stable) + * @property {string} name - channel name (max 64 characters, unique, unstable) + * @property {string} creator_id - user id of the channel creator + * @property {CHANNEL_PERMISSIONS} permissions - permissions setup for the channel + * @property {string} [icon] - optional url for channel icon + * @property {string} [topic] - optional topic for the channel + * @property {string} [rules] - optional channel rules (Markdown/text) + * @property {string[]} [tags] - optional tags for the channel + * @property {Record} [meta] - optional metadata about the channel + * @property {CHANNEL_TIMESTAMPS} timestamps - timestamps + */ + +export type CHANNEL = { + id: string; + name: string; + creator_id: string; + permissions: { + read: string[]; + write: string[]; + events: { + read: string[]; + write: string[]; + }; + }; + icon?: string; + topic?: string; + rules?: string; + tags?: string[]; + meta?: Record; + timestamps: { + created: string; + updated: string; + archived: string | undefined; + }; +}; + +export const CHANNELS = new FSDB_COLLECTION({ + name: 'channels', + id_field: 'id', + organize: by_lurid, + indexers: { + creator_id: new FSDB_INDEXER_SYMLINKS({ + name: 'creator_id', + field: 'creator_id', + to_many: true, + organize: by_lurid + }), + + name: new FSDB_INDEXER_SYMLINKS({ + name: 'name', + get_values_to_index: (channel) => [channel.name.toLowerCase()], + organize: by_character + }), + + tags: new FSDB_INDEXER_SYMLINKS({ + name: 'tags', + get_values_to_index: (channel): string[] => { + return (channel.tags ?? []).map((tag) => tag.toLowerCase()); + }, + to_many: true, + organize: by_character + }) + } +}); diff --git a/models/event.ts b/models/event.ts index 7eefbd8..bd66ac7 100644 --- a/models/event.ts +++ b/models/event.ts @@ -1,4 +1,4 @@ -import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; +import { by_lurid } from '@andyburke/fsdb/organizers'; import { FSDB_COLLECTION } from '@andyburke/fsdb'; import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; import { EMOJIS } from '../public/js/emojis/en.ts'; @@ -16,7 +16,9 @@ import { EMOJIS } from '../public/js/emojis/en.ts'; * @property {string} creator_id - id of the source user * @property {string} type - event type * @property {string} [parent_id] - optional parent event id - * @property {string[]} [tags] - optional event tags + * @property {string} [channel] - optional channel + * @property {string} [topic] - optional topic + * @property {string[]} [tags] - optional tags * @property {Record} [data] - optional data payload of the event * @property {TIMESTAMPS} timestamps - timestamps that will be set by the server */ @@ -25,6 +27,8 @@ export type EVENT = { creator_id: string; type: string; parent_id?: string; + channel?: string; + topic?: string; tags?: string[]; data?: Record; timestamps: { @@ -33,11 +37,6 @@ export type EVENT = { }; }; -type TOPIC_EVENT_CACHE_ENTRY = { - collection: FSDB_COLLECTION; - eviction_timeout: number; -}; - // TODO: separate out these different validators somewhere? export function VALIDATE_EVENT(event: EVENT) { const errors: any[] = []; @@ -111,6 +110,8 @@ export function VALIDATE_EVENT(event: EVENT) { }); } break; + case 'presence': + break; case 'reaction': if (typeof event.parent_id !== 'string') { errors.push({ @@ -148,78 +149,76 @@ export function VALIDATE_EVENT(event: EVENT) { return errors.length ? errors : undefined; } -const TOPIC_EVENT_ID_MATCHER = /^(?.*):(?.*)$/; +const EVENT_ID_EXTRACTOR = /^(?.*):(?.*)$/; -const TOPIC_EVENTS: Record = {}; -export function get_events_collection_for_topic(topic_id: string): FSDB_COLLECTION { - TOPIC_EVENTS[topic_id] = TOPIC_EVENTS[topic_id] ?? { - collection: new FSDB_COLLECTION({ - name: `topics/${topic_id.slice(0, 14)}/${topic_id.slice(0, 34)}/${topic_id}/events`, - id_field: 'id', - organize: (id) => { - TOPIC_EVENT_ID_MATCHER.lastIndex = 0; - - const groups: Record | undefined = TOPIC_EVENT_ID_MATCHER.exec(id ?? '')?.groups; - - if (!groups) { - throw new Error('Could not parse event id: ' + id); - } - - const event_type = groups.event_type; - const event_id = groups.event_id; - - return [ - event_type, - event_id.slice(0, 14), - event_id.slice(0, 34), - event_id, - `${event_id}.json` /* TODO: this should be ${id}.json - need to write a converter */ - ]; - }, - indexers: { - creator_id: new FSDB_INDEXER_SYMLINKS({ - name: 'creator_id', - field: 'creator_id', - to_many: true, - organize: by_lurid - }), - - parent_id: new FSDB_INDEXER_SYMLINKS({ - name: 'parent_id', - field: 'parent_id', - to_many: true, - organize: by_lurid - }), - - tags: new FSDB_INDEXER_SYMLINKS({ - name: 'tags', - get_values_to_index: (event: EVENT): string[] => { - return (event.tags ?? []).map((tag: string) => tag.toLowerCase()); - }, - to_many: true, - organize: by_character - }) - } - }), - eviction_timeout: 0 - }; - - if (TOPIC_EVENTS[topic_id].eviction_timeout) { - clearTimeout(TOPIC_EVENTS[topic_id].eviction_timeout); - } - - TOPIC_EVENTS[topic_id].eviction_timeout = setTimeout(() => { - delete TOPIC_EVENTS[topic_id]; - }, 60_000 * 5); - - return TOPIC_EVENTS[topic_id].collection; +function smart_event_id_organizer(id: string) { + const [event_type, event_id] = id.split(':', 2); + const event_dirs = by_lurid(event_id).slice(0, -1); + return [event_type, ...event_dirs, `${id}.json`]; } -export function clear_topic_events_cache() { - for (const [topic_id, cached] of Object.entries(TOPIC_EVENTS)) { - if (cached.eviction_timeout) { - clearTimeout(cached.eviction_timeout); +export const EVENTS = new FSDB_COLLECTION({ + name: `events`, + id_field: 'id', + organize: (id) => { + EVENT_ID_EXTRACTOR.lastIndex = 0; + + const groups: Record | undefined = EVENT_ID_EXTRACTOR.exec(id ?? '')?.groups; + + if (!groups) { + throw new Error('Could not parse event id: ' + id); } - delete TOPIC_EVENTS[topic_id]; + + const event_type = groups.event_type; + const event_id = groups.event_id; + + return [ + event_type, + event_id.slice(0, 14), + event_id.slice(0, 34), + event_id, + `${id}.json` + ]; + }, + indexers: { + creator_id: new FSDB_INDEXER_SYMLINKS({ + name: 'creator_id', + field: 'creator_id', + to_many: true, + organize: by_lurid + }), + + parent_id: new FSDB_INDEXER_SYMLINKS({ + name: 'parent_id', + field: 'parent_id', + to_many: true, + organize: smart_event_id_organizer + }), + + channel: new FSDB_INDEXER_SYMLINKS({ + name: 'channel', + field: 'channel', + to_many: true, + organize: (channel: string) => [channel], + organize_id: smart_event_id_organizer + }), + + topic: new FSDB_INDEXER_SYMLINKS({ + name: 'topic', + field: 'topic', + to_many: true, + organize: (topic: string) => [topic], + organize_id: smart_event_id_organizer + }), + + tags: new FSDB_INDEXER_SYMLINKS({ + name: 'tags', + get_values_to_index: (event: EVENT): string[] => { + return (event.tags ?? []).map((tag: string) => tag.toLowerCase()); + }, + to_many: true, + organize: (tag: string) => tag.length > 3 ? [tag.substring(0, 3), tag] : [tag], + organize_id: smart_event_id_organizer + }) } -} +}); diff --git a/models/topic.ts b/models/topic.ts deleted file mode 100644 index fa01e66..0000000 --- a/models/topic.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { by_character, by_lurid } from '@andyburke/fsdb/organizers'; -import { FSDB_COLLECTION } from '@andyburke/fsdb'; -import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; - -/** - * @typedef {object} TOPIC_PERMISSIONS - * @property {string[]} read a list of user_ids with read permission for the topic - * @property {string[]} write a list of user_ids with write permission for the topic - * @property {string[]} read_events a list of user_ids with read_events permission for this topic - * @property {string[]} write_events a list of user_ids with write_events permission for this topic - */ - -/** - * TOPIC - * - * @property {string} id - lurid (stable) - * @property {string} name - channel name (max 64 characters, unique, unstable) - * @property {string} creator_id - user id of the topic creator - * @property {TOPIC_PERMISSIONS} permissions - permissions setup for the topic - * @property {string} [icon_url] - optional url for topic icon - * @property {string} [topic] - optional topic for the topic - * @property {string} [rules] - optional topic rules (Markdown/text) - * @property {string[]} [tags] - optional tags for the topic - * @property {Record} [meta] - optional metadata about the topic - * @property {Record} [emojis] - optional emojis table, eg: { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } - */ - -export type TOPIC = { - id: string; - name: string; - creator_id: string; - permissions: { - read: string[]; - write: string[]; - read_events: string[]; - write_events: string[]; - }; - icon_url?: string; - topic?: string; - rules?: string; - tags?: string[]; - meta?: Record; - emojis?: Record; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } - timestamps: { - created: string; - updated: string; - archived: string | undefined; - }; -}; - -export const TOPICS = new FSDB_COLLECTION({ - name: 'topics', - id_field: 'id', - organize: by_lurid, - indexers: { - creator_id: new FSDB_INDEXER_SYMLINKS({ - name: 'creator_id', - field: 'creator_id', - to_many: true, - organize: by_lurid - }), - - name: new FSDB_INDEXER_SYMLINKS({ - name: 'name', - get_values_to_index: (topic) => [topic.name.toLowerCase()], - organize: by_character - }), - - tags: new FSDB_INDEXER_SYMLINKS({ - name: 'tags', - get_values_to_index: (topic): string[] => { - return (topic.tags ?? []).map((tag) => tag.toLowerCase()); - }, - to_many: true, - organize: by_character - }), - - topic: new FSDB_INDEXER_SYMLINKS({ - name: 'topic', - get_values_to_index: (topic): string[] => { - return (topic.topic ?? '').split(/\W/); - }, - to_many: true, - organize: by_character - }) - } -}); diff --git a/models/watch.ts b/models/watch.ts index 97e13b4..2d460ec 100644 --- a/models/watch.ts +++ b/models/watch.ts @@ -2,32 +2,30 @@ import { FSDB_COLLECTION } from '@andyburke/fsdb'; import { by_lurid } from '@andyburke/fsdb/organizers'; import { FSDB_INDEXER_SYMLINKS } from '@andyburke/fsdb/indexers'; -/** - * @typedef {object} WATCH_TYPE_INFO - * @property {boolean} ignored if true, this type should NOT produce any indications or notifications - * @property {string} last_id_seen the last event id the user has seen for this event type - * @property {string} last_id_notified the last event id the user was notified about for this type - */ - -export type WATCH_TYPE_INFO = { - ignored: boolean; - last_id_seen: string; - last_id_notified: string; -}; - /** * @typedef {object} WATCH_TIMESTAMPS * @property {string} created the created date of the watch * @property {string} updated the last updated date, usually coinciding with the last seen id being changed */ +export type WATCH_TIMESTAMPS = { + created: string; + updated: string; +}; + /** * WATCH * * @property {string} id - lurid (stable) * @property {string} creator_id - user id of the watch creator - * @property {string} topic_id - the topic_id being watched - * @property {[WATCH_TYPE_INFO]} types - information for types being watched within this topic + * @property {string} [type] - a filter for event type + * @property {string} [parent_id] - a filter for event parent_id + * @property {string} [channel] - a filter for event channel + * @property {string} [topic] - a filter for event topic + * @property {string[]} [tags] - a filter for event tags + * @property {Record} [data] - a filter on event data, each leaf should be a RegExp + * @property {string} last_id_seen - the last id the user has seen for this watch + * @property {string} [last_id_notified] - the last id the user was notified about for this watch * @property {Record} [meta] - optional metadata about the watch * @property {WATCH_TIMESTAMPS} timestamps - timestamps for the watch */ @@ -35,13 +33,16 @@ export type WATCH_TYPE_INFO = { export type WATCH = { id: string; creator_id: string; - topic_id: string; - types: [WATCH_TYPE_INFO]; + type?: string; + parent_id?: string; + channel?: string; + topic?: string; + tags?: string[]; + data?: Record; + last_id_seen: string; + last_id_notified?: string; meta?: Record; - timestamps: { - created: string; - updated: string; - }; + timestamps: WATCH_TIMESTAMPS; }; export const WATCHES = new FSDB_COLLECTION({ @@ -56,11 +57,44 @@ export const WATCHES = new FSDB_COLLECTION({ organize: by_lurid }), - topic_id: new FSDB_INDEXER_SYMLINKS({ - name: 'topic_id', - field: 'topic_id', + type: new FSDB_INDEXER_SYMLINKS({ + name: 'type', + field: 'type', to_many: true, - organize: by_lurid + organize: (type: string) => [type], + organize_id: by_lurid + }), + + parent_id: new FSDB_INDEXER_SYMLINKS({ + name: 'parent_id', + field: 'parent_id', + to_many: true, + organize: by_lurid, + organize_id: by_lurid + }), + + channel: new FSDB_INDEXER_SYMLINKS({ + name: 'channel', + field: 'channel', + to_many: true, + organize: (channel: string) => channel.length > 3 ? [channel.substring(0, 3), channel] : [channel], + organize_id: by_lurid + }), + + topic: new FSDB_INDEXER_SYMLINKS({ + name: 'topic', + field: 'topic', + to_many: true, + organize: (topic: string) => topic.length > 3 ? [topic.substring(0, 3), topic] : [topic], + organize_id: by_lurid + }), + + tags: new FSDB_INDEXER_SYMLINKS({ + name: 'tags', + field: 'tags', + to_many: true, + organize: (tag: string) => tag.length > 3 ? [tag.substring(0, 3), tag] : [tag], + organize_id: by_lurid }) } }); diff --git a/public/api/topics/:topic_id/events/:event_id/README.md b/public/.spa similarity index 100% rename from public/api/topics/:topic_id/events/:event_id/README.md rename to public/.spa diff --git a/public/api/auth/index.ts b/public/api/auth/index.ts index c69d8cf..7d47e32 100644 --- a/public/api/auth/index.ts +++ b/public/api/auth/index.ts @@ -6,10 +6,11 @@ import { SESSION, SESSIONS } from '../../../models/session.ts'; import { TOTP_ENTRIES } from '../../../models/totp_entry.ts'; import { encodeBase64 } from '@std/encoding/base64'; import parse_body from '../../../utils/bodyparser.ts'; -import { get_session, get_user, PRECHECK_TABLE, require_user, SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts'; +import { AUTHED_BEFORE_COOKIE_ID, get_session, get_user, PRECHECK_TABLE, require_user, SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts'; import * as bcrypt from '@da/bcrypt'; import { verifyTotp } from '../../../utils/totp.ts'; +const AUTHED_BEFORE_EXPIRATION: number = 399 * (24 * (60 * (60 * 1_000))); // 399 days const DEFAULT_SESSION_TIME: number = 365 * (24 * (60 * (60 * 1_000))); // 365 days export const PRECHECKS: PRECHECK_TABLE = {}; @@ -206,6 +207,7 @@ export async function create_new_session(session_settings: SESSION_INFO): Promis const headers = new Headers(); const expires_in_utc = new Date(session.timestamps.expires).toUTCString(); + headers.append('Set-Cookie', `${AUTHED_BEFORE_COOKIE_ID}=1; Path=/; Secure; Expires=${new Date(new Date(now).valueOf() + AUTHED_BEFORE_EXPIRATION).toUTCString()}`); headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=${session.id}; Path=/; Secure; Expires=${expires_in_utc}`); headers.append(`x-${SESSION_ID_TOKEN}`, session.id); diff --git a/public/api/channels/:channel_id/README.md b/public/api/channels/:channel_id/README.md new file mode 100644 index 0000000..f3e8787 --- /dev/null +++ b/public/api/channels/:channel_id/README.md @@ -0,0 +1,23 @@ +# /api/channels/:channel_id + +Interact with a specific channel. + +## GET /api/channels/:channel_id + +Get the channel specified by `:channel_id`. + +## PUT /api/channels/:channel_id + +Update the channels specified by `:channel_id`. + +Eg: + +``` +{ + name?: string; +} +``` + +## DELETE /api/channels/:channel_id + +Delete the channel specified by `:channel_id`. diff --git a/public/api/channels/:channel_id/events/:event_id/index.ts b/public/api/channels/:channel_id/events/:event_id/index.ts new file mode 100644 index 0000000..2759b9d --- /dev/null +++ b/public/api/channels/:channel_id/events/:event_id/index.ts @@ -0,0 +1,40 @@ +import { EVENT, EVENTS } from '../../../../../../models/event.ts'; +import { CHANNEL, CHANNELS } from '../../../../../../models/channel.ts'; +import * as CANNED_RESPONSES from '../../../../../../utils/canned_responses.ts'; +import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../../utils/prechecks.ts'; + +export const PRECHECKS: PRECHECK_TABLE = {}; + +// GET /api/channels/:channel_id/events/:id - Get an event +PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; + + if (!channel) { + return CANNED_RESPONSES.not_found(); + } + + meta.channel = channel; + const channel_is_public = channel.permissions.read.length === 0; + const user_has_read_for_channel = channel_is_public || channel.permissions.read.includes(meta.user.id); + const channel_has_public_events = user_has_read_for_channel && (channel.permissions.events.read.length === 0); + const user_has_read_events_for_channel = user_has_read_for_channel && + (channel_has_public_events || channel.permissions.events.read.includes(meta.user.id)); + + if (!user_has_read_events_for_channel) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function GET(_req: Request, meta: Record): Promise { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + return Response.json(event, { + status: 200 + }); +} diff --git a/public/api/channels/:channel_id/events/index.ts b/public/api/channels/:channel_id/events/index.ts new file mode 100644 index 0000000..1d42417 --- /dev/null +++ b/public/api/channels/:channel_id/events/index.ts @@ -0,0 +1,134 @@ +import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; +import { CHANNEL, CHANNELS } from '../../../../../models/channel.ts'; +import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts'; +import { EVENT, EVENTS } from '../../../../../models/event.ts'; +import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; + +export const PRECHECKS: PRECHECK_TABLE = {}; + +// GET /api/channels/:channel_id/events - get channel events +// query parameters: +// partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted) +PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; + + // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; + + if (!channel) { + return CANNED_RESPONSES.not_found(); + } + + meta.channel = channel; + + const channel_is_public: boolean = meta.channel.permissions.read.length === 0; + const user_has_read_for_channel = channel_is_public || meta.channel.permissions.read.includes(meta.user.id); + + if (!user_has_read_for_channel) { + return CANNED_RESPONSES.permission_denied(); + } +}]; +export async function GET(request: Request, meta: Record): Promise { + const sorts = EVENTS.sorts; + const sort_name: string = meta.query.sort ?? 'newest'; + const key = sort_name as keyof typeof sorts; + const sort: any = sorts[key]; + if (!sort) { + return Response.json({ + error: { + message: 'You must specify a sort: newest, oldest, latest, stalest', + cause: 'invalid_sort' + } + }, { + status: 400 + }); + } + + const options: FSDB_SEARCH_OPTIONS = { + ...(meta.query ?? {}), + limit: Math.min(parseInt(meta.query?.limit ?? '10', 10), 1_000), + offset: Math.max(parseInt(meta.query?.offset ?? '0', 10), 0), + sort, + filter: (entry: WALK_ENTRY) => { + const { + event_type, + event_id + } = /^.*\/(?.*?):(?[A-Za-z-]+)\.json$/.exec(entry.path)?.groups ?? {}; + + if (meta.query.after_id && event_id <= meta.query.after_id) { + return false; + } + + if (meta.query.before_id && event_id >= meta.query.before_id) { + return false; + } + + if (meta.query.type && !meta.query.type.split(',').includes(event_type)) { + return false; + } + + return true; + } + }; + + const headers = { + 'Cache-Control': 'no-cache, must-revalidate' + }; + + const results = (await EVENTS.find({ + channel: meta.channel.id + }, options)) + .map((entry: WALK_ENTRY) => entry.load()) + .sort((lhs_item: EVENT, rhs_item: EVENT) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created)); + + // long-polling support + if (results.length === 0 && meta.query.wait) { + return new Promise((resolve, reject) => { + function on_create(create_event: any) { + if (create_event.item.channel !== meta.channel.id) { + return; + } + + if (meta.query.type && !meta.query.type.split(',').includes(create_event.item.type)) { + return; + } + + results.push(create_event.item); + clearTimeout(timeout); + EVENTS.off('create', on_create); + + return resolve(Response.json(results, { + status: 200, + headers + })); + } + + const timeout = setTimeout(() => { + EVENTS.off('create', on_create); + return resolve(Response.json(results, { + status: 200, + headers + })); + }, 60_000); // 60 seconds + EVENTS.on('create', on_create); + request.signal.addEventListener('abort', () => { + EVENTS.off('create', on_create); + clearTimeout(timeout); + reject(new Error('request aborted')); + }); + Deno.addSignalListener('SIGINT', () => { + EVENTS.off('create', on_create); + clearTimeout(timeout); + return resolve(Response.json(results, { + status: 200, + headers + })); + }); + }); + } + + return Response.json(results, { + status: 200, + headers + }); +} diff --git a/public/api/topics/:topic_id/index.ts b/public/api/channels/:channel_id/index.ts similarity index 57% rename from public/api/topics/:topic_id/index.ts rename to public/api/channels/:channel_id/index.ts index dd656bc..59a9234 100644 --- a/public/api/topics/:topic_id/index.ts +++ b/public/api/channels/:channel_id/index.ts @@ -1,50 +1,50 @@ import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../utils/prechecks.ts'; import parse_body from '../../../../utils/bodyparser.ts'; import * as CANNED_RESPONSES from '../../../../utils/canned_responses.ts'; -import { TOPIC, TOPICS } from '../../../../models/topic.ts'; +import { CHANNEL, CHANNELS } from '../../../../models/channel.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; -// GET /api/topics/:id - Get a topic +// GET /api/channels/:id - Get a channel PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; - if (!topic) { + if (!channel) { return CANNED_RESPONSES.not_found(); } - meta.topic = topic; - const topic_is_public = topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || topic.permissions.read.includes(meta.user.id); + meta.channel = channel; + const channel_is_public = channel.permissions.read.length === 0; + const user_has_read_for_channel = channel_is_public || channel.permissions.read.includes(meta.user.id); - if (!user_has_read_for_topic) { + if (!user_has_read_for_channel) { return CANNED_RESPONSES.permission_denied(); } }]; export function GET(_req: Request, meta: Record): Response { - return Response.json(meta.topic, { + return Response.json(meta.channel, { status: 200 }); } -// PUT /api/topics/:id - Update topic +// PUT /api/channels/:id - Update channel PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; - if (!topic) { + if (!channel) { return CANNED_RESPONSES.not_found(); } - meta.topic = topic; - const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id); + meta.channel = channel; + const user_has_write_for_channel = channel.permissions.write.includes(meta.user.id); - if (!user_has_write_for_topic) { + if (!user_has_write_for_channel) { return CANNED_RESPONSES.permission_denied(); } }]; @@ -54,16 +54,16 @@ export async function PUT(req: Request, meta: Record): Promise): Promise): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; + const channel_id: string = meta.params?.channel_id?.toLowerCase().trim() ?? ''; // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; + const channel: CHANNEL | null = channel_id.length === 49 ? await CHANNELS.get(channel_id) : null; - if (!topic) { + if (!channel) { return CANNED_RESPONSES.not_found(); } - meta.topic = topic; - const user_has_write_for_topic = topic.permissions.write.includes(meta.user.id); + meta.channel = channel; + const user_has_write_for_channel = channel.permissions.write.includes(meta.user.id); - if (!user_has_write_for_topic) { + if (!user_has_write_for_channel) { return CANNED_RESPONSES.permission_denied(); } } ]; export async function DELETE(_req: Request, meta: Record): Promise { - await TOPICS.delete(meta.topic); + await CHANNELS.delete(meta.channel); return Response.json({ deleted: true diff --git a/public/api/channels/README.md b/public/api/channels/README.md new file mode 100644 index 0000000..c16c81b --- /dev/null +++ b/public/api/channels/README.md @@ -0,0 +1,28 @@ +# /api/channels + +Interact with channels. + +## POST /api/channels + +Create a new channel. + +``` +export type CHANNEL = { + id: string; // unique id for this channel + name: string; // the name of the channel (max 128 characters) + icon?: string; // optional url for a channel icon + topic?: string; // optional channel topic + tags?: string[]; // optional tags for the channel + meta?: Record; // optional metadata + limits: { + users: number; + user_messages_per_minute: number; + }; + creator_id: string; // user_id of the topic creator + emojis: Record; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } +}; +``` + +## GET /api/channels + +Get channels. diff --git a/public/api/topics/index.ts b/public/api/channels/index.ts similarity index 61% rename from public/api/topics/index.ts rename to public/api/channels/index.ts index a8120b7..30fde2a 100644 --- a/public/api/topics/index.ts +++ b/public/api/channels/index.ts @@ -3,40 +3,34 @@ import parse_body from '../../../utils/bodyparser.ts'; import { get_session, get_user, require_user } from '../../../utils/prechecks.ts'; import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts'; import { PRECHECK_TABLE } from '../../../utils/prechecks.ts'; -import { TOPIC, TOPICS } from '../../../models/topic.ts'; -import { WALK_ENTRY } from '@andyburke/fsdb'; +import { CHANNEL, CHANNELS } from '../../../models/channel.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; -// GET /api/topics - get topics +// GET /api/channels - get channels PRECHECKS.GET = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const can_read_topics = meta.user.permissions.includes('topics.read'); + const can_read_channels = meta.user.permissions.includes('channels.read'); - if (!can_read_topics) { + if (!can_read_channels) { return CANNED_RESPONSES.permission_denied(); } }]; export async function GET(_req: Request, meta: Record): Promise { const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100); - const topics = (await TOPICS.all({ - limit, - filter: (entry: WALK_ENTRY) => { - // we push our event collections into the topics, and fsdb - // doesn't yet filter that out in its all() logic - return entry.path.indexOf('/events/') === -1; - } - })).map((topic_entry) => topic_entry.load()); + const channels = (await CHANNELS.all({ + limit + })).map((channel_entry) => channel_entry.load()); - return Response.json(topics, { + return Response.json(channels, { status: 200 }); } -// POST /api/topics - Create a topic +// POST /api/channels - Create a channel PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { - const can_create_topics = meta.user.permissions.includes('topics.create'); + const can_create_channels = meta.user.permissions.includes('channels.create'); - if (!can_create_topics) { + if (!can_create_channels) { return CANNED_RESPONSES.permission_denied(); } }]; @@ -49,8 +43,8 @@ export async function POST(req: Request, meta: Record): Promise): Promise 64) { return Response.json({ error: { - cause: 'invalid_topic_name', - message: 'topic names must be 64 characters or fewer.' + cause: 'invalid_channel_name', + message: 'channel names must be 64 characters or fewer.' } }, { status: 400 @@ -70,29 +64,31 @@ export async function POST(req: Request, meta: Record): Promise): Promise): Promise { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + return Response.json(event, { + status: 200 + }); +} + +// PUT /api/events/:event_id - Update event +PRECHECKS.PUT = [ + get_session, + get_user, + require_user, + (_req: Request, _meta: Record): Response | undefined => { + if (Deno.env.get('APPEND_ONLY_EVENTS')) { + return CANNED_RESPONSES.append_only_events(); + } + }, + (_req: Request, meta: Record): Response | undefined => { + if (!meta.user.permissions.some((permission: string) => permission.indexOf('events.write') === 0)) { + return CANNED_RESPONSES.permission_denied(); + } + } +]; +export async function PUT(req: Request, meta: Record): Promise { + const now = new Date().toISOString(); + + try { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + if (event.creator_id !== meta.user.id) { + return CANNED_RESPONSES.permission_denied(); + } + + const body = await parse_body(req); + const updated: EVENT = { + ...event, + ...body, + id: event.id, + creator_id: event.creator_id, + channel: event.channel, + timestamps: { + created: event.timestamps.created, + updated: now + } + }; + + if (updated.channel) { + const channel: CHANNEL | null = await CHANNELS.get(updated.channel); + if (!channel) { + return Response.json({ + errors: [{ + cause: 'missing_channel', + message: 'No such channel exists.' + }] + }, { + status: 400 + }); + } + + const user_can_write_events_to_channel = channel.permissions.events.write.length === 0 ? true : channel.permissions.events.write.includes(meta.user.id); + + if (!user_can_write_events_to_channel) { + return CANNED_RESPONSES.permission_denied(); + } + } + + if (!user_has_write_permission_for_event(meta.user, updated)) { + return CANNED_RESPONSES.permission_denied(); + } + + await EVENTS.update(updated); + return Response.json(updated, { + status: 200 + }); + } catch (err) { + return Response.json({ + error: { + message: (err as Error)?.message ?? 'Unknown error due to invalid data.', + cause: (err as Error)?.cause ?? 'invalid_data' + } + }, { + status: 400 + }); + } +} + +// DELETE /api/events/:event_id - Delete event +PRECHECKS.DELETE = [ + get_session, + get_user, + require_user, + (_req: Request, _meta: Record): Response | undefined => { + if (Deno.env.get('APPEND_ONLY_EVENTS')) { + return CANNED_RESPONSES.append_only_events(); + } + }, + (_req: Request, meta: Record): Response | undefined => { + if (!meta.user.permissions.some((permission: string) => permission.indexOf('events.write') === 0)) { + return CANNED_RESPONSES.permission_denied(); + } + } +]; +export async function DELETE(_req: Request, meta: Record): Promise { + const event: EVENT | null = await EVENTS.get(meta.params.event_id); + if (!event) { + return CANNED_RESPONSES.not_found(); + } + + if (event.channel) { + const channel: CHANNEL | null = await CHANNELS.get(event.channel); + if (!channel) { + return Response.json({ + errors: [{ + cause: 'missing_channel', + message: 'No such channel exists.' + }] + }, { + status: 400 + }); + } + + const user_can_write_events_to_channel = channel.permissions.events.write.length === 0 ? true : channel.permissions.events.write.includes(meta.user.id); + + if (!user_can_write_events_to_channel) { + return CANNED_RESPONSES.permission_denied(); + } + } + + if (!user_has_write_permission_for_event(meta.user, event)) { + return CANNED_RESPONSES.permission_denied(); + } + + await EVENTS.delete(event); + + return Response.json({ + deleted: true + }, { + status: 200 + }); +} diff --git a/public/api/events/README.md b/public/api/events/README.md new file mode 100644 index 0000000..71c7345 --- /dev/null +++ b/public/api/events/README.md @@ -0,0 +1,11 @@ +# /api/events + +Interact with events. + +## GET /api/events + +Get events. + +## POST /api/events + +Create an event. diff --git a/public/api/topics/:topic_id/events/index.ts b/public/api/events/index.ts similarity index 50% rename from public/api/topics/:topic_id/events/index.ts rename to public/api/events/index.ts index 95f947d..ca2fe45 100644 --- a/public/api/topics/:topic_id/events/index.ts +++ b/public/api/events/index.ts @@ -1,42 +1,21 @@ import lurid from '@andyburke/lurid'; -import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; -import { TOPIC, TOPICS } from '../../../../../models/topic.ts'; -import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts'; -import { EVENT, get_events_collection_for_topic, VALIDATE_EVENT } from '../../../../../models/event.ts'; -import parse_body from '../../../../../utils/bodyparser.ts'; -import { FSDB_COLLECTION, FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; -import { WATCH, WATCHES } from '../../../../../models/watch.ts'; +import { get_session, get_user, PRECHECK_TABLE, require_user, user_has_write_permission_for_event } from '../../../utils/prechecks.ts'; +import * as CANNED_RESPONSES from '../../../utils/canned_responses.ts'; +import { EVENT, EVENTS, VALIDATE_EVENT } from '../../../models/event.ts'; +import parse_body from '../../../utils/bodyparser.ts'; +import { FSDB_SEARCH_OPTIONS, WALK_ENTRY } from '@andyburke/fsdb'; +import { WATCH, WATCHES } from '../../../models/watch.ts'; +import { flatten } from '../../../utils/object_helpers.ts'; +import { CHANNEL, CHANNELS } from '../../../models/channel.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; -// GET /api/topics/:topic_id/events - get topic events +// GET /api/events - get events // query parameters: // partial_id: the partial id subset you would like to match (remember, lurids are lexigraphically sorted) -PRECHECKS.GET = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; - - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public: boolean = meta.topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id); - const topic_events_are_public = meta.topic.permissions.read_events.length === 0; - const user_has_read_events_for_topic = user_has_read_for_topic && - (topic_events_are_public || meta.topic.permissions.read_events.includes(meta.user.id)); - - if (!user_has_read_events_for_topic) { - return CANNED_RESPONSES.permission_denied(); - } -}]; +PRECHECKS.GET = [get_session, get_user, require_user]; export async function GET(request: Request, meta: Record): Promise { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - - const sorts = events.sorts; + const sorts = EVENTS.sorts; const sort_name: string = meta.query.sort ?? 'newest'; const key = sort_name as keyof typeof sorts; const sort: any = sorts[key]; @@ -53,7 +32,7 @@ export async function GET(request: Request, meta: Record): Promise< const options: FSDB_SEARCH_OPTIONS = { ...(meta.query ?? {}), - limit: Math.min(parseInt(meta.query?.limit ?? '10', 10), 1_000), + limit: Math.min(parseInt(meta.query?.limit ?? '100', 10), 1_000), offset: Math.max(parseInt(meta.query?.offset ?? '0', 10), 0), sort, filter: (entry: WALK_ENTRY) => { @@ -82,8 +61,9 @@ export async function GET(request: Request, meta: Record): Promise< 'Cache-Control': 'no-cache, must-revalidate' }; - const results = (await events.all(options)) + const results = (await EVENTS.all(options)) .map((entry: WALK_ENTRY) => entry.load()) + .filter((event) => typeof event.channel === 'undefined') // channel events must be queried via the channel's api .sort((lhs_item: EVENT, rhs_item: EVENT) => rhs_item.timestamps.created.localeCompare(lhs_item.timestamps.created)); // long-polling support @@ -96,7 +76,7 @@ export async function GET(request: Request, meta: Record): Promise< results.push(create_event.item); clearTimeout(timeout); - events.off('create', on_create); + EVENTS.off('create', on_create); return resolve(Response.json(results, { status: 200, @@ -105,20 +85,20 @@ export async function GET(request: Request, meta: Record): Promise< } const timeout = setTimeout(() => { - events.off('create', on_create); + EVENTS.off('create', on_create); return resolve(Response.json(results, { status: 200, headers })); }, 60_000); // 60 seconds - events.on('create', on_create); + EVENTS.on('create', on_create); request.signal.addEventListener('abort', () => { - events.off('create', on_create); + EVENTS.off('create', on_create); clearTimeout(timeout); reject(new Error('request aborted')); }); Deno.addSignalListener('SIGINT', () => { - events.off('create', on_create); + EVENTS.off('create', on_create); clearTimeout(timeout); return resolve(Response.json(results, { status: 200, @@ -134,52 +114,78 @@ export async function GET(request: Request, meta: Record): Promise< }); } -async function update_watches(topic: TOPIC, event: EVENT) { +async function update_watches(event: EVENT) { const limit = 100; let more_to_process; let offset = 0; do { - const watches: WATCH[] = (await WATCHES.find({ - topic_id: topic.id - }, { + const watches: WATCH[] = (await WATCHES.all({ limit, offset })).map((entry) => entry.load()); - // TODO: look at the watch .types[] and send notifications + for (const watch of watches) { + if (typeof watch.type === 'string' && event.type !== watch.type) { + continue; + } + + if (typeof watch.parent_id === 'string' && event.parent_id !== watch.parent_id) { + continue; + } + + if (typeof watch.channel === 'string' && event.channel !== watch.channel) { + continue; + } + + if (typeof watch.topic === 'string' && event.topic !== watch.topic) { + continue; + } + + if (typeof watch.tags !== 'undefined' && !watch.tags.every((tag) => event.tags?.includes(tag))) { + continue; + } + + if (typeof watch.data !== 'undefined') { + const event_data = flatten(event.data ?? {}); + for (const [key, value] of Object.entries(flatten(watch.data))) { + const matcher = new RegExp(value); + if (!matcher.test(event_data[key])) { + continue; + } + } + } + + if (event.id < watch.last_id_seen) { + continue; + } + + watch.last_id_seen = event.id; + + // TODO: send a notification + console.dir({ + notification: { + watch, + event + } + }); + } offset += watches.length; more_to_process = watches.length === limit; } while (more_to_process); } -// POST /api/topics/:topic_id/events - Create an event -PRECHECKS.POST = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; +// POST /api/events - Create an event +PRECHECKS.POST = [get_session, get_user, require_user, (_req: Request, meta: Record): Response | undefined => { + const user_can_create_events = meta.user.permissions.some((permission: string) => permission.indexOf('events.create') === 0); - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public: boolean = meta.topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id); - const topic_events_are_publicly_writable = meta.topic.permissions.write_events.length === 0; - const user_has_write_events_for_topic = user_has_read_for_topic && - (topic_events_are_publicly_writable || meta.topic.permissions.write_events.includes(meta.user.id)); - - if (!user_has_write_events_for_topic) { + if (!user_can_create_events) { return CANNED_RESPONSES.permission_denied(); } }]; export async function POST(req: Request, meta: Record): Promise { try { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - const now = new Date().toISOString(); const body = await parse_body(req); @@ -204,11 +210,33 @@ export async function POST(req: Request, meta: Record): Promise): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; - - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public = topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || topic.permissions.read.includes(meta.user.id); - const topic_has_public_events = user_has_read_for_topic && (topic.permissions.read_events.length === 0); - const user_has_read_events_for_topic = user_has_read_for_topic && - (topic_has_public_events || topic.permissions.read_events.includes(meta.user.id)); - - if (!user_has_read_events_for_topic) { - return CANNED_RESPONSES.permission_denied(); - } -}]; -export async function GET(_req: Request, meta: Record): Promise { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - const event: EVENT | null = await events.get(meta.params.event_id); - - if (!event) { - return CANNED_RESPONSES.not_found(); - } - - return Response.json(event, { - status: 200 - }); -} - -// PUT /api/topics/:topic_id/events/:event_id - Update event -PRECHECKS.PUT = [ - get_session, - get_user, - require_user, - (_req: Request, _meta: Record): Response | undefined => { - if (Deno.env.get('APPEND_ONLY_EVENTS')) { - return CANNED_RESPONSES.append_only_events(); - } - }, - async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; - - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public: boolean = meta.topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id); - const topic_events_are_publicly_writable = meta.topic.permissions.write_events.length === 0; - const user_has_write_events_for_topic = user_has_read_for_topic && - (topic_events_are_publicly_writable || meta.topic.permissions.write_events.includes(meta.user.id)); - - if (!user_has_write_events_for_topic) { - return CANNED_RESPONSES.permission_denied(); - } - } -]; -export async function PUT(req: Request, meta: Record): Promise { - const now = new Date().toISOString(); - - try { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - const event: EVENT | null = await events.get(meta.params.event_id); - - if (!event) { - return CANNED_RESPONSES.not_found(); - } - - if (event.creator_id !== meta.user.id) { - return CANNED_RESPONSES.permission_denied(); - } - - const body = await parse_body(req); - const updated = { - ...event, - ...body, - id: event.id, - creator_id: event.creator_id, - timestamps: { - created: event.timestamps.created, - updated: now - } - }; - - await events.update(updated); - return Response.json(updated, { - status: 200 - }); - } catch (err) { - return Response.json({ - error: { - message: (err as Error)?.message ?? 'Unknown error due to invalid data.', - cause: (err as Error)?.cause ?? 'invalid_data' - } - }, { - status: 400 - }); - } -} - -// DELETE /api/topics/:topic_id/events/:event_id - Delete event -PRECHECKS.DELETE = [ - get_session, - get_user, - require_user, - (_req: Request, _meta: Record): Response | undefined => { - if (Deno.env.get('APPEND_ONLY_EVENTS')) { - return CANNED_RESPONSES.append_only_events(); - } - }, - async (_req: Request, meta: Record): Promise => { - const topic_id: string = meta.params?.topic_id?.toLowerCase().trim() ?? ''; - - // lurid is 49 chars as we use them, eg: "also-play-flow-want-form-wide-thus-work-burn-same" - const topic: TOPIC | null = topic_id.length === 49 ? await TOPICS.get(topic_id) : null; - - if (!topic) { - return CANNED_RESPONSES.not_found(); - } - - meta.topic = topic; - const topic_is_public: boolean = meta.topic.permissions.read.length === 0; - const user_has_read_for_topic = topic_is_public || meta.topic.permissions.read.includes(meta.user.id); - const topic_events_are_publicly_writable = meta.topic.permissions.write_events.length === 0; - const user_has_write_events_for_topic = user_has_read_for_topic && - (topic_events_are_publicly_writable || meta.topic.permissions.write_events.includes(meta.user.id)); - - if (!user_has_write_events_for_topic) { - return CANNED_RESPONSES.permission_denied(); - } - } -]; -export async function DELETE(_req: Request, meta: Record): Promise { - const events: FSDB_COLLECTION = get_events_collection_for_topic(meta.topic.id); - const event: EVENT | null = await events.get(meta.params.event_id); - if (!event) { - return CANNED_RESPONSES.not_found(); - } - - await events.delete(event); - - return Response.json({ - deleted: true - }, { - status: 200 - }); -} diff --git a/public/api/topics/:topic_id/events/README.md b/public/api/topics/:topic_id/events/README.md deleted file mode 100644 index 13582fa..0000000 --- a/public/api/topics/:topic_id/events/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# /api/topics/:topic_id/events - -Interact with a events for a topic. - -## GET /api/topics/:topic_id/events - -Get events for the given topic. - -## PUT /api/topics/:topic_id/events/:event_id - -Update an event. - -## DELETE /api/topics/:topic_id/events/:event_id - -Delete an event. diff --git a/public/api/topics/README.md b/public/api/topics/README.md deleted file mode 100644 index 7783a6d..0000000 --- a/public/api/topics/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# /api/topics - -Interact with topics. - -## POST /api/topics - -Create a new topic. - -``` -export type TOPIC = { - id: string; // unique id for this topic - name: string; // the name of the topic (max 128 characters) - icon_url?: string; // optional url for a topic icon - topic?: string; // optional topic topic - tags: string[]; // a list of tags for the topic - meta: Record; - limits: { - users: number; - user_messages_per_minute: number; - }; - creator_id: string; // user_id of the topic creator - emojis: Record; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' } -}; -``` - -## GET /api/topics - -Get topics. diff --git a/public/api/users/:user_id/watches/:watch_id/index.ts b/public/api/users/:user_id/watches/:watch_id/index.ts index aa7e7df..5aab276 100644 --- a/public/api/users/:user_id/watches/:watch_id/index.ts +++ b/public/api/users/:user_id/watches/:watch_id/index.ts @@ -5,7 +5,7 @@ import parse_body from '../../../../../../utils/bodyparser.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; -// PUT /api/users/:user_id/watches/:watch_id - Update topic +// PUT /api/users/:user_id/watches/:watch_id - Update watch PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record): Promise => { const watch_id: string = meta.params?.watch_id?.toLowerCase().trim() ?? ''; @@ -69,7 +69,7 @@ PRECHECKS.DELETE = [ return CANNED_RESPONSES.not_found(); } - meta.topic = watch; + meta.watch = watch; const user_owns_watch = watch.creator_id === meta.user.id; if (!user_owns_watch) { diff --git a/public/api/users/:user_id/watches/index.ts b/public/api/users/:user_id/watches/index.ts index 0808a98..4950a33 100644 --- a/public/api/users/:user_id/watches/index.ts +++ b/public/api/users/:user_id/watches/index.ts @@ -4,7 +4,7 @@ import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts'; import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; import parse_body from '../../../../../utils/bodyparser.ts'; import lurid from '@andyburke/lurid'; -import { TOPICS } from '../../../../../models/topic.ts'; +import { CHANNELS } from '../../../../../models/channel.ts'; export const PRECHECKS: PRECHECK_TABLE = {}; @@ -99,34 +99,18 @@ export async function POST(req: Request, meta: Record): Promise): Promise + + +

Hello World - foo

+ + diff --git a/public/index.html b/public/index.html index 9b02c9a..2b5a262 100644 --- a/public/index.html +++ b/public/index.html @@ -3,54 +3,57 @@ - autonomous.contact + <!-- #include "./files/settings/title.txt" or "./title.txt" --> - - - + + + - + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + - +
+ +
- + - +
diff --git a/public/js/api.js b/public/js/api.js index a306e84..0ac9755 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -5,10 +5,13 @@ const api = { ...__options, }; + // FIXME: this will break with different server settings + // TODO: we need the cookie names here to match any configured on the server const session_id = (document.cookie.match( /^(?:.*;)?\s*session_id\s*=\s*([^;]+)(?:.*)?$/, ) || [, null])[1]; + // FIXME: this will break with different server settings // TODO: this wasn't really intended to be persisted in a cookie const session_secret = (document.cookie.match( /^(?:.*;)?\s*session_secret\s*=\s*([^;]+)(?:.*)?$/, diff --git a/public/js/app.js b/public/js/app.js index e2ec9d3..2643d1f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,7 +1,8 @@ -const HASH_EXTRACTOR = /^\#\/topic\/(?[A-Za-z\-]+)\/?(?\w+)?/gm; -const UPDATE_TOPICS_FREQUENCY = 60_000; +const HASH_EXTRACTOR = /^\#\/(?\w+)(?:\/channel\/(?[A-Za-z\-]+)\/?)?/gm; +const UPDATE_CHANNELS_FREQUENCY = 60_000; const APP = { + user: undefined, user_servers: [], user_watches: [], @@ -51,61 +52,39 @@ const APP = { extract_url_hash_info: async function () { HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls const { - groups: { topic_id, view }, + groups: { view, channel_id }, } = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? { groups: {}, }; - console.dir({ - url: window.location.href, - hash: window.location.hash, - topic_id, - view, - }); - - if (!document.body.dataset.topic || document.body.dataset.topic !== topic_id) { - const previous = document.body.dataset.topic; - - console.dir({ - topic_changed: { - detail: { - previous, - topic_id, - }, - }, - }); - - document.body.dataset.topic = topic_id; - - this._emit( 'topic_changed', { - previous, - topic_id - }); - - if (!topic_id) { - const first_topic_id = this.TOPICS.TOPIC_LIST[0]?.id; - if (first_topic_id) { - window.location.hash = `/topic/${first_topic_id}/chat`; // TODO: allow a different default than chat - } - } - } - if (!document.body.dataset.view || document.body.dataset.view !== view) { - const previous = document.body.dataset.view; - document.body.dataset.view = view; - - console.dir({ - view_changed: { - detail: { - previous, - view, - }, - }, - }); + const previous = typeof document.body.dataset.view === 'string' ? document.body.dataset.view : undefined; + if ( view ) { + document.body.dataset.view = view; + } + else { + delete document.body.dataset.view; + } this._emit( 'view_changed', { previous, - view + view, + channel_id + }); + } + + if (!document.body.dataset.channel || document.body.dataset.channel !== channel_id) { + const previous = typeof document.body.dataset.channel === 'string' ? document.body.dataset.channel : undefined; + if ( channel_id ) { + document.body.dataset.channel = channel_id; + } + else { + delete document.body.dataset.channel; + } + + this._emit( 'channel_changed', { + previous, + channel_id }); } }, @@ -142,19 +121,20 @@ const APP = { } window.addEventListener("locationchange", this.extract_url_hash_info.bind( this )); - window.addEventListener("locationchange", this.TOPICS.update ); + window.addEventListener("locationchange", this.CHANNELS.update ); this.check_if_logged_in(); this.extract_url_hash_info(); this._emit( 'load', this ); }, - update_user: async function( updated_user ) { - const user = this.user = updated_user; + update_user: async function( user ) { + this.user = user; + document.body.dataset.user = JSON.stringify(user); document.body.dataset.perms = user.permissions.join(":"); - this.TOPICS.update(); + this.CHANNELS.update(); this.user_servers = []; try { @@ -223,59 +203,54 @@ const APP = { }, }, - TOPICS: { - _last_topic_update: undefined, - _update_topics_timeout: undefined, - TOPIC_LIST: [], + CHANNELS: { + _last_channel_update: undefined, + _update_channels_timeout: undefined, + CHANNEL_LIST: [], - update: async () => { + update: async ( force = false ) => { const now = new Date(); - const time_since_last_update = now - (APP.TOPICS._last_topic_update ?? 0); - if (time_since_last_update < UPDATE_TOPICS_FREQUENCY / 2) { + const time_since_last_update = now - (APP.CHANNELS._last_channel_update ?? 0); + const sufficient_time_has_passed_since_last_update = time_since_last_update > UPDATE_CHANNELS_FREQUENCY / 2; + if ( !force && !sufficient_time_has_passed_since_last_update ) { return; } - if (APP.TOPICS._update_topics_timeout) { - clearTimeout(APP.TOPICS._update_topics_timeout); - APP.TOPICS._update_topics_timeout = undefined; + if (APP.CHANNELS._update_channels_timeout) { + clearTimeout(APP.CHANNELS._update_channels_timeout); + APP.CHANNELS._update_channels_timeout = undefined; } try { - const topics_response = await api.fetch("/api/topics"); - if (topics_response.ok) { - const new_topics = await topics_response.json(); - const has_differences = - APP.TOPICS.TOPIC_LIST.length !== new_topics.length || - new_topics.some((topic, index) => { - return ( - APP.TOPICS.TOPIC_LIST[index]?.id !== topic.id || - APP.TOPICS.TOPIC_LIST[index]?.name !== topic.name - ); - }); + const channels_response = await api.fetch("/api/channels"); + if (channels_response.ok) { + const new_channels = await channels_response.json(); + APP.CHANNELS.CHANNEL_LIST = [...new_channels]; - if (has_differences) { - APP.TOPICS.TOPIC_LIST = [...new_topics]; + APP._emit( 'channels_updated', { + channels: APP.CHANNELS.CHANNEL_LIST + }); - APP._emit( 'topics_updated', { - topics: APP.TOPICS.TOPIC_LIST - }); - } - - APP.TOPICS._last_topic_update = now; + APP.CHANNELS._last_channel_update = now; } } catch (error) { console.error(error); } - APP.TOPICS._update_topics_timeout = setTimeout( - APP.TOPICS.update, - UPDATE_TOPICS_FREQUENCY, + APP.CHANNELS._update_channels_timeout = setTimeout( + APP.CHANNELS.update, + UPDATE_CHANNELS_FREQUENCY, ); - // now that we have topics, make sure our url is all good + // now that we have channels, make sure our url is all good APP.extract_url_hash_info(); }, }, }; document.addEventListener("DOMContentLoaded", APP.load.bind( APP )); +APP.on( 'view_changed', ( { view } ) => { + if ( !view ) { + window.location.hash = '/chat'; + } +}); diff --git a/public/js/external/leaflet/images/layers-2x.png b/public/js/external/leaflet/images/layers-2x.png new file mode 100644 index 0000000..200c333 Binary files /dev/null and b/public/js/external/leaflet/images/layers-2x.png differ diff --git a/public/js/external/leaflet/images/layers.png b/public/js/external/leaflet/images/layers.png new file mode 100644 index 0000000..1a72e57 Binary files /dev/null and b/public/js/external/leaflet/images/layers.png differ diff --git a/public/js/external/leaflet/images/marker-icon-2x.png b/public/js/external/leaflet/images/marker-icon-2x.png new file mode 100644 index 0000000..88f9e50 Binary files /dev/null and b/public/js/external/leaflet/images/marker-icon-2x.png differ diff --git a/public/js/external/leaflet/images/marker-icon.png b/public/js/external/leaflet/images/marker-icon.png new file mode 100644 index 0000000..950edf2 Binary files /dev/null and b/public/js/external/leaflet/images/marker-icon.png differ diff --git a/public/js/external/leaflet/images/marker-shadow.png b/public/js/external/leaflet/images/marker-shadow.png new file mode 100644 index 0000000..9fd2979 Binary files /dev/null and b/public/js/external/leaflet/images/marker-shadow.png differ diff --git a/public/js/external/leaflet/leaflet-src.esm.js b/public/js/external/leaflet/leaflet-src.esm.js new file mode 100644 index 0000000..1b9a76e --- /dev/null +++ b/public/js/external/leaflet/leaflet-src.esm.js @@ -0,0 +1,14419 @@ +/* @preserve + * Leaflet 1.9.4+v1.d15112c, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ + +var version = "1.9.4"; + +/* + * @namespace Util + * + * Various utility functions, used by Leaflet internally. + */ + +// @function extend(dest: Object, src?: Object): Object +// Merges the properties of the `src` object (or multiple objects) into `dest` object and returns the latter. Has an `L.extend` shortcut. +function extend(dest) { + var i, j, len, src; + + for (j = 1, len = arguments.length; j < len; j++) { + src = arguments[j]; + for (i in src) { + dest[i] = src[i]; + } + } + return dest; +} + +// @function create(proto: Object, properties?: Object): Object +// Compatibility polyfill for [Object.create](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/create) +var create$2 = Object.create || (function () { + function F() {} + return function (proto) { + F.prototype = proto; + return new F(); + }; +})(); + +// @function bind(fn: Function, …): Function +// Returns a new function bound to the arguments passed, like [Function.prototype.bind](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/bind). +// Has a `L.bind()` shortcut. +function bind(fn, obj) { + var slice = Array.prototype.slice; + + if (fn.bind) { + return fn.bind.apply(fn, slice.call(arguments, 1)); + } + + var args = slice.call(arguments, 2); + + return function () { + return fn.apply(obj, args.length ? args.concat(slice.call(arguments)) : arguments); + }; +} + +// @property lastId: Number +// Last unique ID used by [`stamp()`](#util-stamp) +var lastId = 0; + +// @function stamp(obj: Object): Number +// Returns the unique ID of an object, assigning it one if it doesn't have it. +function stamp(obj) { + if (!('_leaflet_id' in obj)) { + obj['_leaflet_id'] = ++lastId; + } + return obj._leaflet_id; +} + +// @function throttle(fn: Function, time: Number, context: Object): Function +// Returns a function which executes function `fn` with the given scope `context` +// (so that the `this` keyword refers to `context` inside `fn`'s code). The function +// `fn` will be called no more than one time per given amount of `time`. The arguments +// received by the bound function will be any arguments passed when binding the +// function, followed by any arguments passed when invoking the bound function. +// Has an `L.throttle` shortcut. +function throttle(fn, time, context) { + var lock, args, wrapperFn, later; + + later = function () { + // reset lock and call if queued + lock = false; + if (args) { + wrapperFn.apply(context, args); + args = false; + } + }; + + wrapperFn = function () { + if (lock) { + // called too soon, queue to call later + args = arguments; + + } else { + // call and lock until later + fn.apply(context, arguments); + setTimeout(later, time); + lock = true; + } + }; + + return wrapperFn; +} + +// @function wrapNum(num: Number, range: Number[], includeMax?: Boolean): Number +// Returns the number `num` modulo `range` in such a way so it lies within +// `range[0]` and `range[1]`. The returned value will be always smaller than +// `range[1]` unless `includeMax` is set to `true`. +function wrapNum(x, range, includeMax) { + var max = range[1], + min = range[0], + d = max - min; + return x === max && includeMax ? x : ((x - min) % d + d) % d + min; +} + +// @function falseFn(): Function +// Returns a function which always returns `false`. +function falseFn() { return false; } + +// @function formatNum(num: Number, precision?: Number|false): Number +// Returns the number `num` rounded with specified `precision`. +// The default `precision` value is 6 decimal places. +// `false` can be passed to skip any processing (can be useful to avoid round-off errors). +function formatNum(num, precision) { + if (precision === false) { return num; } + var pow = Math.pow(10, precision === undefined ? 6 : precision); + return Math.round(num * pow) / pow; +} + +// @function trim(str: String): String +// Compatibility polyfill for [String.prototype.trim](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/Trim) +function trim(str) { + return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, ''); +} + +// @function splitWords(str: String): String[] +// Trims and splits the string on whitespace and returns the array of parts. +function splitWords(str) { + return trim(str).split(/\s+/); +} + +// @function setOptions(obj: Object, options: Object): Object +// Merges the given properties to the `options` of the `obj` object, returning the resulting options. See `Class options`. Has an `L.setOptions` shortcut. +function setOptions(obj, options) { + if (!Object.prototype.hasOwnProperty.call(obj, 'options')) { + obj.options = obj.options ? create$2(obj.options) : {}; + } + for (var i in options) { + obj.options[i] = options[i]; + } + return obj.options; +} + +// @function getParamString(obj: Object, existingUrl?: String, uppercase?: Boolean): String +// Converts an object into a parameter URL string, e.g. `{a: "foo", b: "bar"}` +// translates to `'?a=foo&b=bar'`. If `existingUrl` is set, the parameters will +// be appended at the end. If `uppercase` is `true`, the parameter names will +// be uppercased (e.g. `'?A=foo&B=bar'`) +function getParamString(obj, existingUrl, uppercase) { + var params = []; + for (var i in obj) { + params.push(encodeURIComponent(uppercase ? i.toUpperCase() : i) + '=' + encodeURIComponent(obj[i])); + } + return ((!existingUrl || existingUrl.indexOf('?') === -1) ? '?' : '&') + params.join('&'); +} + +var templateRe = /\{ *([\w_ -]+) *\}/g; + +// @function template(str: String, data: Object): String +// Simple templating facility, accepts a template string of the form `'Hello {a}, {b}'` +// and a data object like `{a: 'foo', b: 'bar'}`, returns evaluated string +// `('Hello foo, bar')`. You can also specify functions instead of strings for +// data values — they will be evaluated passing `data` as an argument. +function template(str, data) { + return str.replace(templateRe, function (str, key) { + var value = data[key]; + + if (value === undefined) { + throw new Error('No value provided for variable ' + str); + + } else if (typeof value === 'function') { + value = value(data); + } + return value; + }); +} + +// @function isArray(obj): Boolean +// Compatibility polyfill for [Array.isArray](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray) +var isArray = Array.isArray || function (obj) { + return (Object.prototype.toString.call(obj) === '[object Array]'); +}; + +// @function indexOf(array: Array, el: Object): Number +// Compatibility polyfill for [Array.prototype.indexOf](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf) +function indexOf(array, el) { + for (var i = 0; i < array.length; i++) { + if (array[i] === el) { return i; } + } + return -1; +} + +// @property emptyImageUrl: String +// Data URI string containing a base64-encoded empty GIF image. +// Used as a hack to free memory from unused images on WebKit-powered +// mobile devices (by setting image `src` to this string). +var emptyImageUrl = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; + +// inspired by https://paulirish.com/2011/requestanimationframe-for-smart-animating/ + +function getPrefixed(name) { + return window['webkit' + name] || window['moz' + name] || window['ms' + name]; +} + +var lastTime = 0; + +// fallback for IE 7-8 +function timeoutDefer(fn) { + var time = +new Date(), + timeToCall = Math.max(0, 16 - (time - lastTime)); + + lastTime = time + timeToCall; + return window.setTimeout(fn, timeToCall); +} + +var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer; +var cancelFn = window.cancelAnimationFrame || getPrefixed('CancelAnimationFrame') || + getPrefixed('CancelRequestAnimationFrame') || function (id) { window.clearTimeout(id); }; + +// @function requestAnimFrame(fn: Function, context?: Object, immediate?: Boolean): Number +// Schedules `fn` to be executed when the browser repaints. `fn` is bound to +// `context` if given. When `immediate` is set, `fn` is called immediately if +// the browser doesn't have native support for +// [`window.requestAnimationFrame`](https://developer.mozilla.org/docs/Web/API/window/requestAnimationFrame), +// otherwise it's delayed. Returns a request ID that can be used to cancel the request. +function requestAnimFrame(fn, context, immediate) { + if (immediate && requestFn === timeoutDefer) { + fn.call(context); + } else { + return requestFn.call(window, bind(fn, context)); + } +} + +// @function cancelAnimFrame(id: Number): undefined +// Cancels a previous `requestAnimFrame`. See also [window.cancelAnimationFrame](https://developer.mozilla.org/docs/Web/API/window/cancelAnimationFrame). +function cancelAnimFrame(id) { + if (id) { + cancelFn.call(window, id); + } +} + +var Util = { + __proto__: null, + extend: extend, + create: create$2, + bind: bind, + get lastId () { return lastId; }, + stamp: stamp, + throttle: throttle, + wrapNum: wrapNum, + falseFn: falseFn, + formatNum: formatNum, + trim: trim, + splitWords: splitWords, + setOptions: setOptions, + getParamString: getParamString, + template: template, + isArray: isArray, + indexOf: indexOf, + emptyImageUrl: emptyImageUrl, + requestFn: requestFn, + cancelFn: cancelFn, + requestAnimFrame: requestAnimFrame, + cancelAnimFrame: cancelAnimFrame +}; + +// @class Class +// @aka L.Class + +// @section +// @uninheritable + +// Thanks to John Resig and Dean Edwards for inspiration! + +function Class() {} + +Class.extend = function (props) { + + // @function extend(props: Object): Function + // [Extends the current class](#class-inheritance) given the properties to be included. + // Returns a Javascript function that is a class constructor (to be called with `new`). + var NewClass = function () { + + setOptions(this); + + // call the constructor + if (this.initialize) { + this.initialize.apply(this, arguments); + } + + // call all constructor hooks + this.callInitHooks(); + }; + + var parentProto = NewClass.__super__ = this.prototype; + + var proto = create$2(parentProto); + proto.constructor = NewClass; + + NewClass.prototype = proto; + + // inherit parent's statics + for (var i in this) { + if (Object.prototype.hasOwnProperty.call(this, i) && i !== 'prototype' && i !== '__super__') { + NewClass[i] = this[i]; + } + } + + // mix static properties into the class + if (props.statics) { + extend(NewClass, props.statics); + } + + // mix includes into the prototype + if (props.includes) { + checkDeprecatedMixinEvents(props.includes); + extend.apply(null, [proto].concat(props.includes)); + } + + // mix given properties into the prototype + extend(proto, props); + delete proto.statics; + delete proto.includes; + + // merge options + if (proto.options) { + proto.options = parentProto.options ? create$2(parentProto.options) : {}; + extend(proto.options, props.options); + } + + proto._initHooks = []; + + // add method for calling all hooks + proto.callInitHooks = function () { + + if (this._initHooksCalled) { return; } + + if (parentProto.callInitHooks) { + parentProto.callInitHooks.call(this); + } + + this._initHooksCalled = true; + + for (var i = 0, len = proto._initHooks.length; i < len; i++) { + proto._initHooks[i].call(this); + } + }; + + return NewClass; +}; + + +// @function include(properties: Object): this +// [Includes a mixin](#class-includes) into the current class. +Class.include = function (props) { + var parentOptions = this.prototype.options; + extend(this.prototype, props); + if (props.options) { + this.prototype.options = parentOptions; + this.mergeOptions(props.options); + } + return this; +}; + +// @function mergeOptions(options: Object): this +// [Merges `options`](#class-options) into the defaults of the class. +Class.mergeOptions = function (options) { + extend(this.prototype.options, options); + return this; +}; + +// @function addInitHook(fn: Function): this +// Adds a [constructor hook](#class-constructor-hooks) to the class. +Class.addInitHook = function (fn) { // (Function) || (String, args...) + var args = Array.prototype.slice.call(arguments, 1); + + var init = typeof fn === 'function' ? fn : function () { + this[fn].apply(this, args); + }; + + this.prototype._initHooks = this.prototype._initHooks || []; + this.prototype._initHooks.push(init); + return this; +}; + +function checkDeprecatedMixinEvents(includes) { + /* global L: true */ + if (typeof L === 'undefined' || !L || !L.Mixin) { return; } + + includes = isArray(includes) ? includes : [includes]; + + for (var i = 0; i < includes.length; i++) { + if (includes[i] === L.Mixin.Events) { + console.warn('Deprecated include of L.Mixin.Events: ' + + 'this property will be removed in future releases, ' + + 'please inherit from L.Evented instead.', new Error().stack); + } + } +} + +/* + * @class Evented + * @aka L.Evented + * @inherits Class + * + * A set of methods shared between event-powered classes (like `Map` and `Marker`). Generally, events allow you to execute some function when something happens with an object (e.g. the user clicks on the map, causing the map to fire `'click'` event). + * + * @example + * + * ```js + * map.on('click', function(e) { + * alert(e.latlng); + * } ); + * ``` + * + * Leaflet deals with event listeners by reference, so if you want to add a listener and then remove it, define it as a function: + * + * ```js + * function onClick(e) { ... } + * + * map.on('click', onClick); + * map.off('click', onClick); + * ``` + */ + +var Events = { + /* @method on(type: String, fn: Function, context?: Object): this + * Adds a listener function (`fn`) to a particular event type of the object. You can optionally specify the context of the listener (object the this keyword will point to). You can also pass several space-separated types (e.g. `'click dblclick'`). + * + * @alternative + * @method on(eventMap: Object): this + * Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` + */ + on: function (types, fn, context) { + + // types can be a map of types/handlers + if (typeof types === 'object') { + for (var type in types) { + // we don't process space-separated events here for performance; + // it's a hot path since Layer uses the on(obj) syntax + this._on(type, types[type], fn); + } + + } else { + // types can be a string of space-separated words + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + this._on(types[i], fn, context); + } + } + + return this; + }, + + /* @method off(type: String, fn?: Function, context?: Object): this + * Removes a previously added listener function. If no function is specified, it will remove all the listeners of that particular event from the object. Note that if you passed a custom context to `on`, you must pass the same context to `off` in order to remove the listener. + * + * @alternative + * @method off(eventMap: Object): this + * Removes a set of type/listener pairs. + * + * @alternative + * @method off: this + * Removes all listeners to all events on the object. This includes implicitly attached events. + */ + off: function (types, fn, context) { + + if (!arguments.length) { + // clear all listeners if called without arguments + delete this._events; + + } else if (typeof types === 'object') { + for (var type in types) { + this._off(type, types[type], fn); + } + + } else { + types = splitWords(types); + + var removeAll = arguments.length === 1; + for (var i = 0, len = types.length; i < len; i++) { + if (removeAll) { + this._off(types[i]); + } else { + this._off(types[i], fn, context); + } + } + } + + return this; + }, + + // attach listener (without syntactic sugar now) + _on: function (type, fn, context, _once) { + if (typeof fn !== 'function') { + console.warn('wrong listener type: ' + typeof fn); + return; + } + + // check if fn already there + if (this._listens(type, fn, context) !== false) { + return; + } + + if (context === this) { + // Less memory footprint. + context = undefined; + } + + var newListener = {fn: fn, ctx: context}; + if (_once) { + newListener.once = true; + } + + this._events = this._events || {}; + this._events[type] = this._events[type] || []; + this._events[type].push(newListener); + }, + + _off: function (type, fn, context) { + var listeners, + i, + len; + + if (!this._events) { + return; + } + + listeners = this._events[type]; + if (!listeners) { + return; + } + + if (arguments.length === 1) { // remove all + if (this._firingCount) { + // Set all removed listeners to noop + // so they are not called if remove happens in fire + for (i = 0, len = listeners.length; i < len; i++) { + listeners[i].fn = falseFn; + } + } + // clear all listeners for a type if function isn't specified + delete this._events[type]; + return; + } + + if (typeof fn !== 'function') { + console.warn('wrong listener type: ' + typeof fn); + return; + } + + // find fn and remove it + var index = this._listens(type, fn, context); + if (index !== false) { + var listener = listeners[index]; + if (this._firingCount) { + // set the removed listener to noop so that's not called if remove happens in fire + listener.fn = falseFn; + + /* copy array in case events are being fired */ + this._events[type] = listeners = listeners.slice(); + } + listeners.splice(index, 1); + } + }, + + // @method fire(type: String, data?: Object, propagate?: Boolean): this + // Fires an event of the specified type. You can optionally provide a data + // object — the first argument of the listener function will contain its + // properties. The event can optionally be propagated to event parents. + fire: function (type, data, propagate) { + if (!this.listens(type, propagate)) { return this; } + + var event = extend({}, data, { + type: type, + target: this, + sourceTarget: data && data.sourceTarget || this + }); + + if (this._events) { + var listeners = this._events[type]; + if (listeners) { + this._firingCount = (this._firingCount + 1) || 1; + for (var i = 0, len = listeners.length; i < len; i++) { + var l = listeners[i]; + // off overwrites l.fn, so we need to copy fn to a var + var fn = l.fn; + if (l.once) { + this.off(type, fn, l.ctx); + } + fn.call(l.ctx || this, event); + } + + this._firingCount--; + } + } + + if (propagate) { + // propagate the event to parents (set with addEventParent) + this._propagateEvent(event); + } + + return this; + }, + + // @method listens(type: String, propagate?: Boolean): Boolean + // @method listens(type: String, fn: Function, context?: Object, propagate?: Boolean): Boolean + // Returns `true` if a particular event type has any listeners attached to it. + // The verification can optionally be propagated, it will return `true` if parents have the listener attached to it. + listens: function (type, fn, context, propagate) { + if (typeof type !== 'string') { + console.warn('"string" type argument expected'); + } + + // we don't overwrite the input `fn` value, because we need to use it for propagation + var _fn = fn; + if (typeof fn !== 'function') { + propagate = !!fn; + _fn = undefined; + context = undefined; + } + + var listeners = this._events && this._events[type]; + if (listeners && listeners.length) { + if (this._listens(type, _fn, context) !== false) { + return true; + } + } + + if (propagate) { + // also check parents for listeners if event propagates + for (var id in this._eventParents) { + if (this._eventParents[id].listens(type, fn, context, propagate)) { return true; } + } + } + return false; + }, + + // returns the index (number) or false + _listens: function (type, fn, context) { + if (!this._events) { + return false; + } + + var listeners = this._events[type] || []; + if (!fn) { + return !!listeners.length; + } + + if (context === this) { + // Less memory footprint. + context = undefined; + } + + for (var i = 0, len = listeners.length; i < len; i++) { + if (listeners[i].fn === fn && listeners[i].ctx === context) { + return i; + } + } + return false; + + }, + + // @method once(…): this + // Behaves as [`on(…)`](#evented-on), except the listener will only get fired once and then removed. + once: function (types, fn, context) { + + // types can be a map of types/handlers + if (typeof types === 'object') { + for (var type in types) { + // we don't process space-separated events here for performance; + // it's a hot path since Layer uses the on(obj) syntax + this._on(type, types[type], fn, true); + } + + } else { + // types can be a string of space-separated words + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + this._on(types[i], fn, context, true); + } + } + + return this; + }, + + // @method addEventParent(obj: Evented): this + // Adds an event parent - an `Evented` that will receive propagated events + addEventParent: function (obj) { + this._eventParents = this._eventParents || {}; + this._eventParents[stamp(obj)] = obj; + return this; + }, + + // @method removeEventParent(obj: Evented): this + // Removes an event parent, so it will stop receiving propagated events + removeEventParent: function (obj) { + if (this._eventParents) { + delete this._eventParents[stamp(obj)]; + } + return this; + }, + + _propagateEvent: function (e) { + for (var id in this._eventParents) { + this._eventParents[id].fire(e.type, extend({ + layer: e.target, + propagatedFrom: e.target + }, e), true); + } + } +}; + +// aliases; we should ditch those eventually + +// @method addEventListener(…): this +// Alias to [`on(…)`](#evented-on) +Events.addEventListener = Events.on; + +// @method removeEventListener(…): this +// Alias to [`off(…)`](#evented-off) + +// @method clearAllEventListeners(…): this +// Alias to [`off()`](#evented-off) +Events.removeEventListener = Events.clearAllEventListeners = Events.off; + +// @method addOneTimeEventListener(…): this +// Alias to [`once(…)`](#evented-once) +Events.addOneTimeEventListener = Events.once; + +// @method fireEvent(…): this +// Alias to [`fire(…)`](#evented-fire) +Events.fireEvent = Events.fire; + +// @method hasEventListeners(…): Boolean +// Alias to [`listens(…)`](#evented-listens) +Events.hasEventListeners = Events.listens; + +var Evented = Class.extend(Events); + +/* + * @class Point + * @aka L.Point + * + * Represents a point with `x` and `y` coordinates in pixels. + * + * @example + * + * ```js + * var point = L.point(200, 300); + * ``` + * + * All Leaflet methods and options that accept `Point` objects also accept them in a simple Array form (unless noted otherwise), so these lines are equivalent: + * + * ```js + * map.panBy([200, 300]); + * map.panBy(L.point(200, 300)); + * ``` + * + * Note that `Point` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function Point(x, y, round) { + // @property x: Number; The `x` coordinate of the point + this.x = (round ? Math.round(x) : x); + // @property y: Number; The `y` coordinate of the point + this.y = (round ? Math.round(y) : y); +} + +var trunc = Math.trunc || function (v) { + return v > 0 ? Math.floor(v) : Math.ceil(v); +}; + +Point.prototype = { + + // @method clone(): Point + // Returns a copy of the current point. + clone: function () { + return new Point(this.x, this.y); + }, + + // @method add(otherPoint: Point): Point + // Returns the result of addition of the current and the given points. + add: function (point) { + // non-destructive, returns a new point + return this.clone()._add(toPoint(point)); + }, + + _add: function (point) { + // destructive, used directly for performance in situations where it's safe to modify existing point + this.x += point.x; + this.y += point.y; + return this; + }, + + // @method subtract(otherPoint: Point): Point + // Returns the result of subtraction of the given point from the current. + subtract: function (point) { + return this.clone()._subtract(toPoint(point)); + }, + + _subtract: function (point) { + this.x -= point.x; + this.y -= point.y; + return this; + }, + + // @method divideBy(num: Number): Point + // Returns the result of division of the current point by the given number. + divideBy: function (num) { + return this.clone()._divideBy(num); + }, + + _divideBy: function (num) { + this.x /= num; + this.y /= num; + return this; + }, + + // @method multiplyBy(num: Number): Point + // Returns the result of multiplication of the current point by the given number. + multiplyBy: function (num) { + return this.clone()._multiplyBy(num); + }, + + _multiplyBy: function (num) { + this.x *= num; + this.y *= num; + return this; + }, + + // @method scaleBy(scale: Point): Point + // Multiply each coordinate of the current point by each coordinate of + // `scale`. In linear algebra terms, multiply the point by the + // [scaling matrix](https://en.wikipedia.org/wiki/Scaling_%28geometry%29#Matrix_representation) + // defined by `scale`. + scaleBy: function (point) { + return new Point(this.x * point.x, this.y * point.y); + }, + + // @method unscaleBy(scale: Point): Point + // Inverse of `scaleBy`. Divide each coordinate of the current point by + // each coordinate of `scale`. + unscaleBy: function (point) { + return new Point(this.x / point.x, this.y / point.y); + }, + + // @method round(): Point + // Returns a copy of the current point with rounded coordinates. + round: function () { + return this.clone()._round(); + }, + + _round: function () { + this.x = Math.round(this.x); + this.y = Math.round(this.y); + return this; + }, + + // @method floor(): Point + // Returns a copy of the current point with floored coordinates (rounded down). + floor: function () { + return this.clone()._floor(); + }, + + _floor: function () { + this.x = Math.floor(this.x); + this.y = Math.floor(this.y); + return this; + }, + + // @method ceil(): Point + // Returns a copy of the current point with ceiled coordinates (rounded up). + ceil: function () { + return this.clone()._ceil(); + }, + + _ceil: function () { + this.x = Math.ceil(this.x); + this.y = Math.ceil(this.y); + return this; + }, + + // @method trunc(): Point + // Returns a copy of the current point with truncated coordinates (rounded towards zero). + trunc: function () { + return this.clone()._trunc(); + }, + + _trunc: function () { + this.x = trunc(this.x); + this.y = trunc(this.y); + return this; + }, + + // @method distanceTo(otherPoint: Point): Number + // Returns the cartesian distance between the current and the given points. + distanceTo: function (point) { + point = toPoint(point); + + var x = point.x - this.x, + y = point.y - this.y; + + return Math.sqrt(x * x + y * y); + }, + + // @method equals(otherPoint: Point): Boolean + // Returns `true` if the given point has the same coordinates. + equals: function (point) { + point = toPoint(point); + + return point.x === this.x && + point.y === this.y; + }, + + // @method contains(otherPoint: Point): Boolean + // Returns `true` if both coordinates of the given point are less than the corresponding current point coordinates (in absolute values). + contains: function (point) { + point = toPoint(point); + + return Math.abs(point.x) <= Math.abs(this.x) && + Math.abs(point.y) <= Math.abs(this.y); + }, + + // @method toString(): String + // Returns a string representation of the point for debugging purposes. + toString: function () { + return 'Point(' + + formatNum(this.x) + ', ' + + formatNum(this.y) + ')'; + } +}; + +// @factory L.point(x: Number, y: Number, round?: Boolean) +// Creates a Point object with the given `x` and `y` coordinates. If optional `round` is set to true, rounds the `x` and `y` values. + +// @alternative +// @factory L.point(coords: Number[]) +// Expects an array of the form `[x, y]` instead. + +// @alternative +// @factory L.point(coords: Object) +// Expects a plain object of the form `{x: Number, y: Number}` instead. +function toPoint(x, y, round) { + if (x instanceof Point) { + return x; + } + if (isArray(x)) { + return new Point(x[0], x[1]); + } + if (x === undefined || x === null) { + return x; + } + if (typeof x === 'object' && 'x' in x && 'y' in x) { + return new Point(x.x, x.y); + } + return new Point(x, y, round); +} + +/* + * @class Bounds + * @aka L.Bounds + * + * Represents a rectangular area in pixel coordinates. + * + * @example + * + * ```js + * var p1 = L.point(10, 10), + * p2 = L.point(40, 60), + * bounds = L.bounds(p1, p2); + * ``` + * + * All Leaflet methods that accept `Bounds` objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: + * + * ```js + * otherBounds.intersects([[10, 10], [40, 60]]); + * ``` + * + * Note that `Bounds` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function Bounds(a, b) { + if (!a) { return; } + + var points = b ? [a, b] : a; + + for (var i = 0, len = points.length; i < len; i++) { + this.extend(points[i]); + } +} + +Bounds.prototype = { + // @method extend(point: Point): this + // Extends the bounds to contain the given point. + + // @alternative + // @method extend(otherBounds: Bounds): this + // Extend the bounds to contain the given bounds + extend: function (obj) { + var min2, max2; + if (!obj) { return this; } + + if (obj instanceof Point || typeof obj[0] === 'number' || 'x' in obj) { + min2 = max2 = toPoint(obj); + } else { + obj = toBounds(obj); + min2 = obj.min; + max2 = obj.max; + + if (!min2 || !max2) { return this; } + } + + // @property min: Point + // The top left corner of the rectangle. + // @property max: Point + // The bottom right corner of the rectangle. + if (!this.min && !this.max) { + this.min = min2.clone(); + this.max = max2.clone(); + } else { + this.min.x = Math.min(min2.x, this.min.x); + this.max.x = Math.max(max2.x, this.max.x); + this.min.y = Math.min(min2.y, this.min.y); + this.max.y = Math.max(max2.y, this.max.y); + } + return this; + }, + + // @method getCenter(round?: Boolean): Point + // Returns the center point of the bounds. + getCenter: function (round) { + return toPoint( + (this.min.x + this.max.x) / 2, + (this.min.y + this.max.y) / 2, round); + }, + + // @method getBottomLeft(): Point + // Returns the bottom-left point of the bounds. + getBottomLeft: function () { + return toPoint(this.min.x, this.max.y); + }, + + // @method getTopRight(): Point + // Returns the top-right point of the bounds. + getTopRight: function () { // -> Point + return toPoint(this.max.x, this.min.y); + }, + + // @method getTopLeft(): Point + // Returns the top-left point of the bounds (i.e. [`this.min`](#bounds-min)). + getTopLeft: function () { + return this.min; // left, top + }, + + // @method getBottomRight(): Point + // Returns the bottom-right point of the bounds (i.e. [`this.max`](#bounds-max)). + getBottomRight: function () { + return this.max; // right, bottom + }, + + // @method getSize(): Point + // Returns the size of the given bounds + getSize: function () { + return this.max.subtract(this.min); + }, + + // @method contains(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle contains the given one. + // @alternative + // @method contains(point: Point): Boolean + // Returns `true` if the rectangle contains the given point. + contains: function (obj) { + var min, max; + + if (typeof obj[0] === 'number' || obj instanceof Point) { + obj = toPoint(obj); + } else { + obj = toBounds(obj); + } + + if (obj instanceof Bounds) { + min = obj.min; + max = obj.max; + } else { + min = max = obj; + } + + return (min.x >= this.min.x) && + (max.x <= this.max.x) && + (min.y >= this.min.y) && + (max.y <= this.max.y); + }, + + // @method intersects(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle intersects the given bounds. Two bounds + // intersect if they have at least one point in common. + intersects: function (bounds) { // (Bounds) -> Boolean + bounds = toBounds(bounds); + + var min = this.min, + max = this.max, + min2 = bounds.min, + max2 = bounds.max, + xIntersects = (max2.x >= min.x) && (min2.x <= max.x), + yIntersects = (max2.y >= min.y) && (min2.y <= max.y); + + return xIntersects && yIntersects; + }, + + // @method overlaps(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle overlaps the given bounds. Two bounds + // overlap if their intersection is an area. + overlaps: function (bounds) { // (Bounds) -> Boolean + bounds = toBounds(bounds); + + var min = this.min, + max = this.max, + min2 = bounds.min, + max2 = bounds.max, + xOverlaps = (max2.x > min.x) && (min2.x < max.x), + yOverlaps = (max2.y > min.y) && (min2.y < max.y); + + return xOverlaps && yOverlaps; + }, + + // @method isValid(): Boolean + // Returns `true` if the bounds are properly initialized. + isValid: function () { + return !!(this.min && this.max); + }, + + + // @method pad(bufferRatio: Number): Bounds + // Returns bounds created by extending or retracting the current bounds by a given ratio in each direction. + // For example, a ratio of 0.5 extends the bounds by 50% in each direction. + // Negative values will retract the bounds. + pad: function (bufferRatio) { + var min = this.min, + max = this.max, + heightBuffer = Math.abs(min.x - max.x) * bufferRatio, + widthBuffer = Math.abs(min.y - max.y) * bufferRatio; + + + return toBounds( + toPoint(min.x - heightBuffer, min.y - widthBuffer), + toPoint(max.x + heightBuffer, max.y + widthBuffer)); + }, + + + // @method equals(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle is equivalent to the given bounds. + equals: function (bounds) { + if (!bounds) { return false; } + + bounds = toBounds(bounds); + + return this.min.equals(bounds.getTopLeft()) && + this.max.equals(bounds.getBottomRight()); + }, +}; + + +// @factory L.bounds(corner1: Point, corner2: Point) +// Creates a Bounds object from two corners coordinate pairs. +// @alternative +// @factory L.bounds(points: Point[]) +// Creates a Bounds object from the given array of points. +function toBounds(a, b) { + if (!a || a instanceof Bounds) { + return a; + } + return new Bounds(a, b); +} + +/* + * @class LatLngBounds + * @aka L.LatLngBounds + * + * Represents a rectangular geographical area on a map. + * + * @example + * + * ```js + * var corner1 = L.latLng(40.712, -74.227), + * corner2 = L.latLng(40.774, -74.125), + * bounds = L.latLngBounds(corner1, corner2); + * ``` + * + * All Leaflet methods that accept LatLngBounds objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: + * + * ```js + * map.fitBounds([ + * [40.712, -74.227], + * [40.774, -74.125] + * ]); + * ``` + * + * Caution: if the area crosses the antimeridian (often confused with the International Date Line), you must specify corners _outside_ the [-180, 180] degrees longitude range. + * + * Note that `LatLngBounds` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function LatLngBounds(corner1, corner2) { // (LatLng, LatLng) or (LatLng[]) + if (!corner1) { return; } + + var latlngs = corner2 ? [corner1, corner2] : corner1; + + for (var i = 0, len = latlngs.length; i < len; i++) { + this.extend(latlngs[i]); + } +} + +LatLngBounds.prototype = { + + // @method extend(latlng: LatLng): this + // Extend the bounds to contain the given point + + // @alternative + // @method extend(otherBounds: LatLngBounds): this + // Extend the bounds to contain the given bounds + extend: function (obj) { + var sw = this._southWest, + ne = this._northEast, + sw2, ne2; + + if (obj instanceof LatLng) { + sw2 = obj; + ne2 = obj; + + } else if (obj instanceof LatLngBounds) { + sw2 = obj._southWest; + ne2 = obj._northEast; + + if (!sw2 || !ne2) { return this; } + + } else { + return obj ? this.extend(toLatLng(obj) || toLatLngBounds(obj)) : this; + } + + if (!sw && !ne) { + this._southWest = new LatLng(sw2.lat, sw2.lng); + this._northEast = new LatLng(ne2.lat, ne2.lng); + } else { + sw.lat = Math.min(sw2.lat, sw.lat); + sw.lng = Math.min(sw2.lng, sw.lng); + ne.lat = Math.max(ne2.lat, ne.lat); + ne.lng = Math.max(ne2.lng, ne.lng); + } + + return this; + }, + + // @method pad(bufferRatio: Number): LatLngBounds + // Returns bounds created by extending or retracting the current bounds by a given ratio in each direction. + // For example, a ratio of 0.5 extends the bounds by 50% in each direction. + // Negative values will retract the bounds. + pad: function (bufferRatio) { + var sw = this._southWest, + ne = this._northEast, + heightBuffer = Math.abs(sw.lat - ne.lat) * bufferRatio, + widthBuffer = Math.abs(sw.lng - ne.lng) * bufferRatio; + + return new LatLngBounds( + new LatLng(sw.lat - heightBuffer, sw.lng - widthBuffer), + new LatLng(ne.lat + heightBuffer, ne.lng + widthBuffer)); + }, + + // @method getCenter(): LatLng + // Returns the center point of the bounds. + getCenter: function () { + return new LatLng( + (this._southWest.lat + this._northEast.lat) / 2, + (this._southWest.lng + this._northEast.lng) / 2); + }, + + // @method getSouthWest(): LatLng + // Returns the south-west point of the bounds. + getSouthWest: function () { + return this._southWest; + }, + + // @method getNorthEast(): LatLng + // Returns the north-east point of the bounds. + getNorthEast: function () { + return this._northEast; + }, + + // @method getNorthWest(): LatLng + // Returns the north-west point of the bounds. + getNorthWest: function () { + return new LatLng(this.getNorth(), this.getWest()); + }, + + // @method getSouthEast(): LatLng + // Returns the south-east point of the bounds. + getSouthEast: function () { + return new LatLng(this.getSouth(), this.getEast()); + }, + + // @method getWest(): Number + // Returns the west longitude of the bounds + getWest: function () { + return this._southWest.lng; + }, + + // @method getSouth(): Number + // Returns the south latitude of the bounds + getSouth: function () { + return this._southWest.lat; + }, + + // @method getEast(): Number + // Returns the east longitude of the bounds + getEast: function () { + return this._northEast.lng; + }, + + // @method getNorth(): Number + // Returns the north latitude of the bounds + getNorth: function () { + return this._northEast.lat; + }, + + // @method contains(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle contains the given one. + + // @alternative + // @method contains (latlng: LatLng): Boolean + // Returns `true` if the rectangle contains the given point. + contains: function (obj) { // (LatLngBounds) or (LatLng) -> Boolean + if (typeof obj[0] === 'number' || obj instanceof LatLng || 'lat' in obj) { + obj = toLatLng(obj); + } else { + obj = toLatLngBounds(obj); + } + + var sw = this._southWest, + ne = this._northEast, + sw2, ne2; + + if (obj instanceof LatLngBounds) { + sw2 = obj.getSouthWest(); + ne2 = obj.getNorthEast(); + } else { + sw2 = ne2 = obj; + } + + return (sw2.lat >= sw.lat) && (ne2.lat <= ne.lat) && + (sw2.lng >= sw.lng) && (ne2.lng <= ne.lng); + }, + + // @method intersects(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle intersects the given bounds. Two bounds intersect if they have at least one point in common. + intersects: function (bounds) { + bounds = toLatLngBounds(bounds); + + var sw = this._southWest, + ne = this._northEast, + sw2 = bounds.getSouthWest(), + ne2 = bounds.getNorthEast(), + + latIntersects = (ne2.lat >= sw.lat) && (sw2.lat <= ne.lat), + lngIntersects = (ne2.lng >= sw.lng) && (sw2.lng <= ne.lng); + + return latIntersects && lngIntersects; + }, + + // @method overlaps(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle overlaps the given bounds. Two bounds overlap if their intersection is an area. + overlaps: function (bounds) { + bounds = toLatLngBounds(bounds); + + var sw = this._southWest, + ne = this._northEast, + sw2 = bounds.getSouthWest(), + ne2 = bounds.getNorthEast(), + + latOverlaps = (ne2.lat > sw.lat) && (sw2.lat < ne.lat), + lngOverlaps = (ne2.lng > sw.lng) && (sw2.lng < ne.lng); + + return latOverlaps && lngOverlaps; + }, + + // @method toBBoxString(): String + // Returns a string with bounding box coordinates in a 'southwest_lng,southwest_lat,northeast_lng,northeast_lat' format. Useful for sending requests to web services that return geo data. + toBBoxString: function () { + return [this.getWest(), this.getSouth(), this.getEast(), this.getNorth()].join(','); + }, + + // @method equals(otherBounds: LatLngBounds, maxMargin?: Number): Boolean + // Returns `true` if the rectangle is equivalent (within a small margin of error) to the given bounds. The margin of error can be overridden by setting `maxMargin` to a small number. + equals: function (bounds, maxMargin) { + if (!bounds) { return false; } + + bounds = toLatLngBounds(bounds); + + return this._southWest.equals(bounds.getSouthWest(), maxMargin) && + this._northEast.equals(bounds.getNorthEast(), maxMargin); + }, + + // @method isValid(): Boolean + // Returns `true` if the bounds are properly initialized. + isValid: function () { + return !!(this._southWest && this._northEast); + } +}; + +// TODO International date line? + +// @factory L.latLngBounds(corner1: LatLng, corner2: LatLng) +// Creates a `LatLngBounds` object by defining two diagonally opposite corners of the rectangle. + +// @alternative +// @factory L.latLngBounds(latlngs: LatLng[]) +// Creates a `LatLngBounds` object defined by the geographical points it contains. Very useful for zooming the map to fit a particular set of locations with [`fitBounds`](#map-fitbounds). +function toLatLngBounds(a, b) { + if (a instanceof LatLngBounds) { + return a; + } + return new LatLngBounds(a, b); +} + +/* @class LatLng + * @aka L.LatLng + * + * Represents a geographical point with a certain latitude and longitude. + * + * @example + * + * ``` + * var latlng = L.latLng(50.5, 30.5); + * ``` + * + * All Leaflet methods that accept LatLng objects also accept them in a simple Array form and simple object form (unless noted otherwise), so these lines are equivalent: + * + * ``` + * map.panTo([50, 30]); + * map.panTo({lon: 30, lat: 50}); + * map.panTo({lat: 50, lng: 30}); + * map.panTo(L.latLng(50, 30)); + * ``` + * + * Note that `LatLng` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function LatLng(lat, lng, alt) { + if (isNaN(lat) || isNaN(lng)) { + throw new Error('Invalid LatLng object: (' + lat + ', ' + lng + ')'); + } + + // @property lat: Number + // Latitude in degrees + this.lat = +lat; + + // @property lng: Number + // Longitude in degrees + this.lng = +lng; + + // @property alt: Number + // Altitude in meters (optional) + if (alt !== undefined) { + this.alt = +alt; + } +} + +LatLng.prototype = { + // @method equals(otherLatLng: LatLng, maxMargin?: Number): Boolean + // Returns `true` if the given `LatLng` point is at the same position (within a small margin of error). The margin of error can be overridden by setting `maxMargin` to a small number. + equals: function (obj, maxMargin) { + if (!obj) { return false; } + + obj = toLatLng(obj); + + var margin = Math.max( + Math.abs(this.lat - obj.lat), + Math.abs(this.lng - obj.lng)); + + return margin <= (maxMargin === undefined ? 1.0E-9 : maxMargin); + }, + + // @method toString(): String + // Returns a string representation of the point (for debugging purposes). + toString: function (precision) { + return 'LatLng(' + + formatNum(this.lat, precision) + ', ' + + formatNum(this.lng, precision) + ')'; + }, + + // @method distanceTo(otherLatLng: LatLng): Number + // Returns the distance (in meters) to the given `LatLng` calculated using the [Spherical Law of Cosines](https://en.wikipedia.org/wiki/Spherical_law_of_cosines). + distanceTo: function (other) { + return Earth.distance(this, toLatLng(other)); + }, + + // @method wrap(): LatLng + // Returns a new `LatLng` object with the longitude wrapped so it's always between -180 and +180 degrees. + wrap: function () { + return Earth.wrapLatLng(this); + }, + + // @method toBounds(sizeInMeters: Number): LatLngBounds + // Returns a new `LatLngBounds` object in which each boundary is `sizeInMeters/2` meters apart from the `LatLng`. + toBounds: function (sizeInMeters) { + var latAccuracy = 180 * sizeInMeters / 40075017, + lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat); + + return toLatLngBounds( + [this.lat - latAccuracy, this.lng - lngAccuracy], + [this.lat + latAccuracy, this.lng + lngAccuracy]); + }, + + clone: function () { + return new LatLng(this.lat, this.lng, this.alt); + } +}; + + + +// @factory L.latLng(latitude: Number, longitude: Number, altitude?: Number): LatLng +// Creates an object representing a geographical point with the given latitude and longitude (and optionally altitude). + +// @alternative +// @factory L.latLng(coords: Array): LatLng +// Expects an array of the form `[Number, Number]` or `[Number, Number, Number]` instead. + +// @alternative +// @factory L.latLng(coords: Object): LatLng +// Expects an plain object of the form `{lat: Number, lng: Number}` or `{lat: Number, lng: Number, alt: Number}` instead. + +function toLatLng(a, b, c) { + if (a instanceof LatLng) { + return a; + } + if (isArray(a) && typeof a[0] !== 'object') { + if (a.length === 3) { + return new LatLng(a[0], a[1], a[2]); + } + if (a.length === 2) { + return new LatLng(a[0], a[1]); + } + return null; + } + if (a === undefined || a === null) { + return a; + } + if (typeof a === 'object' && 'lat' in a) { + return new LatLng(a.lat, 'lng' in a ? a.lng : a.lon, a.alt); + } + if (b === undefined) { + return null; + } + return new LatLng(a, b, c); +} + +/* + * @namespace CRS + * @crs L.CRS.Base + * Object that defines coordinate reference systems for projecting + * geographical points into pixel (screen) coordinates and back (and to + * coordinates in other units for [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) services). See + * [spatial reference system](https://en.wikipedia.org/wiki/Spatial_reference_system). + * + * Leaflet defines the most usual CRSs by default. If you want to use a + * CRS not defined by default, take a look at the + * [Proj4Leaflet](https://github.com/kartena/Proj4Leaflet) plugin. + * + * Note that the CRS instances do not inherit from Leaflet's `Class` object, + * and can't be instantiated. Also, new classes can't inherit from them, + * and methods can't be added to them with the `include` function. + */ + +var CRS = { + // @method latLngToPoint(latlng: LatLng, zoom: Number): Point + // Projects geographical coordinates into pixel coordinates for a given zoom. + latLngToPoint: function (latlng, zoom) { + var projectedPoint = this.projection.project(latlng), + scale = this.scale(zoom); + + return this.transformation._transform(projectedPoint, scale); + }, + + // @method pointToLatLng(point: Point, zoom: Number): LatLng + // The inverse of `latLngToPoint`. Projects pixel coordinates on a given + // zoom into geographical coordinates. + pointToLatLng: function (point, zoom) { + var scale = this.scale(zoom), + untransformedPoint = this.transformation.untransform(point, scale); + + return this.projection.unproject(untransformedPoint); + }, + + // @method project(latlng: LatLng): Point + // Projects geographical coordinates into coordinates in units accepted for + // this CRS (e.g. meters for EPSG:3857, for passing it to WMS services). + project: function (latlng) { + return this.projection.project(latlng); + }, + + // @method unproject(point: Point): LatLng + // Given a projected coordinate returns the corresponding LatLng. + // The inverse of `project`. + unproject: function (point) { + return this.projection.unproject(point); + }, + + // @method scale(zoom: Number): Number + // Returns the scale used when transforming projected coordinates into + // pixel coordinates for a particular zoom. For example, it returns + // `256 * 2^zoom` for Mercator-based CRS. + scale: function (zoom) { + return 256 * Math.pow(2, zoom); + }, + + // @method zoom(scale: Number): Number + // Inverse of `scale()`, returns the zoom level corresponding to a scale + // factor of `scale`. + zoom: function (scale) { + return Math.log(scale / 256) / Math.LN2; + }, + + // @method getProjectedBounds(zoom: Number): Bounds + // Returns the projection's bounds scaled and transformed for the provided `zoom`. + getProjectedBounds: function (zoom) { + if (this.infinite) { return null; } + + var b = this.projection.bounds, + s = this.scale(zoom), + min = this.transformation.transform(b.min, s), + max = this.transformation.transform(b.max, s); + + return new Bounds(min, max); + }, + + // @method distance(latlng1: LatLng, latlng2: LatLng): Number + // Returns the distance between two geographical coordinates. + + // @property code: String + // Standard code name of the CRS passed into WMS services (e.g. `'EPSG:3857'`) + // + // @property wrapLng: Number[] + // An array of two numbers defining whether the longitude (horizontal) coordinate + // axis wraps around a given range and how. Defaults to `[-180, 180]` in most + // geographical CRSs. If `undefined`, the longitude axis does not wrap around. + // + // @property wrapLat: Number[] + // Like `wrapLng`, but for the latitude (vertical) axis. + + // wrapLng: [min, max], + // wrapLat: [min, max], + + // @property infinite: Boolean + // If true, the coordinate space will be unbounded (infinite in both axes) + infinite: false, + + // @method wrapLatLng(latlng: LatLng): LatLng + // Returns a `LatLng` where lat and lng has been wrapped according to the + // CRS's `wrapLat` and `wrapLng` properties, if they are outside the CRS's bounds. + wrapLatLng: function (latlng) { + var lng = this.wrapLng ? wrapNum(latlng.lng, this.wrapLng, true) : latlng.lng, + lat = this.wrapLat ? wrapNum(latlng.lat, this.wrapLat, true) : latlng.lat, + alt = latlng.alt; + + return new LatLng(lat, lng, alt); + }, + + // @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds + // Returns a `LatLngBounds` with the same size as the given one, ensuring + // that its center is within the CRS's bounds. + // Only accepts actual `L.LatLngBounds` instances, not arrays. + wrapLatLngBounds: function (bounds) { + var center = bounds.getCenter(), + newCenter = this.wrapLatLng(center), + latShift = center.lat - newCenter.lat, + lngShift = center.lng - newCenter.lng; + + if (latShift === 0 && lngShift === 0) { + return bounds; + } + + var sw = bounds.getSouthWest(), + ne = bounds.getNorthEast(), + newSw = new LatLng(sw.lat - latShift, sw.lng - lngShift), + newNe = new LatLng(ne.lat - latShift, ne.lng - lngShift); + + return new LatLngBounds(newSw, newNe); + } +}; + +/* + * @namespace CRS + * @crs L.CRS.Earth + * + * Serves as the base for CRS that are global such that they cover the earth. + * Can only be used as the base for other CRS and cannot be used directly, + * since it does not have a `code`, `projection` or `transformation`. `distance()` returns + * meters. + */ + +var Earth = extend({}, CRS, { + wrapLng: [-180, 180], + + // Mean Earth Radius, as recommended for use by + // the International Union of Geodesy and Geophysics, + // see https://rosettacode.org/wiki/Haversine_formula + R: 6371000, + + // distance between two geographical points using spherical law of cosines approximation + distance: function (latlng1, latlng2) { + var rad = Math.PI / 180, + lat1 = latlng1.lat * rad, + lat2 = latlng2.lat * rad, + sinDLat = Math.sin((latlng2.lat - latlng1.lat) * rad / 2), + sinDLon = Math.sin((latlng2.lng - latlng1.lng) * rad / 2), + a = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon, + c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return this.R * c; + } +}); + +/* + * @namespace Projection + * @projection L.Projection.SphericalMercator + * + * Spherical Mercator projection — the most common projection for online maps, + * used by almost all free and commercial tile providers. Assumes that Earth is + * a sphere. Used by the `EPSG:3857` CRS. + */ + +var earthRadius = 6378137; + +var SphericalMercator = { + + R: earthRadius, + MAX_LATITUDE: 85.0511287798, + + project: function (latlng) { + var d = Math.PI / 180, + max = this.MAX_LATITUDE, + lat = Math.max(Math.min(max, latlng.lat), -max), + sin = Math.sin(lat * d); + + return new Point( + this.R * latlng.lng * d, + this.R * Math.log((1 + sin) / (1 - sin)) / 2); + }, + + unproject: function (point) { + var d = 180 / Math.PI; + + return new LatLng( + (2 * Math.atan(Math.exp(point.y / this.R)) - (Math.PI / 2)) * d, + point.x * d / this.R); + }, + + bounds: (function () { + var d = earthRadius * Math.PI; + return new Bounds([-d, -d], [d, d]); + })() +}; + +/* + * @class Transformation + * @aka L.Transformation + * + * Represents an affine transformation: a set of coefficients `a`, `b`, `c`, `d` + * for transforming a point of a form `(x, y)` into `(a*x + b, c*y + d)` and doing + * the reverse. Used by Leaflet in its projections code. + * + * @example + * + * ```js + * var transformation = L.transformation(2, 5, -1, 10), + * p = L.point(1, 2), + * p2 = transformation.transform(p), // L.point(7, 8) + * p3 = transformation.untransform(p2); // L.point(1, 2) + * ``` + */ + + +// factory new L.Transformation(a: Number, b: Number, c: Number, d: Number) +// Creates a `Transformation` object with the given coefficients. +function Transformation(a, b, c, d) { + if (isArray(a)) { + // use array properties + this._a = a[0]; + this._b = a[1]; + this._c = a[2]; + this._d = a[3]; + return; + } + this._a = a; + this._b = b; + this._c = c; + this._d = d; +} + +Transformation.prototype = { + // @method transform(point: Point, scale?: Number): Point + // Returns a transformed point, optionally multiplied by the given scale. + // Only accepts actual `L.Point` instances, not arrays. + transform: function (point, scale) { // (Point, Number) -> Point + return this._transform(point.clone(), scale); + }, + + // destructive transform (faster) + _transform: function (point, scale) { + scale = scale || 1; + point.x = scale * (this._a * point.x + this._b); + point.y = scale * (this._c * point.y + this._d); + return point; + }, + + // @method untransform(point: Point, scale?: Number): Point + // Returns the reverse transformation of the given point, optionally divided + // by the given scale. Only accepts actual `L.Point` instances, not arrays. + untransform: function (point, scale) { + scale = scale || 1; + return new Point( + (point.x / scale - this._b) / this._a, + (point.y / scale - this._d) / this._c); + } +}; + +// factory L.transformation(a: Number, b: Number, c: Number, d: Number) + +// @factory L.transformation(a: Number, b: Number, c: Number, d: Number) +// Instantiates a Transformation object with the given coefficients. + +// @alternative +// @factory L.transformation(coefficients: Array): Transformation +// Expects an coefficients array of the form +// `[a: Number, b: Number, c: Number, d: Number]`. + +function toTransformation(a, b, c, d) { + return new Transformation(a, b, c, d); +} + +/* + * @namespace CRS + * @crs L.CRS.EPSG3857 + * + * The most common CRS for online maps, used by almost all free and commercial + * tile providers. Uses Spherical Mercator projection. Set in by default in + * Map's `crs` option. + */ + +var EPSG3857 = extend({}, Earth, { + code: 'EPSG:3857', + projection: SphericalMercator, + + transformation: (function () { + var scale = 0.5 / (Math.PI * SphericalMercator.R); + return toTransformation(scale, 0.5, -scale, 0.5); + }()) +}); + +var EPSG900913 = extend({}, EPSG3857, { + code: 'EPSG:900913' +}); + +// @namespace SVG; @section +// There are several static functions which can be called without instantiating L.SVG: + +// @function create(name: String): SVGElement +// Returns a instance of [SVGElement](https://developer.mozilla.org/docs/Web/API/SVGElement), +// corresponding to the class name passed. For example, using 'line' will return +// an instance of [SVGLineElement](https://developer.mozilla.org/docs/Web/API/SVGLineElement). +function svgCreate(name) { + return document.createElementNS('http://www.w3.org/2000/svg', name); +} + +// @function pointsToPath(rings: Point[], closed: Boolean): String +// Generates a SVG path string for multiple rings, with each ring turning +// into "M..L..L.." instructions +function pointsToPath(rings, closed) { + var str = '', + i, j, len, len2, points, p; + + for (i = 0, len = rings.length; i < len; i++) { + points = rings[i]; + + for (j = 0, len2 = points.length; j < len2; j++) { + p = points[j]; + str += (j ? 'L' : 'M') + p.x + ' ' + p.y; + } + + // closes the ring for polygons; "x" is VML syntax + str += closed ? (Browser.svg ? 'z' : 'x') : ''; + } + + // SVG complains about empty path strings + return str || 'M0 0'; +} + +/* + * @namespace Browser + * @aka L.Browser + * + * A namespace with static properties for browser/feature detection used by Leaflet internally. + * + * @example + * + * ```js + * if (L.Browser.ielt9) { + * alert('Upgrade your browser, dude!'); + * } + * ``` + */ + +var style = document.documentElement.style; + +// @property ie: Boolean; `true` for all Internet Explorer versions (not Edge). +var ie = 'ActiveXObject' in window; + +// @property ielt9: Boolean; `true` for Internet Explorer versions less than 9. +var ielt9 = ie && !document.addEventListener; + +// @property edge: Boolean; `true` for the Edge web browser. +var edge = 'msLaunchUri' in navigator && !('documentMode' in document); + +// @property webkit: Boolean; +// `true` for webkit-based browsers like Chrome and Safari (including mobile versions). +var webkit = userAgentContains('webkit'); + +// @property android: Boolean +// **Deprecated.** `true` for any browser running on an Android platform. +var android = userAgentContains('android'); + +// @property android23: Boolean; **Deprecated.** `true` for browsers running on Android 2 or Android 3. +var android23 = userAgentContains('android 2') || userAgentContains('android 3'); + +/* See https://stackoverflow.com/a/17961266 for details on detecting stock Android */ +var webkitVer = parseInt(/WebKit\/([0-9]+)|$/.exec(navigator.userAgent)[1], 10); // also matches AppleWebKit +// @property androidStock: Boolean; **Deprecated.** `true` for the Android stock browser (i.e. not Chrome) +var androidStock = android && userAgentContains('Google') && webkitVer < 537 && !('AudioNode' in window); + +// @property opera: Boolean; `true` for the Opera browser +var opera = !!window.opera; + +// @property chrome: Boolean; `true` for the Chrome browser. +var chrome = !edge && userAgentContains('chrome'); + +// @property gecko: Boolean; `true` for gecko-based browsers like Firefox. +var gecko = userAgentContains('gecko') && !webkit && !opera && !ie; + +// @property safari: Boolean; `true` for the Safari browser. +var safari = !chrome && userAgentContains('safari'); + +var phantom = userAgentContains('phantom'); + +// @property opera12: Boolean +// `true` for the Opera browser supporting CSS transforms (version 12 or later). +var opera12 = 'OTransition' in style; + +// @property win: Boolean; `true` when the browser is running in a Windows platform +var win = navigator.platform.indexOf('Win') === 0; + +// @property ie3d: Boolean; `true` for all Internet Explorer versions supporting CSS transforms. +var ie3d = ie && ('transition' in style); + +// @property webkit3d: Boolean; `true` for webkit-based browsers supporting CSS transforms. +var webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23; + +// @property gecko3d: Boolean; `true` for gecko-based browsers supporting CSS transforms. +var gecko3d = 'MozPerspective' in style; + +// @property any3d: Boolean +// `true` for all browsers supporting CSS transforms. +var any3d = !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d) && !opera12 && !phantom; + +// @property mobile: Boolean; `true` for all browsers running in a mobile device. +var mobile = typeof orientation !== 'undefined' || userAgentContains('mobile'); + +// @property mobileWebkit: Boolean; `true` for all webkit-based browsers in a mobile device. +var mobileWebkit = mobile && webkit; + +// @property mobileWebkit3d: Boolean +// `true` for all webkit-based browsers in a mobile device supporting CSS transforms. +var mobileWebkit3d = mobile && webkit3d; + +// @property msPointer: Boolean +// `true` for browsers implementing the Microsoft touch events model (notably IE10). +var msPointer = !window.PointerEvent && window.MSPointerEvent; + +// @property pointer: Boolean +// `true` for all browsers supporting [pointer events](https://msdn.microsoft.com/en-us/library/dn433244%28v=vs.85%29.aspx). +var pointer = !!(window.PointerEvent || msPointer); + +// @property touchNative: Boolean +// `true` for all browsers supporting [touch events](https://developer.mozilla.org/docs/Web/API/Touch_events). +// **This does not necessarily mean** that the browser is running in a computer with +// a touchscreen, it only means that the browser is capable of understanding +// touch events. +var touchNative = 'ontouchstart' in window || !!window.TouchEvent; + +// @property touch: Boolean +// `true` for all browsers supporting either [touch](#browser-touch) or [pointer](#browser-pointer) events. +// Note: pointer events will be preferred (if available), and processed for all `touch*` listeners. +var touch = !window.L_NO_TOUCH && (touchNative || pointer); + +// @property mobileOpera: Boolean; `true` for the Opera browser in a mobile device. +var mobileOpera = mobile && opera; + +// @property mobileGecko: Boolean +// `true` for gecko-based browsers running in a mobile device. +var mobileGecko = mobile && gecko; + +// @property retina: Boolean +// `true` for browsers on a high-resolution "retina" screen or on any screen when browser's display zoom is more than 100%. +var retina = (window.devicePixelRatio || (window.screen.deviceXDPI / window.screen.logicalXDPI)) > 1; + +// @property passiveEvents: Boolean +// `true` for browsers that support passive events. +var passiveEvents = (function () { + var supportsPassiveOption = false; + try { + var opts = Object.defineProperty({}, 'passive', { + get: function () { // eslint-disable-line getter-return + supportsPassiveOption = true; + } + }); + window.addEventListener('testPassiveEventSupport', falseFn, opts); + window.removeEventListener('testPassiveEventSupport', falseFn, opts); + } catch (e) { + // Errors can safely be ignored since this is only a browser support test. + } + return supportsPassiveOption; +}()); + +// @property canvas: Boolean +// `true` when the browser supports [``](https://developer.mozilla.org/docs/Web/API/Canvas_API). +var canvas$1 = (function () { + return !!document.createElement('canvas').getContext; +}()); + +// @property svg: Boolean +// `true` when the browser supports [SVG](https://developer.mozilla.org/docs/Web/SVG). +var svg$1 = !!(document.createElementNS && svgCreate('svg').createSVGRect); + +var inlineSvg = !!svg$1 && (function () { + var div = document.createElement('div'); + div.innerHTML = ''; + return (div.firstChild && div.firstChild.namespaceURI) === 'http://www.w3.org/2000/svg'; +})(); + +// @property vml: Boolean +// `true` if the browser supports [VML](https://en.wikipedia.org/wiki/Vector_Markup_Language). +var vml = !svg$1 && (function () { + try { + var div = document.createElement('div'); + div.innerHTML = ''; + + var shape = div.firstChild; + shape.style.behavior = 'url(#default#VML)'; + + return shape && (typeof shape.adj === 'object'); + + } catch (e) { + return false; + } +}()); + + +// @property mac: Boolean; `true` when the browser is running in a Mac platform +var mac = navigator.platform.indexOf('Mac') === 0; + +// @property mac: Boolean; `true` when the browser is running in a Linux platform +var linux = navigator.platform.indexOf('Linux') === 0; + +function userAgentContains(str) { + return navigator.userAgent.toLowerCase().indexOf(str) >= 0; +} + + +var Browser = { + ie: ie, + ielt9: ielt9, + edge: edge, + webkit: webkit, + android: android, + android23: android23, + androidStock: androidStock, + opera: opera, + chrome: chrome, + gecko: gecko, + safari: safari, + phantom: phantom, + opera12: opera12, + win: win, + ie3d: ie3d, + webkit3d: webkit3d, + gecko3d: gecko3d, + any3d: any3d, + mobile: mobile, + mobileWebkit: mobileWebkit, + mobileWebkit3d: mobileWebkit3d, + msPointer: msPointer, + pointer: pointer, + touch: touch, + touchNative: touchNative, + mobileOpera: mobileOpera, + mobileGecko: mobileGecko, + retina: retina, + passiveEvents: passiveEvents, + canvas: canvas$1, + svg: svg$1, + vml: vml, + inlineSvg: inlineSvg, + mac: mac, + linux: linux +}; + +/* + * Extends L.DomEvent to provide touch support for Internet Explorer and Windows-based devices. + */ + +var POINTER_DOWN = Browser.msPointer ? 'MSPointerDown' : 'pointerdown'; +var POINTER_MOVE = Browser.msPointer ? 'MSPointerMove' : 'pointermove'; +var POINTER_UP = Browser.msPointer ? 'MSPointerUp' : 'pointerup'; +var POINTER_CANCEL = Browser.msPointer ? 'MSPointerCancel' : 'pointercancel'; +var pEvent = { + touchstart : POINTER_DOWN, + touchmove : POINTER_MOVE, + touchend : POINTER_UP, + touchcancel : POINTER_CANCEL +}; +var handle = { + touchstart : _onPointerStart, + touchmove : _handlePointer, + touchend : _handlePointer, + touchcancel : _handlePointer +}; +var _pointers = {}; +var _pointerDocListener = false; + +// Provides a touch events wrapper for (ms)pointer events. +// ref https://www.w3.org/TR/pointerevents/ https://www.w3.org/Bugs/Public/show_bug.cgi?id=22890 + +function addPointerListener(obj, type, handler) { + if (type === 'touchstart') { + _addPointerDocListener(); + } + if (!handle[type]) { + console.warn('wrong event specified:', type); + return falseFn; + } + handler = handle[type].bind(this, handler); + obj.addEventListener(pEvent[type], handler, false); + return handler; +} + +function removePointerListener(obj, type, handler) { + if (!pEvent[type]) { + console.warn('wrong event specified:', type); + return; + } + obj.removeEventListener(pEvent[type], handler, false); +} + +function _globalPointerDown(e) { + _pointers[e.pointerId] = e; +} + +function _globalPointerMove(e) { + if (_pointers[e.pointerId]) { + _pointers[e.pointerId] = e; + } +} + +function _globalPointerUp(e) { + delete _pointers[e.pointerId]; +} + +function _addPointerDocListener() { + // need to keep track of what pointers and how many are active to provide e.touches emulation + if (!_pointerDocListener) { + // we listen document as any drags that end by moving the touch off the screen get fired there + document.addEventListener(POINTER_DOWN, _globalPointerDown, true); + document.addEventListener(POINTER_MOVE, _globalPointerMove, true); + document.addEventListener(POINTER_UP, _globalPointerUp, true); + document.addEventListener(POINTER_CANCEL, _globalPointerUp, true); + + _pointerDocListener = true; + } +} + +function _handlePointer(handler, e) { + if (e.pointerType === (e.MSPOINTER_TYPE_MOUSE || 'mouse')) { return; } + + e.touches = []; + for (var i in _pointers) { + e.touches.push(_pointers[i]); + } + e.changedTouches = [e]; + + handler(e); +} + +function _onPointerStart(handler, e) { + // IE10 specific: MsTouch needs preventDefault. See #2000 + if (e.MSPOINTER_TYPE_TOUCH && e.pointerType === e.MSPOINTER_TYPE_TOUCH) { + preventDefault(e); + } + _handlePointer(handler, e); +} + +/* + * Extends the event handling code with double tap support for mobile browsers. + * + * Note: currently most browsers fire native dblclick, with only a few exceptions + * (see https://github.com/Leaflet/Leaflet/issues/7012#issuecomment-595087386) + */ + +function makeDblclick(event) { + // in modern browsers `type` cannot be just overridden: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Getter_only + var newEvent = {}, + prop, i; + for (i in event) { + prop = event[i]; + newEvent[i] = prop && prop.bind ? prop.bind(event) : prop; + } + event = newEvent; + newEvent.type = 'dblclick'; + newEvent.detail = 2; + newEvent.isTrusted = false; + newEvent._simulated = true; // for debug purposes + return newEvent; +} + +var delay = 200; +function addDoubleTapListener(obj, handler) { + // Most browsers handle double tap natively + obj.addEventListener('dblclick', handler); + + // On some platforms the browser doesn't fire native dblclicks for touch events. + // It seems that in all such cases `detail` property of `click` event is always `1`. + // So here we rely on that fact to avoid excessive 'dblclick' simulation when not needed. + var last = 0, + detail; + function simDblclick(e) { + if (e.detail !== 1) { + detail = e.detail; // keep in sync to avoid false dblclick in some cases + return; + } + + if (e.pointerType === 'mouse' || + (e.sourceCapabilities && !e.sourceCapabilities.firesTouchEvents)) { + + return; + } + + // When clicking on an , the browser generates a click on its + //