fedipod-bb 0.4.1 → 0.4.3

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 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. The design and the phases are in
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
- node --test packages/fedipod-bb/test/*.test.mjs
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
- node packages/fedipod-bb/bin/fedipod-bb.mjs attach --home DIR --front https://fedipod.net
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.1",
3
+ "version": "0.4.3",
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.44.0",
36
+ "fedipod": "^1.50.2",
31
37
  "fediverse-session": "^0.2.1"
32
38
  }
33
39
  }
package/site/bb.js CHANGED
@@ -10,8 +10,8 @@
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';
14
- import { MastoLogin, hostOfHandle, serverKind, actorOfHandle } from './masto.mjs';
13
+ import { reader, placeOf, authorLabel, cacheKey, categoryBase, cleanHtml } from './read.mjs';
14
+ import { MastoLogin, hostOfHandle, serverKind, actorOfHandle, returnAddress } from './masto.mjs';
15
15
  import * as pod from './pod.mjs';
16
16
  import { mine as keptByReader } from './mine.mjs';
17
17
  import { readState } from './seen.mjs';
@@ -47,7 +47,7 @@ let freshRead = false;
47
47
  const readerFetch = (url, init = {}) => fetch(url, freshRead ? { ...init, cache: 'reload' } : init);
48
48
  const makeReader = (session = null) => reader({ session, fetch: readerFetch });
49
49
  let read = makeReader();
50
- const login = new MastoLogin({ storage: localStorage, redirectUri: location.origin + location.pathname + location.search });
50
+ const login = new MastoLogin({ storage: localStorage, redirectUri: returnAddress(location.href) });
51
51
  // What this reader has already read. Theirs, in their own browser: a public
52
52
  // forum has nowhere to keep it and no business keeping it.
53
53
  const seen = base ? readState({ storage: localStorage, forum: base }) : null;
@@ -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 || ''}</div></article></li>`);
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 || ''}</div></article></li>`);
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/masto.mjs CHANGED
@@ -54,6 +54,15 @@ export async function serverKind(host, front, f = globalThis.fetch.bind(globalTh
54
54
  } catch { return { kind: 'unknown', host }; }
55
55
  }
56
56
 
57
+ // Where Mastodon sends the reader back: this page, without the sign-in's own
58
+ // code and state. Mastodon hands out the key only for the address it approved,
59
+ // and the page comes back with those two added, so both legs name it this way.
60
+ export function returnAddress(href) {
61
+ const u = new URL(href);
62
+ for (const k of ['code', 'state', 'iss']) u.searchParams.delete(k);
63
+ return u.origin + u.pathname + u.search;
64
+ }
65
+
57
66
  export class MastoLogin {
58
67
  // `storage` is localStorage-shaped; `fetch` is the page's.
59
68
  constructor({ fetch: f = globalThis.fetch.bind(globalThis), storage, redirectUri }) {
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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: typeof copy.content === 'string' ? copy.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: typeof copy.content === 'string' ? copy.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);