fedipod-bb 0.4.1 → 0.4.2
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/README.md +3 -4
- package/package.json +9 -3
- package/site/bb.js +4 -4
- package/site/read.mjs +48 -3
- package/src/publish.mjs +2 -0
package/README.md
CHANGED
|
@@ -12,11 +12,10 @@ the forum from a moderator's machine, and the page that shows the forum.
|
|
|
12
12
|
A moderator's console is part of that page: the queue of reports, held posts
|
|
13
13
|
and asks, and the settings.
|
|
14
14
|
The queue shows what needs a moderator: a report, a held post, a join
|
|
15
|
-
request, and a request of a moderator's that did not take.
|
|
16
|
-
`claude/plans/fedipod-bb.md` at the repository root.
|
|
15
|
+
request, and a request of a moderator's that did not take.
|
|
17
16
|
|
|
18
17
|
```
|
|
19
|
-
|
|
18
|
+
npm test
|
|
20
19
|
```
|
|
21
20
|
|
|
22
21
|
## Hosting a forum
|
|
@@ -181,7 +180,7 @@ category's address; a reader with nothing is pointed at sign-up.
|
|
|
181
180
|
## Attaching to a Gateway
|
|
182
181
|
|
|
183
182
|
```
|
|
184
|
-
|
|
183
|
+
fedipod-bb attach --home DIR --front https://fedipod.net
|
|
185
184
|
```
|
|
186
185
|
|
|
187
186
|
Takes `@<handle>@fedipod.net` for the forum and one address per category,
|
package/package.json
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod-bb",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "FediPod-BB: a forum website whose record lives on a Solid pod.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": "Jeff Zucker",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jeff-zucker/fedipod-bb.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/jeff-zucker/fedipod-bb#readme",
|
|
12
|
+
"bugs": "https://github.com/jeff-zucker/fedipod-bb/issues",
|
|
6
13
|
"type": "module",
|
|
7
14
|
"main": "src/index.mjs",
|
|
8
15
|
"exports": {
|
|
@@ -23,11 +30,10 @@
|
|
|
23
30
|
"fep-draft.md"
|
|
24
31
|
],
|
|
25
32
|
"scripts": {
|
|
26
|
-
"pretest": "node scripts/link-fedipod.mjs",
|
|
27
33
|
"test": "node --test test/*.test.mjs"
|
|
28
34
|
},
|
|
29
35
|
"dependencies": {
|
|
30
|
-
"fedipod": "^1.
|
|
36
|
+
"fedipod": "^1.50.2",
|
|
31
37
|
"fediverse-session": "^0.2.1"
|
|
32
38
|
}
|
|
33
39
|
}
|
package/site/bb.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// https://bb.<site>/<handle>/ the same forum at its own address
|
|
11
11
|
// #/ #/c/<slug> #/t/<slug>/<tid>
|
|
12
12
|
|
|
13
|
-
import { reader, placeOf, authorLabel, cacheKey, categoryBase } from './read.mjs';
|
|
13
|
+
import { reader, placeOf, authorLabel, cacheKey, categoryBase, cleanHtml } from './read.mjs';
|
|
14
14
|
import { MastoLogin, hostOfHandle, serverKind, actorOfHandle } from './masto.mjs';
|
|
15
15
|
import * as pod from './pod.mjs';
|
|
16
16
|
import { mine as keptByReader } from './mine.mjs';
|
|
@@ -70,7 +70,7 @@ const handleOf = (cat) => {
|
|
|
70
70
|
// A post names the category it is for, and every reader of this page is
|
|
71
71
|
// already in it: the address is how it got here, not something to read.
|
|
72
72
|
function body(p, cat) {
|
|
73
|
-
const html = p?.content
|
|
73
|
+
const html = cleanHtml(p?.content);
|
|
74
74
|
if (!cat) return html;
|
|
75
75
|
const d = document.createElement('div');
|
|
76
76
|
d.innerHTML = html;
|
|
@@ -297,7 +297,7 @@ async function showReplies() {
|
|
|
297
297
|
const href = cat && tid ? `#/t/${esc(cat.slug)}/${esc(tid)}/${esc(await cacheKey(p.id))}` : '#/';
|
|
298
298
|
items.push(`<li><a class="title" href="${href}">${esc(p.topicName || 'Topic')}</a>
|
|
299
299
|
<div class="meta">${who ? esc(who.handle) : esc(authorLabel(p.author || ''))} · ${esc(when(p.published))}</div>
|
|
300
|
-
<article class="post"><div class="body">${p.content
|
|
300
|
+
<article class="post"><div class="body">${cleanHtml(p.content)}</div></article></li>`);
|
|
301
301
|
}
|
|
302
302
|
$('main').innerHTML = `<ul class="list">${items.join('')}</ul>`;
|
|
303
303
|
}
|
|
@@ -391,7 +391,7 @@ async function showWho(handleOrId) {
|
|
|
391
391
|
const href = c && tid ? `#/t/${esc(c.slug)}/${esc(tid)}/${esc(await cacheKey(p.id))}` : '#/';
|
|
392
392
|
items.push(`<li><a class="title" href="${href}">${esc(p.topicName || 'Topic')}</a>
|
|
393
393
|
<div class="meta">${c ? esc(c.name) + ' · ' : ''}${esc(when(p.published))} · ▲ ${p.likes || 0} ▼ ${p.dislikes || 0}</div>
|
|
394
|
-
<article class="post"><div class="body">${p.content
|
|
394
|
+
<article class="post"><div class="body">${cleanHtml(p.content)}</div></article></li>`);
|
|
395
395
|
}
|
|
396
396
|
$('main').innerHTML = head + `<ul class="list">${items.join('')}</ul>`;
|
|
397
397
|
}
|
package/site/read.mjs
CHANGED
|
@@ -10,6 +10,51 @@ const MAX_PAGES = 50;
|
|
|
10
10
|
|
|
11
11
|
export const categoryBase = (actorId) => String(actorId).replace(/ap\/actor$/u, '');
|
|
12
12
|
|
|
13
|
+
// A post's text, one copy: a server that sends it once per language hands
|
|
14
|
+
// over a list, read as its first.
|
|
15
|
+
export function textOf(v) {
|
|
16
|
+
if (typeof v === 'string') return v;
|
|
17
|
+
if (Array.isArray(v)) return v.find(s => typeof s === 'string') || '';
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A post's text as the page shows it: only the tags and attributes a post may
|
|
22
|
+
// carry (the list the forum's host keeps when it stores one), built back up
|
|
23
|
+
// from a parse so nothing else reaches the page. DOMParser's parse is inert:
|
|
24
|
+
// it runs no script and loads no image. `parse` is for tests.
|
|
25
|
+
const KEEP = new Set(['p', 'br', 'a', 'span', 'em', 'strong', 'b', 'i', 'u', 'del',
|
|
26
|
+
'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4']);
|
|
27
|
+
const ATTRS = new Set(['href', 'class', 'lang', 'title']);
|
|
28
|
+
const DROP = new Set(['script', 'style', 'textarea', 'noscript', 'template', 'title', 'head']);
|
|
29
|
+
const escText = (s) => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
30
|
+
const linkable = (href) => { try { return ['http:', 'https:', 'mailto:'].includes(new URL(href).protocol); } catch { return false; } };
|
|
31
|
+
const inert = (html) => new DOMParser().parseFromString(html, 'text/html').body;
|
|
32
|
+
|
|
33
|
+
function rebuild(node) {
|
|
34
|
+
let out = '';
|
|
35
|
+
for (const n of node.childNodes) {
|
|
36
|
+
if (n.nodeType === 3) { out += escText(n.nodeValue); continue; }
|
|
37
|
+
if (n.nodeType !== 1) continue;
|
|
38
|
+
const tag = n.nodeName.toLowerCase();
|
|
39
|
+
if (DROP.has(tag)) continue;
|
|
40
|
+
if (!KEEP.has(tag)) { out += rebuild(n); continue; }
|
|
41
|
+
let attrs = '';
|
|
42
|
+
for (const a of n.attributes) {
|
|
43
|
+
const name = a.name.toLowerCase();
|
|
44
|
+
if (!ATTRS.has(name) || (name === 'href' && (tag !== 'a' || !linkable(a.value)))) continue;
|
|
45
|
+
attrs += ` ${name}="${escText(a.value)}"`;
|
|
46
|
+
}
|
|
47
|
+
if (tag === 'a') attrs += ' rel="nofollow noopener noreferrer"';
|
|
48
|
+
out += tag === 'br' ? '<br>' : `<${tag}${attrs}>${rebuild(n)}</${tag}>`;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cleanHtml(html, parse = inert) {
|
|
54
|
+
const text = textOf(html);
|
|
55
|
+
return text ? rebuild(parse(text)) : '';
|
|
56
|
+
}
|
|
57
|
+
|
|
13
58
|
// Where a forum is read from: through a Gateway at `<origin>/u/<handle>/`, or
|
|
14
59
|
// a pod home given outright.
|
|
15
60
|
export const forumBase = ({ origin = null, handle = null, pod = null } = {}) => {
|
|
@@ -213,8 +258,8 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
|
|
|
213
258
|
id: copy.id || url,
|
|
214
259
|
|
|
215
260
|
author: idOf([].concat(copy.attributedTo || [])[0]) || null,
|
|
216
|
-
name: copy.name || null,
|
|
217
|
-
content:
|
|
261
|
+
name: textOf(copy.name) || null,
|
|
262
|
+
content: textOf(copy.content),
|
|
218
263
|
published: copy.published || null,
|
|
219
264
|
topic: idOf(copy.context) || null,
|
|
220
265
|
inReplyTo: idOf(copy.inReplyTo) || null,
|
|
@@ -278,7 +323,7 @@ export function reader({ fetch: f = globalThis.fetch.bind(globalThis), session =
|
|
|
278
323
|
return {
|
|
279
324
|
id: copy.id || postId, type: copy.type || 'Note', gone: copy.type === 'Tombstone', page,
|
|
280
325
|
author: idOf([].concat(copy.attributedTo || [])[0]) || null,
|
|
281
|
-
name: copy.name || null, content:
|
|
326
|
+
name: textOf(copy.name) || null, content: textOf(copy.content),
|
|
282
327
|
// What its author typed, when they said so: an edit reopens this
|
|
283
328
|
// rather than guessing it back out of the HTML.
|
|
284
329
|
source: copy.source?.mediaType === 'text/markdown' && typeof copy.source?.content === 'string' ? copy.source.content : null,
|
package/src/publish.mjs
CHANGED
|
@@ -175,6 +175,8 @@ export async function cachePost({ remote, urls }, note, { topic = null, replies
|
|
|
175
175
|
// count beside every post it shows.
|
|
176
176
|
if (Number.isFinite(likes)) copy.likes = { type: 'Collection', totalItems: likes };
|
|
177
177
|
if (Number.isFinite(dislikes)) copy.dislikes = { type: 'Collection', totalItems: dislikes };
|
|
178
|
+
// One copy of the text: a server that sends it once per language hands over a list.
|
|
179
|
+
if (Array.isArray(copy.content)) copy.content = copy.content.find(s => typeof s === 'string') || '';
|
|
178
180
|
if (typeof copy.content === 'string') copy.content = sanitizeHtml(copy.content);
|
|
179
181
|
if (!copy['@context']) copy['@context'] = 'https://www.w3.org/ns/activitystreams';
|
|
180
182
|
const url = urls.cached(note.id);
|