wireweave 0.3.10 → 0.3.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bans.js +10 -9
- package/src/channels.js +4 -2
- package/src/dtag.js +14 -0
- package/src/pages.js +61 -18
- package/src/relay-pool.js +19 -9
- package/src/roles.js +4 -2
- package/src/settings.js +5 -3
package/package.json
CHANGED
package/src/bans.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { dtag, parseDtag } from './dtag.js';
|
|
2
|
+
|
|
1
3
|
export class Bans extends EventTarget {
|
|
2
4
|
constructor({ relayPool, auth = null, roles = null }) {
|
|
3
5
|
super();
|
|
@@ -17,7 +19,7 @@ export class Bans extends EventTarget {
|
|
|
17
19
|
async ban(serverId, pubkey) {
|
|
18
20
|
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
19
21
|
if (this.roles && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
|
|
20
|
-
const dTag = '
|
|
22
|
+
const dTag = dtag('ban', serverId, pubkey);
|
|
21
23
|
const signed = await this.auth.sign({
|
|
22
24
|
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
23
25
|
tags: [['d', dTag], ['server', serverId]],
|
|
@@ -30,7 +32,7 @@ export class Bans extends EventTarget {
|
|
|
30
32
|
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
31
33
|
if (this.roles && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
|
|
32
34
|
const expiry = Math.floor(Date.now() / 1000) + (minutes * 60);
|
|
33
|
-
const dTag = '
|
|
35
|
+
const dTag = dtag('timeout', serverId, pubkey);
|
|
34
36
|
const signed = await this.auth.sign({
|
|
35
37
|
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
36
38
|
tags: [['d', dTag], ['server', serverId]],
|
|
@@ -43,7 +45,7 @@ export class Bans extends EventTarget {
|
|
|
43
45
|
if (!this.auth?.isLoggedIn()) throw new Error('Not logged in');
|
|
44
46
|
const signed = await this.auth.sign({
|
|
45
47
|
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
46
|
-
tags: [['d', '
|
|
48
|
+
tags: [['d', dtag('kick', pubkey)]], content: ''
|
|
47
49
|
});
|
|
48
50
|
this.pool.publish(signed);
|
|
49
51
|
}
|
|
@@ -61,13 +63,12 @@ export class Bans extends EventTarget {
|
|
|
61
63
|
try {
|
|
62
64
|
const dTag = event.tags.find(t => t[0] === 'd');
|
|
63
65
|
if (!dTag?.[1]) return;
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
const pubkey = parts[parts.length - 1];
|
|
67
|
-
if (prefix !== 'zellous-ban' && prefix !== 'zellous-timeout') return;
|
|
66
|
+
const parsed = parseDtag(dTag[1]);
|
|
67
|
+
if (!parsed || (parsed.ns !== 'ban' && parsed.ns !== 'timeout')) return;
|
|
68
|
+
const pubkey = parsed.parts[parsed.parts.length - 1];
|
|
68
69
|
const data = this.store.get(serverId) || { banned: [], timeouts: {} };
|
|
69
|
-
if (
|
|
70
|
-
else if (
|
|
70
|
+
if (parsed.ns === 'ban' && pubkey && !data.banned.includes(pubkey)) data.banned.push(pubkey);
|
|
71
|
+
else if (parsed.ns === 'timeout' && pubkey) {
|
|
71
72
|
const parsed = JSON.parse(event.content);
|
|
72
73
|
if (parsed.expiry > Math.floor(Date.now() / 1000)) (data.timeouts = data.timeouts || {})[pubkey] = { expiry: parsed.expiry };
|
|
73
74
|
else if (data.timeouts?.[pubkey]) delete data.timeouts[pubkey];
|
package/src/channels.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { dtag } from './dtag.js';
|
|
2
|
+
|
|
1
3
|
const DEFAULT_CATEGORIES = [
|
|
2
4
|
{ id: 'general', name: 'TEXT CHANNELS', position: 0 },
|
|
3
5
|
{ id: 'voice', name: 'VOICE CHANNELS', position: 1 }
|
|
@@ -23,7 +25,7 @@ export class Channels extends EventTarget {
|
|
|
23
25
|
this.serverId = serverId;
|
|
24
26
|
this.channels = []; this.categories = [];
|
|
25
27
|
const ownerPubkey = serverId.split(':')[0];
|
|
26
|
-
const dTag = '
|
|
28
|
+
const dTag = dtag('channels', serverId);
|
|
27
29
|
this.pool.subscribe('channels-' + serverId,
|
|
28
30
|
[{ kinds: [30078], authors: [ownerPubkey], '#d': [dTag] }],
|
|
29
31
|
(event) => {
|
|
@@ -53,7 +55,7 @@ export class Channels extends EventTarget {
|
|
|
53
55
|
if (!this.isOwner()) return;
|
|
54
56
|
const signed = await this.auth.sign({
|
|
55
57
|
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
56
|
-
tags: [['d', '
|
|
58
|
+
tags: [['d', dtag('channels', this.serverId)]],
|
|
57
59
|
content: JSON.stringify({ channels: this.channels, categories: this.categories })
|
|
58
60
|
});
|
|
59
61
|
this.pool.publish(signed);
|
package/src/dtag.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const NAMESPACES = new Set(['ban', 'timeout', 'kick', 'page', 'channels', 'roles', 'settings']);
|
|
2
|
+
|
|
3
|
+
export const dtag = (ns, ...parts) => {
|
|
4
|
+
if (!NAMESPACES.has(ns)) throw new Error('dtag: unknown namespace ' + ns);
|
|
5
|
+
return ['zellous-' + ns, ...parts].join(':');
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const parseDtag = (s) => {
|
|
9
|
+
if (typeof s !== 'string' || !s.startsWith('zellous-')) return null;
|
|
10
|
+
const parts = s.slice(8).split(':');
|
|
11
|
+
const ns = parts.shift();
|
|
12
|
+
if (!NAMESPACES.has(ns)) return null;
|
|
13
|
+
return { ns, parts };
|
|
14
|
+
};
|
package/src/pages.js
CHANGED
|
@@ -1,26 +1,69 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
const ALLOWED_TAGS = new Set(['a','abbr','b','blockquote','br','code','div','em','h1','h2','h3','h4','h5','h6','hr','i','img','li','ol','p','pre','span','strong','sub','sup','table','tbody','td','th','thead','tr','u','ul']);
|
|
2
|
+
const ALLOWED_ATTRS = { a: new Set(['href','title','rel','target']), img: new Set(['src','alt','title','width','height']), '*': new Set(['class','id']) };
|
|
3
|
+
const SAFE_URL = /^(https?:|mailto:|#|\/)/i;
|
|
4
|
+
|
|
5
|
+
const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
|
6
|
+
|
|
7
|
+
const sanitizeNoDom = (html) => {
|
|
8
|
+
let out = '';
|
|
9
|
+
let i = 0;
|
|
10
|
+
while (i < html.length) {
|
|
11
|
+
const lt = html.indexOf('<', i);
|
|
12
|
+
if (lt < 0) { out += escapeHtml(html.slice(i)); break; }
|
|
13
|
+
out += escapeHtml(html.slice(i, lt));
|
|
14
|
+
const gt = html.indexOf('>', lt);
|
|
15
|
+
if (gt < 0) { out += escapeHtml(html.slice(lt)); break; }
|
|
16
|
+
const raw = html.slice(lt + 1, gt);
|
|
17
|
+
const closing = raw.startsWith('/');
|
|
18
|
+
const body = closing ? raw.slice(1) : raw;
|
|
19
|
+
const m = body.match(/^([a-zA-Z][a-zA-Z0-9]*)/);
|
|
20
|
+
i = gt + 1;
|
|
21
|
+
if (!m) continue;
|
|
22
|
+
const tag = m[1].toLowerCase();
|
|
23
|
+
if (!ALLOWED_TAGS.has(tag)) continue;
|
|
24
|
+
if (closing) { out += '</' + tag + '>'; continue; }
|
|
25
|
+
const attrs = [];
|
|
26
|
+
const attrRe = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/g;
|
|
27
|
+
let am;
|
|
28
|
+
const allowed = ALLOWED_ATTRS[tag] || new Set();
|
|
29
|
+
while ((am = attrRe.exec(body.slice(m[0].length))) !== null) {
|
|
30
|
+
const name = am[1].toLowerCase();
|
|
31
|
+
const value = am[3] ?? am[4] ?? am[5] ?? '';
|
|
32
|
+
if (/^on/.test(name)) continue;
|
|
33
|
+
if (!allowed.has(name) && !ALLOWED_ATTRS['*'].has(name)) continue;
|
|
34
|
+
if ((name === 'href' || name === 'src') && !SAFE_URL.test(value.trim())) continue;
|
|
35
|
+
attrs.push(name + '="' + escapeHtml(value) + '"');
|
|
36
|
+
}
|
|
37
|
+
out += '<' + tag + (attrs.length ? ' ' + attrs.join(' ') : '') + '>';
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
};
|
|
9
41
|
|
|
10
42
|
const sanitize = (html) => {
|
|
11
|
-
|
|
12
|
-
if (typeof document === 'undefined') return stripped;
|
|
43
|
+
if (typeof document === 'undefined') return sanitizeNoDom(html || '');
|
|
13
44
|
const el = document.createElement('div');
|
|
14
|
-
el.innerHTML =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
45
|
+
el.innerHTML = html || '';
|
|
46
|
+
const walk = (node) => {
|
|
47
|
+
[...node.childNodes].forEach(child => {
|
|
48
|
+
if (child.nodeType === 1) {
|
|
49
|
+
const tag = child.tagName.toLowerCase();
|
|
50
|
+
if (!ALLOWED_TAGS.has(tag)) { child.replaceWith(...child.childNodes); return; }
|
|
51
|
+
const allowed = ALLOWED_ATTRS[tag] || new Set();
|
|
52
|
+
[...child.attributes].forEach(a => {
|
|
53
|
+
const name = a.name.toLowerCase();
|
|
54
|
+
if (/^on/.test(name) || (!allowed.has(name) && !ALLOWED_ATTRS['*'].has(name))) child.removeAttribute(a.name);
|
|
55
|
+
else if ((name === 'href' || name === 'src') && !SAFE_URL.test(a.value.trim())) child.removeAttribute(a.name);
|
|
56
|
+
});
|
|
57
|
+
walk(child);
|
|
58
|
+
} else if (child.nodeType !== 3) child.remove();
|
|
19
59
|
});
|
|
20
|
-
}
|
|
60
|
+
};
|
|
61
|
+
walk(el);
|
|
21
62
|
return el.innerHTML;
|
|
22
63
|
};
|
|
23
64
|
|
|
65
|
+
import { dtag } from './dtag.js';
|
|
66
|
+
|
|
24
67
|
export class Pages extends EventTarget {
|
|
25
68
|
constructor({ relayPool, auth, roles }) {
|
|
26
69
|
super();
|
|
@@ -29,7 +72,7 @@ export class Pages extends EventTarget {
|
|
|
29
72
|
this.store = new Map(); this.subs = new Map();
|
|
30
73
|
}
|
|
31
74
|
|
|
32
|
-
_key(serverId, slug) { return '
|
|
75
|
+
_key(serverId, slug) { return dtag('page', serverId, slug); }
|
|
33
76
|
getPages(serverId) { return Array.from((this.store.get(serverId) || new Map()).values()); }
|
|
34
77
|
|
|
35
78
|
subscribe(serverId) {
|
|
@@ -43,7 +86,7 @@ export class Pages extends EventTarget {
|
|
|
43
86
|
(event) => {
|
|
44
87
|
if (event.pubkey !== creator) return;
|
|
45
88
|
const dTag = (event.tags.find(t => t[0] === 'd') || [])[1] || '';
|
|
46
|
-
const prefix = '
|
|
89
|
+
const prefix = dtag('page', serverId) + ':';
|
|
47
90
|
if (!dTag.startsWith(prefix)) return;
|
|
48
91
|
const slug = dTag.slice(prefix.length); if (!slug) return;
|
|
49
92
|
try {
|
package/src/relay-pool.js
CHANGED
|
@@ -11,12 +11,23 @@ const DEFAULT_RELAYS = [
|
|
|
11
11
|
'wss://relay.0xchat.com'
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const SEEN_MAX = 10000;
|
|
15
|
+
|
|
16
|
+
const lruTouch = (map, key) => {
|
|
17
|
+
if (map.has(key)) { map.delete(key); map.set(key, 1); return false; }
|
|
18
|
+
map.set(key, 1);
|
|
19
|
+
if (map.size > SEEN_MAX) { const first = map.keys().next().value; map.delete(first); }
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const fnv1a = (s) => {
|
|
24
|
+
let h = 0x811c9dc5;
|
|
25
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; }
|
|
26
|
+
return h.toString(16).padStart(8, '0');
|
|
18
27
|
};
|
|
19
28
|
|
|
29
|
+
const safeSubId = (subId) => subId.length <= 64 ? subId : subId.slice(0, 55) + '-' + fnv1a(subId);
|
|
30
|
+
|
|
20
31
|
export class RelayPool extends EventTarget {
|
|
21
32
|
constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null } = {}) {
|
|
22
33
|
super();
|
|
@@ -24,7 +35,7 @@ export class RelayPool extends EventTarget {
|
|
|
24
35
|
this.relays = new Map();
|
|
25
36
|
this.subs = new Map();
|
|
26
37
|
this.pending = [];
|
|
27
|
-
this.seen = new
|
|
38
|
+
this.seen = new Map();
|
|
28
39
|
this.verifyEvent = verifyEvent;
|
|
29
40
|
this.WS = WebSocketImpl || (typeof WebSocket !== 'undefined' ? WebSocket : null);
|
|
30
41
|
if (!this.WS) throw new Error('No WebSocket implementation available');
|
|
@@ -99,8 +110,7 @@ export class RelayPool extends EventTarget {
|
|
|
99
110
|
if (this.verifyEvent) {
|
|
100
111
|
try { if (!this.verifyEvent(event)) return; } catch { return; }
|
|
101
112
|
}
|
|
102
|
-
this.seen
|
|
103
|
-
this.seen = trim(this.seen, 10000, 5000);
|
|
113
|
+
lruTouch(this.seen, event.id);
|
|
104
114
|
const sub = this.subs.get(subId);
|
|
105
115
|
sub?.onEvent?.(event);
|
|
106
116
|
this._emit('event', { subId, event });
|
|
@@ -115,7 +125,7 @@ export class RelayPool extends EventTarget {
|
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
subscribe(subId, filters, onEvent, onEose) {
|
|
118
|
-
subId = subId
|
|
128
|
+
subId = safeSubId(subId);
|
|
119
129
|
this.subs.set(subId, { filters, onEvent, onEose });
|
|
120
130
|
for (const [, relay] of this.relays) {
|
|
121
131
|
if (relay.ws?.readyState === 1) {
|
|
@@ -128,7 +138,7 @@ export class RelayPool extends EventTarget {
|
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
unsubscribe(subId) {
|
|
131
|
-
subId = subId
|
|
141
|
+
subId = safeSubId(subId);
|
|
132
142
|
for (const [, relay] of this.relays) {
|
|
133
143
|
if (relay.ws?.readyState === 1 && relay.subIds.has(subId)) {
|
|
134
144
|
relay.ws.send(JSON.stringify(['CLOSE', subId]));
|
package/src/roles.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { dtag } from './dtag.js';
|
|
2
|
+
|
|
1
3
|
export class Roles extends EventTarget {
|
|
2
4
|
constructor({ relayPool, auth }) {
|
|
3
5
|
super();
|
|
@@ -31,7 +33,7 @@ export class Roles extends EventTarget {
|
|
|
31
33
|
else if (role === 'moderator') mods = [...mods, targetPubkey];
|
|
32
34
|
const next = { admins, mods };
|
|
33
35
|
this.store.set(serverId, next);
|
|
34
|
-
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', '
|
|
36
|
+
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', dtag('roles', serverId)]], content: JSON.stringify(next) });
|
|
35
37
|
this.pool.publish(signed);
|
|
36
38
|
this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
|
|
37
39
|
}
|
|
@@ -43,7 +45,7 @@ export class Roles extends EventTarget {
|
|
|
43
45
|
if (!creator) return;
|
|
44
46
|
this.sub = 'roles-' + serverId;
|
|
45
47
|
this.pool.subscribe(this.sub,
|
|
46
|
-
[{ kinds: [30078], authors: [creator], '#d': ['
|
|
48
|
+
[{ kinds: [30078], authors: [creator], '#d': [dtag('roles', serverId)] }],
|
|
47
49
|
(event) => {
|
|
48
50
|
if (event.pubkey !== creator) return;
|
|
49
51
|
try {
|
package/src/settings.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { dtag } from './dtag.js';
|
|
2
|
+
|
|
1
3
|
const VALID_BITRATES = [8000, 16000, 24000, 48000, 96000];
|
|
2
4
|
const clampBitrate = (v) => VALID_BITRATES.reduce((prev, cur) => Math.abs(cur - v) < Math.abs(prev - v) ? cur : prev);
|
|
3
5
|
|
|
@@ -18,7 +20,7 @@ export class Settings extends EventTarget {
|
|
|
18
20
|
const existing = this.store.get(serverId) || {};
|
|
19
21
|
const next = { ...existing, opusBitrate: clamped };
|
|
20
22
|
this.store.set(serverId, next);
|
|
21
|
-
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', '
|
|
23
|
+
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', dtag('settings', serverId)]], content: JSON.stringify(next) });
|
|
22
24
|
this.pool.publish(signed);
|
|
23
25
|
this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
|
|
24
26
|
return clamped;
|
|
@@ -36,7 +38,7 @@ export class Settings extends EventTarget {
|
|
|
36
38
|
const existing = this.store.get(serverId) || {};
|
|
37
39
|
const next = { ...existing, embedAllowlist: domains };
|
|
38
40
|
this.store.set(serverId, next);
|
|
39
|
-
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', '
|
|
41
|
+
const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', dtag('settings', serverId)]], content: JSON.stringify(next) });
|
|
40
42
|
this.pool.publish(signed);
|
|
41
43
|
this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
|
|
42
44
|
return domains;
|
|
@@ -61,7 +63,7 @@ export class Settings extends EventTarget {
|
|
|
61
63
|
if (!creator) return;
|
|
62
64
|
this.sub = 'settings-' + serverId;
|
|
63
65
|
this.pool.subscribe(this.sub,
|
|
64
|
-
[{ kinds: [30078], authors: [creator], '#d': ['
|
|
66
|
+
[{ kinds: [30078], authors: [creator], '#d': [dtag('settings', serverId)] }],
|
|
65
67
|
(event) => {
|
|
66
68
|
if (event.pubkey !== creator) return;
|
|
67
69
|
try { this.store.set(serverId, JSON.parse(event.content)); this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next: this.store.get(serverId) } })); } catch {}
|