fedipod 1.48.0 → 1.49.0

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
@@ -2,63 +2,36 @@
2
2
 
3
3
  - access the Fediverse from a Solid pod
4
4
 
5
- FediPod gives you a Fediverse account whose data lives on a Solid pod. You
6
- follow people on Mastodon, Bluesky, and other Fediverse or ATProto servers in
7
- one timeline. Your posts, followers and settings are kept on your pod. While fedipod.net
8
- keeps your account running, it works from a copy of them, and every fifteen
9
- minutes it writes that copy to your pod and deletes it; your signing key never
10
- leaves your pod.
5
+ FediPod gives you a Fediverse account whose data lives on a Solid pod, and you
6
+ use it with any Fediverse client. You can follow people on Mastodon, Bluesky, and other Fediverse or ATProto servers in one timeline.
11
7
 
12
8
  The easiest way to run FediPod is to use it in any browser at https://fedipod.net. Nothing to install. Sign-up points you to a pod provider if you need a pod, then attaches a Fediverse identity to the pod you sign in with.
13
9
 
14
-
15
- This README is about the `FediPod BrowserAgent`. There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipod) which offer a variety of scenarios. If interested in the code, see also : [architecture overview](architecture.md) and [files overview](files-overview.md). Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-in-use.md).
10
+ This README is about the `FediPod` as used with a Gateway. There are also a number of [other ways to run FediPod](#other-ways-to-run-fedipod) which offer a variety of scenarios. If interested in the code, see also : [architecture overview](architecture.md) and [files overview](files-overview.md). Which specs FediPod follows, and where it stops short: [specs-in-use.md](specs-in-use.md).
16
11
 
17
12
  ## Requirements
18
13
 
19
14
  - A current browser
20
15
  - A free account on a gateway such as https://fedipod.net
21
- - A Solid pod that supports WAC (you can get one if you don't have one as part of account setup)
16
+ - A Solid pod (you can get one if you don't have one as part of account setup)
22
17
 
23
18
  ## Getting an account
24
19
 
25
20
  1. Open https://fedipod.net and choose **create an account**.
26
21
  2. Choose your pod provider. You'll be redirected to your provider to login if you have a pod or sign up if you need to create one.
27
22
  3. Back on fedipod.net, choose your handle and where your address lives:
28
- on your pod, `@handle@yourpod`, or at this site, `@handle@fedipod.net`.
23
+ on your pod, `@handle@yourpod`, or at the gateway, `@handle@fedipod.net`.
29
24
  Choose where on your pod your data goes: a container named `fedipod`,
30
25
  inside whichever container you name (your pod's root if you leave it
31
- empty). If your pod has no public type index yet, you're asked whether
32
- to create one; FediPod records there where your account lives, and
33
- nothing is set up without it.
26
+ empty).
34
27
  4. You see your Fediverse timeline.
35
28
 
36
- From now on, anyone can open your profile at `https://fedipod.net/@handle@provider`, signed in or not.
37
-
38
- ## Getting a forum
39
-
40
- The same page makes a forum. Choose **A forum** as the first thing on it. A
41
- forum has a pod of its own, so make a new pod for it at your provider (or
42
- sign in with one that holds nothing else), then give the forum a handle, a
43
- name and its categories, and the address at fedipod.net you will moderate it
44
- as. fedipod.net runs the forum from then on; nothing runs on your machine.
45
- Its page is at `https://bb.fedipod.net/<handle>/` and its address is
46
- `@handle@fedipod.net`, with one address per category.
47
-
48
29
  ## What you can do
49
30
 
50
- There are two clients, both served by fedipod.net:
51
- [Sengi](https://github.com/NicolasConstant/sengi) (MIT, by Nicolas Constant),
52
- which you get by default, and [Phanpy](https://github.com/cheeaun/phanpy)
53
- (MIT, by Chee Aun). The links at the top right of every page switch between
31
+ You can access your account by pointing any Mastodon API or C2S client at fedipod.net and signing in. There are also two built-inclients, both served by fedipod.net: [Sengi](https://github.com/NicolasConstant/sengi) (MIT, by Nicolas Constant), which you get by default, and [Phanpy](https://github.com/cheeaun/phanpy)(MIT, by Chee Aun). The links at the top right of every page switch between
54
32
  them, at any time; whichever you opened last is the one that opens next time.
55
33
 
56
- You follow and unfollow, post, reply,
57
- favourite, boost, attach media, edit and delete. Content warnings, polls with
58
- up to four options and one or several answers, all four visibility levels, a
59
- conversations view for direct messages, bookmarks, lists, keyword filters,
60
- pinned posts that other servers can see, blocking and muting, and the custom
61
- emojis other servers send.
34
+ You can follow and unfollow, post, reply, favourite, boost, attach media, edit and delete. Content warnings, polls with up to four options and one or several answers, all four visibility levels, a conversations view for direct messages, bookmarks, lists, keyword filters, pinned posts that other servers can see, blocking and muting, and the custom emojis other servers send.
62
35
 
63
36
  A post can quote another post. Quote is offered on posts whose author allows
64
37
  it; the quoted author's server is asked, and the quote shows as pending until
@@ -108,50 +81,18 @@ the DeviceAgent has, described in [the admin interface](gui.md).
108
81
  it in a second browser shows your timeline read-only, and the moment you act
109
82
  there it takes over; the first drops back to reading.
110
83
 
111
- ## What the browser version does not do
112
-
113
- - **Live updates.** The client refreshes by polling.
114
- - **Hosting a group.** Joining one works.
115
-
116
- For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
117
-
118
- ## Your data and your keys
119
-
120
- Everything you publish and everything you read is stored on your pod. Your
121
- signing key is stored there too, in a container only you can read through
122
- your pod's login.
123
- While fedipod.net keeps your account running, its own pod identity can read
124
- that container too, so it can act for you while FediPod is closed. It works
125
- from a copy of your account's data, and every fifteen minutes it writes that
126
- copy to your pod and deletes it, so none of your data stays at fedipod.net
127
- longer than that. The next time anything needs your account, the copy is made
128
- again from your pod. Your key stays on your pod only.
129
- It all lives in the `fedipod` container you chose at sign-up. Your pod's
130
- public type index records it, as an ActivityStreams actor, which is how
131
- FediPod and other Solid apps find your account again.
132
- fedipod.net holds no key: it verifies incoming mail, drops
133
- the junk, forwards the rest to your pod, and hands your browser the app.
134
- With an address on your pod, `@handle@yourpod`, you can detach from it at
135
- any time and attach to a gateway of your own; your address and your data do
136
- not change.
137
-
138
84
  ## Other ways to use FediPod
139
85
 
140
86
  - [fediverse-session](https://github.com/jeff-zucker/fediverse-session) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
141
87
  - [FediPod Server](https://github.com/jeff-zucker/fedipod-server): a full ActivityPub
142
88
  server as a Community Solid Server component, giving every pod on the server
143
- the option of a Fediverse account.
89
+ the option of a Fediverse account. See this link for how to host your own Solid-ActivityPub server.
144
90
  - [FediPod BB](https://github.com/jeff-zucker/fedipod-bb) - A Lemmy-like bulletin board forum with the structure stored on a group pod and users' data stored on their own pod or on their Fediverse server if they don't have a pod.
91
+ <!--
145
92
  - [FediPod DeviceAgent](device-agent.md) a single-user AP server that lives partly in a gateway, partly on a local device, with all data stored on your pod.
93
+ -->
146
94
  - [FediPod Groups](groups.md): hosting a discussion group of Fediverse and Bluesky users from a pod.
147
- - [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. (only needed with `BrowserAgent` and `DeviceAgent`)
148
-
149
- There are two ways to run FediPod: in a browser at a Gateway such as
150
- fedipod.net, with nothing installed, or as a Gateway of your own, deployed
151
- from this repository ([netlify/README.md](netlify/README.md)). Cloning the
152
- repository and running it is not one of them: `npm start` only says so. The
153
- DeviceAgent is deprecated; nothing new is built for it.
154
-
95
+ - [FediPod Gateway](gateway.md): A thin, always-on door running on Netlify or any small host that provides a middleman between a pod and the single-user agents. See this link for how to host your own gateway.
155
96
 
156
97
  ## Acknowledgements
157
98
 
package/gateway.md CHANGED
@@ -242,9 +242,9 @@ from an app.
242
242
 
243
243
  **Notifications on your phone.** An app that offers notifications can sign up
244
244
  for them here. A mention, a reply, a direct message, a follow, or a like, boost
245
- or reaction to your own post then reaches your phone as it arrives, while
246
- FediPod and the app are closed. While FediPod is open in a browser, it reads
247
- your mail itself, and those arrivals are not pushed.
245
+ or reaction to your own post then reaches your phone as it arrives, whether
246
+ FediPod is open in a browser or closed. Notifications from a connected Bluesky
247
+ or Mastodon account are not pushed.
248
248
 
249
249
  ## Accounts that go quiet
250
250
 
package/gui.md CHANGED
@@ -19,7 +19,8 @@ The software row names the version the agent is running. When the copy on the ma
19
19
  old handle keeps working as a redirect, and the identity is parked
20
20
  afterwards.
21
21
  * **Retire identity** is permanent — every follower's server is told to drop
22
- the account, and the identity does not come back.
22
+ the account, your WebID profile and public type index stop naming it, and
23
+ the identity does not come back.
23
24
  * In the **Upkeep** group: **Drain the inbox** fetches and handles everything
24
25
  waiting now, and **Show dead letters** lists deliveries that failed for
25
26
  good and will not be retried.
@@ -8,6 +8,16 @@ import * as podMedia from '../../pod/media.mjs';
8
8
  import * as social from '../../core/social.mjs';
9
9
  import { readBody, readMultipart } from './body.mjs';
10
10
 
11
+ // What a profile picture or header may be: an image of a kind every client
12
+ // shows, stored as that type and named by it, never by the client's filename.
13
+ // Anything else, a web page above all, would be served from the pod's own
14
+ // address and run there when opened.
15
+ const PROFILE_IMAGE_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'image/avif': 'avif' };
16
+ const profileImageType = (f) => {
17
+ const t = String(f?.contentType || '').split(';')[0].trim().toLowerCase();
18
+ return PROFILE_IMAGE_EXT[t] ? t : null;
19
+ };
20
+
11
21
  export async function handle(api, ctx) {
12
22
  const { req, res, pathname, url, send } = ctx; // eslint-disable-line no-unused-vars
13
23
 
@@ -38,13 +48,17 @@ export async function handle(api, ctx) {
38
48
  else form = await readBody(req);
39
49
 
40
50
  const cfg = { ...api.store.getConfig() };
51
+ for (const f of [ files.avatar, files.header ]) {
52
+ if (f?.data?.length && !profileImageType(f)) {
53
+ return send(422, { error: 'a profile picture or header must be a PNG, JPEG, GIF, WebP or AVIF image' });
54
+ }
55
+ }
41
56
  const putImage = async (f) => {
42
- const ext = (f.filename || '').includes('.')
43
- ? f.filename.split('.').pop().replace(/[^\w]/g, '') : 'bin';
44
- const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + ext;
57
+ const type = profileImageType(f);
58
+ const slug = new Date().toISOString().slice(0, 10) + '-' + crypto.randomBytes(4).toString('hex') + '.' + PROFILE_IMAGE_EXT[type];
45
59
  const url = api.urls.media + slug;
46
60
  await api.agent.publisher.ensureMediaContainer();
47
- await podMedia.write(api.agent.remote, url, f.data, f.contentType);
61
+ await podMedia.write(api.agent.remote, url, f.data, type);
48
62
  return url;
49
63
  };
50
64
 
@@ -84,6 +84,7 @@ export class MastoApi {
84
84
  store: this.store,
85
85
  subject: () => this.urls?.actor || 'https://localhost/',
86
86
  log: this.log,
87
+ liveKeys: () => new Set(this.tokens().map((t) => this._push.keyOf(t))),
87
88
  });
88
89
  return this._push;
89
90
  }
@@ -12,6 +12,10 @@ const AUTHZ_WINDOW_MS = 60_000;
12
12
  const AUTHZ_MAX_ATTEMPTS = 5;
13
13
  const CODE_TTL_MS = 5 * 60_000; // an authorization code is short-lived
14
14
  const MAX_APPS = 200; // registered third-party clients, capped
15
+ const MAX_REDIRECTS = 10; // return addresses one client may register
16
+ const MAX_REDIRECT_CHARS = 2048;
17
+ const REGISTRATION_WINDOW_MS = 60_000;
18
+ const MAX_REGISTRATIONS = 10; // registrations per window, from anyone
15
19
  const CLIENT_DOC_TTL_MS = 10 * 60_000; // how long a fetched client document is trusted
16
20
  const CLIENT_DOC_MAX_CACHED = 200; // ids remembered at once; oldest out
17
21
  const CLIENT_DOC_WINDOW_MS = 60_000;
@@ -48,12 +52,14 @@ const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+
48
52
  // fediverse-session library, and proves that sign-in back to /oauth/authorize.
49
53
  function sendPodSigninPage(res, client, webId, mount = '') {
50
54
  let asking = '';
55
+ let allow = 'Allow';
51
56
  if (client && (client.name || client.redirect)) {
52
57
  let where = '';
53
58
  try { where = client.redirect ? new URL(client.redirect).host : ''; } catch { /* oob or blank */ }
54
59
  const who = client.name ? escapeHtml(client.name) : (where ? escapeHtml(where) : 'A client');
55
60
  asking = `<p><strong>${who}</strong> is asking to use your account`
56
61
  + `${where ? `, and you will be sent back to <code>${escapeHtml(where)}</code>` : ''}.</p>`;
62
+ allow = `Allow ${who}`;
57
63
  }
58
64
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' });
59
65
  res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
@@ -64,13 +70,14 @@ h1{color:var(--heading);font-size:1.5rem}
64
70
  code{word-break:break-all;background:rgba(120,120,120,.12);padding:.05em .35em;border-radius:4px}
65
71
  button{font:inherit;padding:.6rem 1.1rem;background:var(--btn);color:#fff;border:none;border-radius:9px;cursor:pointer}
66
72
  button[hidden]{display:none}
73
+ #cancel{background:transparent;color:inherit;border:2px solid currentColor;margin-left:.6rem}
67
74
  #signin-status{min-height:1.5em;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;margin-top:1rem}
68
- @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#5a8a4f}}</style></head><body>
75
+ @media (prefers-color-scheme:dark){:root{--heading:#7fb3e8;--btn:#4a7a40}}</style></head><body>
69
76
  <main>
70
77
  <h1>Sign in with your pod</h1>
71
78
  ${asking}
72
79
  <p>This account belongs to <a id="webid" href="${escapeHtml(webId)}">${escapeHtml(webId)}</a>. Sign in at that pod to allow it.</p>
73
- <p><button type="button" id="signin" hidden>Sign in with your pod</button></p>
80
+ <p><button type="button" id="signin" hidden>${allow}</button><button type="button" id="cancel" hidden>Cancel</button></p>
74
81
  <p id="signin-status" role="status" aria-live="polite"></p>
75
82
  </main>
76
83
  <script type="module" src="${escapeHtml(mount)}/oauth/session/signin.mjs"></script>
@@ -324,12 +331,53 @@ export function registerApp(api, { name, website, redirectUris, scopes }) {
324
331
  clientSecret: crypto.randomBytes(32).toString('base64url'),
325
332
  name: String(name || 'client').slice(0, 200),
326
333
  website: String(website || '').slice(0, 500),
327
- redirectUris, scopes: String(scopes || 'read'), createdAt: Date.now(),
334
+ redirectUris, scopes: String(scopes || 'read').slice(0, 500), createdAt: Date.now(),
335
+ // Set when the owner first lets it sign in (touchApp): a client nobody
336
+ // has used is the first to go when the list is full.
337
+ usedAt: null,
328
338
  };
329
- api.store.write('oauth-apps.json', [...api.apps(), app].slice(-MAX_APPS));
339
+ const kept = api.apps();
340
+ while (kept.length >= MAX_APPS) kept.splice(kept.indexOf(leastUsed(kept)), 1);
341
+ api.store.write('oauth-apps.json', [...kept, app]);
330
342
  return app;
331
343
  }
332
344
 
345
+ // The client to push out of a full list: one nobody has signed in with,
346
+ // oldest first; failing that, the one used longest ago. A client registered
347
+ // before `usedAt` existed counts as used when it was made, so a flood of new
348
+ // registrations cannot push out the apps the owner already has.
349
+ function leastUsed(list) {
350
+ const never = list.filter((a) => a.usedAt === null);
351
+ const when = (a) => a.usedAt ?? a.createdAt ?? 0;
352
+ return (never.length ? never : list).reduce((a, b) => (when(b) < when(a) ? b : a));
353
+ }
354
+
355
+ export function touchApp(api, clientId) {
356
+ const all = api.apps();
357
+ const app = all.find((a) => a.clientId === clientId);
358
+ if (!app) return;
359
+ app.usedAt = Date.now();
360
+ api.store.write('oauth-apps.json', all);
361
+ }
362
+
363
+ // Why a registration's return addresses cannot be kept, or null. Registering
364
+ // needs no sign-in, so its size is the part a stranger controls.
365
+ export function redirectsProblem(raw) {
366
+ if (Array.isArray(raw) && raw.some((v) => typeof v !== 'string')) return 'redirect_uris must be text';
367
+ const list = parseRedirects(raw);
368
+ if (list.length > MAX_REDIRECTS) return `at most ${MAX_REDIRECTS} redirect_uris`;
369
+ if (list.some((u) => u.length > MAX_REDIRECT_CHARS)) return `a redirect_uri is at most ${MAX_REDIRECT_CHARS} characters`;
370
+ return null;
371
+ }
372
+
373
+ export function registrationLimited(api) {
374
+ const now = Date.now();
375
+ api.appRegistrations = (api.appRegistrations || []).filter((t) => now - t < REGISTRATION_WINDOW_MS);
376
+ if (api.appRegistrations.length >= MAX_REGISTRATIONS) return true;
377
+ api.appRegistrations.push(now);
378
+ return false;
379
+ }
380
+
333
381
  // A short-lived, single-use authorization code for a registered client, kept
334
382
  // apart from masto-tokens.json so the code is NOT a bearer until it is
335
383
  // exchanged with the client secret.
@@ -398,6 +446,13 @@ export async function handle(api, ctx) {
398
446
  // authorize step becomes a real login form.
399
447
  if (pathname === '/api/v1/apps' && req.method === 'POST') {
400
448
  const body = await readBody(req);
449
+ const problem = redirectsProblem(body.redirect_uris);
450
+ if (problem) return send(422, { error: problem });
451
+ if (registrationLimited(api)) {
452
+ api.log('app registration refused: too many this minute');
453
+ return send(429, { error: 'too many apps registered just now — try again in a minute' },
454
+ { 'retry-after': String(REGISTRATION_WINDOW_MS / 1000) });
455
+ }
401
456
  const redirectUris = parseRedirects(body.redirect_uris);
402
457
  const app = api.registerApp({ name: body.client_name, website: body.website,
403
458
  redirectUris, scopes: body.scopes });
@@ -543,6 +598,7 @@ export async function handle(api, ctx) {
543
598
  challenge: params.get('code_challenge') || null,
544
599
  challengeMethod: params.get('code_challenge_method') || null })
545
600
  : api.mintToken(client.scope);
601
+ if (app) touchApp(api, app.clientId);
546
602
  if (!redirect || redirect === 'urn:ietf:wg:oauth:2.0:oob') return send(200, { code });
547
603
  const target = new URL(redirect);
548
604
  target.searchParams.set('code', code);
@@ -647,6 +703,8 @@ export async function handle(api, ctx) {
647
703
  if (gone) {
648
704
  const kept = api.tokenRecords().filter(r => r.token !== gone);
649
705
  api.store.write('masto-tokens.json', kept);
706
+ // Its notifications stop with it.
707
+ if (api.push.get(gone)) api.push.drop(gone);
650
708
  api.log('client token revoked');
651
709
  }
652
710
  return send(200, {});
@@ -8,6 +8,9 @@
8
8
  // The verifier is injected so offline tests stub it, and wrapped so the
9
9
  // library (CJS, older jose) can be replaced without touching any caller.
10
10
 
11
+ import { scopeAllows } from './masto/oauth.mjs';
12
+ import { claimedWebId } from '../gateway/token-claims.mjs';
13
+
11
14
  export function makeC2sAuth({ agent, masto = null, verifier = null, log = () => {}, scheme = null, mount = '' }) {
12
15
  let verify = verifier;
13
16
  const loadVerifier = async () => {
@@ -19,7 +22,14 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
19
22
  };
20
23
 
21
24
  return async function authenticate(req, pathname) {
22
- if (masto?.authed(req)) {
25
+ const bearer = masto?.tokenOf(req);
26
+ if (bearer) {
27
+ // The same permission the Mastodon routes ask of this token: a client
28
+ // allowed only to read does not post here either.
29
+ const need = req.method === 'GET' || req.method === 'HEAD' ? 'read' : 'write';
30
+ if (!scopeAllows(bearer.scope, need)) {
31
+ return { ok: false, status: 403, error: `this app was not allowed to ${need === 'read' ? 'read' : 'post'} for the account` };
32
+ }
23
33
  return { ok: true, webid: agent.remote?.webId || null, via: 'bearer' };
24
34
  }
25
35
  if (!req.headers.authorization) {
@@ -32,6 +42,12 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
32
42
  return { ok: false, status: 401, error: 'a Solid-OIDC token must come with its DPoP proof',
33
43
  headers: { 'www-authenticate': 'DPoP' } };
34
44
  }
45
+ // A token naming anyone but the owner is refused before it is checked:
46
+ // checking fetches the documents it names, and a stranger names them.
47
+ const claimed = claimedWebId({ headers: { get: (n) => req.headers[n] ?? null } });
48
+ if (claimed && claimed !== agent.remote?.webId) {
49
+ return { ok: false, status: 403, error: 'this account belongs to its owner alone' };
50
+ }
35
51
  let webid;
36
52
  try {
37
53
  const v = await loadVerifier();
@@ -48,8 +64,10 @@ export function makeC2sAuth({ agent, masto = null, verifier = null, log = () =>
48
64
  { header: req.headers.dpop, method: req.method, url: htu },
49
65
  ));
50
66
  } catch (e) {
67
+ // The reason stays here: it can carry what was read from an address
68
+ // the token named.
51
69
  log(`c2s auth: token rejected — ${e.message}`);
52
- return { ok: false, status: 401, error: `token rejected: ${e.message}` };
70
+ return { ok: false, status: 401, error: 'token rejected' };
53
71
  }
54
72
  const owner = agent.remote?.webId;
55
73
  if (!owner || webid !== owner) {
@@ -7,10 +7,13 @@ import crypto from 'node:crypto';
7
7
  import webpush from 'web-push';
8
8
 
9
9
  export class Push {
10
- constructor({ store, subject, log = () => {} }) {
10
+ constructor({ store, subject, log = () => {}, liveKeys = null }) {
11
11
  this.store = store;
12
12
  this.subject = subject; // () => the actor URL, VAPID's contact
13
13
  this.log = log;
14
+ // () => the keys of the sign-ins still in force, where they are kept beside
15
+ // the subscriptions; without it no subscription is forgotten for its sign-in.
16
+ this.liveKeys = liveKeys;
14
17
  }
15
18
 
16
19
  state() { return this.store.read('webpush.json', { subs: {} }); }
@@ -68,14 +71,21 @@ export class Push {
68
71
 
69
72
  // Push one notification to every subscription whose alerts allow the type.
70
73
  // A push service answering 404/410 means the browser dropped the
71
- // subscription — it is removed rather than retried forever.
74
+ // subscription — it is removed rather than retried forever. With
75
+ // `liveKeys`, every subscription whose sign-in is no longer in force is
76
+ // forgotten first: an app signed out, expired or pushed off the list hears
77
+ // nothing more.
72
78
  async notify(n, payload) {
73
79
  const st = this.state();
80
+ let dropped = false;
81
+ const live = this.liveKeys?.() || null;
82
+ if (live) {
83
+ for (const key of Object.keys(st.subs)) if (!live.has(key)) { delete st.subs[key]; dropped = true; }
84
+ }
74
85
  const entries = Object.entries(st.subs);
75
- if (!entries.length) return;
86
+ if (!entries.length) { if (dropped) this.save(st); return; }
76
87
  const { publicKey, privateKey } = this.vapid();
77
88
  const details = { subject: this.subject(), publicKey, privateKey };
78
- let dropped = false;
79
89
  for (const [key, sub] of entries) {
80
90
  if (sub.alerts && sub.alerts[n.type] === false) continue;
81
91
  try {
@@ -52,6 +52,16 @@ export async function recordPlace(pod, podBase, actorAtPod, { create = false } =
52
52
  return (await typeIndex.register(pod, index, actorAtPod)) ? 'registered' : 'already';
53
53
  }
54
54
 
55
+ /**
56
+ * Take the account's place out of the owner's public type index, for an
57
+ * account retired for good. Returns 'removed', 'absent' or 'no-index'.
58
+ */
59
+ export async function forgetPlace(pod, podBase, actorAtPod) {
60
+ const index = await typeIndex.findPublicIndex(pod, podBase);
61
+ if (!index) return 'no-index';
62
+ return (await typeIndex.unregister(pod, index, actorAtPod)) ? 'removed' : 'absent';
63
+ }
64
+
55
65
  /** Whether this person's profile names a public type index. */
56
66
  export async function hasPublicIndex(pod, podBase) {
57
67
  return !!(await typeIndex.findPublicIndex(pod, podBase).catch(() => null));
@@ -26,7 +26,7 @@ import * as restore from './restore.mjs';
26
26
  import * as notes from './notes.mjs';
27
27
  import * as questions from './questions.mjs';
28
28
  import * as own from './own.mjs';
29
- import { recordPlace } from '../place.mjs';
29
+ import { recordPlace, forgetPlace } from '../place.mjs';
30
30
 
31
31
  // The version is read where a bundled deploy (the Netlify functions) cannot
32
32
  // break it: a package.json that is not beside the bundle is not a reason for
@@ -212,7 +212,7 @@ export class Publisher {
212
212
  // writes none — the forum's own actor, or a Gateway, answers for it.
213
213
  if (!this.config.forum) {
214
214
  await discovery.writeWebfinger(this.remote, urls,
215
- wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml }));
215
+ wire.jrd({ handle: this.config.handle, host, actor: urls.actor, page: urls.profileHtml, avatar: this.config.icon || null }));
216
216
  await discovery.writeHostMeta(this.remote, urls, wire.hostMeta(urls.base));
217
217
 
218
218
  const nodeinfoDocUrl = urls.home + 'ap/nodeinfo-2.0';
@@ -393,6 +393,18 @@ export class Publisher {
393
393
  await podActor.writeTombstone(this.remote, urls, wire.tombstoneDoc(urls, deletedAt, this.config.kind));
394
394
  this.store.setConfig({ ...this.store.getConfig(), retiredAt: deletedAt });
395
395
  await this.store.flush();
396
+ // The owner's own documents stop pointing at it: the WebID profile's
397
+ // account and outbox links, and its place in the public type index.
398
+ // Best-effort, as writing them was.
399
+ if (!this.config.forum) {
400
+ await podActor.unlinkInWebIdProfile(this.remote, {
401
+ actorUrl: urls.actor, outbox: this.clientOrigin ? `${this.clientOrigin}ap/outbox` : this.gatewayOutbox(),
402
+ }).then((n) => { if (n) this.log('the WebID profile no longer lists the actor'); })
403
+ .catch((e) => this.log(`the WebID profile still lists the actor: ${e.message}`));
404
+ await forgetPlace(this.remote, urls.base, urls.home + 'ap/actor')
405
+ .then((r) => { if (r === 'removed') this.log('the public type index no longer records the account'); })
406
+ .catch((e) => this.log(`the type index still records the account: ${e.message}`));
407
+ }
396
408
  this.log(`retired: Delete sent to ${inboxes.length} inbox(es), actor replaced with a Tombstone`);
397
409
  return { inboxes: inboxes.length, deletedAt };
398
410
  }
@@ -185,9 +185,13 @@ export class PodStore {
185
185
  // Read these documents again, whatever is held: a kept copy caught them
186
186
  // mid-write, so the pod's copy is the one to hold. Throws when one cannot be
187
187
  // read, so the caller can fall back to a full load rather than act without it.
188
- async refresh(names) {
188
+ // `ifChanged`: a document held here is asked for only if it changed since — a
189
+ // pod's listing does not change when a document in it is rewritten.
190
+ async refresh(names, { ifChanged = false } = {}) {
189
191
  for (const name of names) {
190
- const r = await this.storage.read(name);
192
+ const etag = ifChanged && this.cache.has(name) ? this.etags.get(name) : null;
193
+ const r = await this.storage.read(name, etag ? { etag } : undefined);
194
+ if (r.notModified) continue;
191
195
  if (r.status === 404) {
192
196
  this.cache.delete(name); this.lastText.delete(name); this.etags.delete(name);
193
197
  } else if (!r.ok) {
package/lib/core/wire.mjs CHANGED
@@ -56,13 +56,15 @@ export function hostMeta(base) {
56
56
  return `<?xml version="1.0" encoding="UTF-8"?>\n<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n <Link rel="lrdd" template="${base}.well-known/webfinger?resource={uri}"/>\n</XRD>\n`;
57
57
  }
58
58
 
59
- export function jrd({ handle, host, actor, aliases = [], page = null }) {
59
+ export function jrd({ handle, host, actor, aliases = [], page = null, avatar = null }) {
60
60
  return {
61
61
  subject: `acct:${handle}@${host}`,
62
62
  ...(aliases.length ? { aliases } : {}),
63
63
  links: [
64
64
  { rel: 'self', type: 'application/activity+json', href: actor },
65
65
  ...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
66
+ // The account's picture, as Mastodon's answer names it.
67
+ ...(avatar ? [{ rel: 'http://webfinger.net/rel/avatar', href: avatar }] : []),
66
68
  ],
67
69
  };
68
70
  }