fedipod 1.47.0 → 1.48.1

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
- - [fediverse-session](lib/session/README.md) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
141
- - [FediPod Server](packages/fedipod-server/README.md): a full ActivityPub
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.
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.
144
- - [FediPod BB](packages/fedipod-bb/README.md) - 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.
89
+ the option of a Fediverse account. See this link for how to host your own Solid-ActivityPub server.
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/architecture.md CHANGED
@@ -27,7 +27,7 @@ hold). A gateway's admin may turn the hold off: then there is no copy, and
27
27
  everything works on the pod, the browser and the gateway taking turns by the
28
28
  pod's lease. See [the gateway](gateway.md).
29
29
 
30
- [FediPod Server](packages/fedipod-server/README.md) puts the agent inside a
30
+ [FediPod Server](https://github.com/jeff-zucker/fedipod-server) puts the agent inside a
31
31
  Community Solid Server, so anyone with a pod on that server can opt in to a
32
32
  Fediverse account fed by the server itself.
33
33
 
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
 
@@ -308,7 +308,7 @@ Two ways are ready to use:
308
308
  [netlify/README.md](netlify/README.md) for the deployment specifics.
309
309
  - **On any box of your own**, with your own adapter around the same core.
310
310
  - **Inside a Community Solid Server**, as the same door run in-process by the
311
- CSS component — see [packages/fedipod-server](packages/fedipod-server/README.md).
311
+ CSS component — see [fedipod-server](https://github.com/jeff-zucker/fedipod-server).
312
312
 
313
313
  ### Offering accounts to other people
314
314
 
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.
@@ -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
  }
@@ -23,7 +23,7 @@ import * as podRoot from '../pod/root.mjs';
23
23
  import * as podPolicy from '../pod/policy.mjs';
24
24
  import { podBaseOfWebId } from '../pod/urls.mjs';
25
25
  import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
26
- import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi } from './held-mail.mjs';
26
+ import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi } from './held-mail.mjs';
27
27
  import { routeNoticesApi } from './notices.mjs';
28
28
  import { withSecurityHeaders } from './headers.mjs';
29
29
  import { podTokenVerifier } from './caches.mjs';
@@ -35,16 +35,15 @@ import { announced, overLimit, relayOne } from './relay-extras.mjs';
35
35
  // wire.mjs: wire drags the agent's whole HTML pipeline (sanitize-html and
36
36
  // friends), which a serverless front must never carry — it crashed the
37
37
  // deployed function before it answered its first request.
38
- const jrd = ({ handle, host, actor, aliases = [], page = null, subscribe = null }) => ({
38
+ const jrd = ({ handle, host, actor, aliases = [], page = null, subscribe = null, avatar = null }) => ({
39
39
  subject: `acct:${handle}@${host}`,
40
40
  ...(aliases.length ? { aliases } : {}),
41
41
  links: [
42
42
  { rel: 'self', type: 'application/activity+json', href: actor },
43
43
  ...(page ? [{ rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: page }] : []),
44
- // Remote follow. Somebody reading this account on another server presses
45
- // Follow, says they are here, and that server looks for this link to find
46
- // out where to send them. `{uri}` is a literal the other server fills in
47
- // with whatever they are following — it must not be escaped.
44
+ ...(avatar ? [{ rel: 'http://webfinger.net/rel/avatar', href: avatar }] : []), // the account's picture
45
+ // Remote follow: another server sends its reader to this template to follow
46
+ // from there. `{uri}` is a literal that server fills in; never escape it.
48
47
  ...(subscribe ? [{ rel: 'http://ostatus.org/schema/1.0/subscribe', template: subscribe }] : []),
49
48
  ],
50
49
  });
@@ -585,9 +584,9 @@ async function route(request, ctx) {
585
584
  // The owner's say over an account that goes quiet (quiet.mjs).
586
585
  const quiet = await routeQuietApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight });
587
586
  if (quiet) return withApiCors(quiet);
588
- // The owner's app saying it is open, and taking what was held (held-mail.mjs).
589
- const here = await routeHereApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner })
590
- || await routeKeeperApi(request, pathname, ctx, { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner });
587
+ // The owner's app saying it is open, taking what was held, and naming what to push (held-mail.mjs).
588
+ const owned = { j, verifyPodToken, webidUnderPod, apiPreflight, provedOwner };
589
+ const here = await routeHereApi(request, pathname, ctx, owned) || await routeKeeperApi(request, pathname, ctx, owned) || await routePushApi(request, pathname, ctx, owned);
591
590
  if (here) return withApiCors(here);
592
591
 
593
592
  // The relay: the front sends requests a browser has already signed. A page
@@ -758,7 +757,7 @@ async function route(request, ctx) {
758
757
  // the pod (and its login) without a lookup only the host could answer.
759
758
  const podActor = rec.inboxOnly ? [] : [rec.podHome + 'ap/actor'];
760
759
  const out = j(200, jrd({ handle: m[1], host: ctx.host, actor: rec.actorUrl, aliases: podActor,
761
- page: profilePageOf(rec), subscribe: subscribeTemplate(ctx.frontOrigin) }),
760
+ page: profilePageOf(rec), subscribe: subscribeTemplate(ctx.frontOrigin), avatar: rec.icon || null }),
762
761
  'application/jrd+json');
763
762
  // RFC 7033: a JRD is read from any origin.
764
763
  out.headers['access-control-allow-origin'] = '*';
@@ -213,3 +213,26 @@ export async function routeHereApi(request, pathname, ctx, deps) {
213
213
  // `hold`: with it off, a browser working from a copy goes back to the pod.
214
214
  return j(200, { ok: true, handle, flushed, hold: holdOn(ctx) });
215
215
  }
216
+
217
+ // FediPod open in a browser reads its own mail, so nothing is held for the
218
+ // door to push: it names the notifications it made, and they are pushed to the
219
+ // phones and browsers its owner signed up (masto-gateway.mjs: pushMade).
220
+ // Proved like /api/here. Returns a response, or null for other paths.
221
+ const NOTIFICATION_ID = /^[0-9a-f]{16}$/u;
222
+ export async function routePushApi(request, pathname, ctx, deps) {
223
+ if (pathname !== '/api/push') return null;
224
+ const { j } = deps;
225
+ if (request.method === 'OPTIONS') return deps.apiPreflight();
226
+ if (request.method !== 'POST') return null;
227
+ let body;
228
+ try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
229
+ const handle = String(body.handle || '').toLowerCase();
230
+ const rec = await ctx.lookup(handle);
231
+ if (!rec) return j(404, { error: 'no such account' });
232
+ const who = await deps.provedOwner(request, pathname, ctx, rec, deps);
233
+ if (who.error) return who.error;
234
+ const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).filter((id) => NOTIFICATION_ID.test(String(id))))].slice(0, 50);
235
+ if (!ids.length || !ctx.startPush || !await ctx.pushWanted?.(handle).catch(() => false)) return j(200, { ok: true, handle, pushing: 0 });
236
+ await ctx.startPush(handle, { ids }).catch((e) => console.log(`push @${handle}: not started: ${e?.message || e}`));
237
+ return j(200, { ok: true, handle, pushing: ids.length });
238
+ }
@@ -29,7 +29,10 @@
29
29
  // keeps for all its accounts, made the first time it is needed. When the door
30
30
  // holds a delivery that becomes a notification (gateway-core: notifies), it
31
31
  // starts a short run (push-background.mjs, pushHeld below) that reads the held
32
- // mail into the copy and pushes each notification it makes.
32
+ // mail into the copy and pushes each notification it makes. While FediPod is
33
+ // open in a browser, it reads the mail itself and names the notifications it
34
+ // made (held-mail.mjs: /api/push); those are pushed from the account as it
35
+ // reads (pushMade below).
33
36
  import crypto from 'node:crypto';
34
37
  import { MastoApi } from '../client/masto/index.mjs';
35
38
  import { instanceConfig } from '../client/masto/instance.mjs';
@@ -49,7 +52,10 @@ import { noteOpened } from './quiet.mjs';
49
52
  import { Push } from '../client/webpush.mjs';
50
53
  import webpush from 'web-push';
51
54
 
55
+ // An app stays signed in for ninety days from its last use; the mark of use
56
+ // moves at most once a day.
52
57
  const TOKEN_TTL_MS = 90 * 86400_000;
58
+ const TOKEN_USE_MARK_MS = 86400_000;
53
59
  const CODE_TTL_MS = 10 * 60_000;
54
60
  const MAIL_EVERY_MS = 30_000;
55
61
  const SIGNIN_PAGE = '/app-signin/';
@@ -194,8 +200,11 @@ export async function vapidKeys(kv) {
194
200
  }
195
201
 
196
202
  class GatewayPush extends Push {
197
- constructor({ keys, ...o }) { super(o); this.keys = keys; }
203
+ constructor({ keys, keep = true, ...o }) { super(o); this.keys = keys; this.keep = keep; }
198
204
  vapid() { return this.keys; }
205
+ // Pushing from an account only read: a sign-up the push service has dropped
206
+ // is let go by the next push that acts on the account.
207
+ save(s) { if (this.keep) super.save(s); }
199
208
  }
200
209
 
201
210
  // ---- the account behind a token ----
@@ -282,10 +291,10 @@ function readingAgent(at, store, acting, log) {
282
291
  // contact address push services accept (https or mailto).
283
292
  const pushContact = (ctx) => (/^https:\/\//u.test(ctx.frontOrigin || '') ? `${ctx.frontOrigin.replace(/\/$/u, '')}/` : `mailto:postmaster@${ctx.host || 'localhost'}`);
284
293
 
285
- function facade(agent, token, log, keys = null, contact = null) {
294
+ function facade(agent, token, log, keys = null, contact = null, { keep = true } = {}) {
286
295
  const api = new MastoApi({ agent, log, scheme: 'https', streaming: false, webPush: !!keys, scheduling: true, allowed: noAuthorities });
287
296
  api.tokenOf = () => token;
288
- if (keys) api._push = new GatewayPush({ store: agent.store, keys, log, subject: () => contact });
297
+ if (keys) api._push = new GatewayPush({ store: agent.store, keys, log, subject: () => contact, keep });
289
298
  return api;
290
299
  }
291
300
 
@@ -390,6 +399,40 @@ export async function pushHeld(ctx, handle, rec, { log = console.log } = {}) {
390
399
  return n;
391
400
  }
392
401
 
402
+ /**
403
+ * For FediPod open in a browser, which read mail that went straight to the pod
404
+ * and names the notifications it made: each is read from the account as it is
405
+ * now, without acting, and pushed once, while it is new. One that came from
406
+ * another network (Bluesky, a connected account) is not pushed, as it is not
407
+ * while FediPod is closed.
408
+ */
409
+ const PUSH_FRESH_MS = 15 * 60_000;
410
+ const PUSHED_KEPT = 200;
411
+ export async function pushMade(ctx, handle, rec, ids, { log = console.log, now = Date.now() } = {}) {
412
+ if (!ids?.length || !await pushWanted(ctx, handle)) return 0;
413
+ const key = `pushed/${handle}`;
414
+ const done = (await getJson(ctx.mastoKv, key)) || [];
415
+ const want = new Set(ids.filter((id) => !done.includes(id)));
416
+ if (!want.size) return 0;
417
+ await waitOutClosing(ctx.copyKv, handle);
418
+ const at = await reachAccount(ctx, handle, rec, { log });
419
+ if (at.skipped) return 0;
420
+ const store = await readingStore(ctx, handle, at, log);
421
+ const agent = readingAgent(at, store, async () => { throw new Error('only reading'); }, log);
422
+ if (!agent) return 0;
423
+ // On the pod, a rewritten document leaves the listing as it was: what the
424
+ // push reads is asked for again, if it changed.
425
+ if (!at.inCopy) await store.refresh(['notifications.json', 'actors.json', 'statuses.json', 'webpush.json'], { ifChanged: true });
426
+ const fresh = store.getNotifications()
427
+ .filter((n) => want.has(n.id) && !n.bsky && !n.via && now - Date.parse(n.at) < PUSH_FRESH_MS);
428
+ if (!fresh.length) return 0;
429
+ const api = facade(agent, null, log, await vapidKeys(ctx.mastoKv), pushContact(ctx), { keep: false });
430
+ for (const n of fresh) await api.pushNotify(n).catch((e) => log(`push @${handle}: ${e.message}`));
431
+ await ctx.mastoKv.set(key, JSON.stringify([...fresh.map((n) => n.id), ...done].slice(0, PUSHED_KEPT)));
432
+ log(`@${handle}: ${fresh.length} made in FediPod pushed`);
433
+ return fresh.length;
434
+ }
435
+
393
436
  // A request from an app, answered by the account's facade over its copy, or
394
437
  // with the hold off, over the pod.
395
438
  async function answerForAccount(request, url, ctx, tok, rec, log, tokenHash) {
@@ -635,13 +678,15 @@ async function route(request, pathname, ctx, deps, log) {
635
678
  const bearer = /^Bearer (.+)$/u.exec(request.headers.get('authorization') || '')?.[1];
636
679
  const tokenHash = bearer ? sha(bearer) : null;
637
680
  const tok = tokenHash ? await getJson(kv, `token/${tokenHash}`) : null;
638
- if (!tok || Date.now() - tok.at > TOKEN_TTL_MS) {
681
+ const lastUse = tok ? tok.usedAt || tok.at : 0;
682
+ if (!tok || Date.now() - lastUse > TOKEN_TTL_MS) {
639
683
  // A page of this site asking with a token the gateway never gave out is
640
684
  // FediPod's own client in a browser whose worker is not answering yet:
641
685
  // told to try again, not that it is signed out.
642
686
  if (request.headers.get('sec-fetch-site') === 'same-origin') return json(503, { error: 'FediPod is starting in this browser; try again' });
643
687
  return json(401, { error: 'The access token is invalid' });
644
688
  }
689
+ if (Date.now() - lastUse > TOKEN_USE_MARK_MS) await putJson(kv, `token/${tokenHash}`, { ...tok, usedAt: Date.now() }).catch(() => {});
645
690
  if (pathname === '/api/v1/apps/verify_credentials') {
646
691
  const app = await appOf(kv, tok.clientId);
647
692
  return json(200, { name: app?.name || 'an app', website: app?.website || null, scopes: String(tok.scope).split(/\s+/u) });
@@ -63,6 +63,10 @@ export async function noteReceived(ctx, key, rec, bytes) {
63
63
  .catch((e) => console.log(`front @${key}: count not written: ${e?.message || e}`));
64
64
  }
65
65
 
66
+ // The account's picture as its owner's browser names it, for the address's
67
+ // WebFinger answer (front-core.mjs): an http(s) address, or none.
68
+ const pictureOf = (v) => (typeof v === 'string' && v.length <= 2048 && /^https?:\/\/\S+$/u.test(v) ? v : null);
69
+
66
70
  // The owner is here. `always` is a sign-in; the relay stamps at most hourly,
67
71
  // so a busy hour of posting is one write. The count that went with the old
68
72
  // stamp is dropped: it is over, and a store should not keep one per hour.
@@ -142,6 +146,12 @@ export async function routeQuietApi(request, pathname, ctx, deps) {
142
146
  if (who.error) return who.error;
143
147
  const before = await closedState(ctx, handle, rec);
144
148
  if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
149
+ // The picture rides on the same write as the stamp. Anything that is not
150
+ // an address, or null, is not a picture, and changes nothing.
151
+ if ('icon' in body && (body.icon === null || pictureOf(body.icon)) && pictureOf(body.icon) !== (rec.icon || null)) {
152
+ const { icon: _was, ...rest } = rec;
153
+ rec = pictureOf(body.icon) ? { ...rest, icon: pictureOf(body.icon) } : rest;
154
+ }
145
155
  rec = await noteOpened(ctx, handle, rec, { always: true });
146
156
  // Whom the app would name if its owner lets the gateway act for them while
147
157
  // it is closed, and whether they have (held-mail.mjs, keeper.mjs); and
package/lib/pod/actor.mjs CHANGED
@@ -73,6 +73,11 @@ export function linkInWebIdProfile(pod, { actorUrl, accountName, kind = 'person'
73
73
  return pod.linkAccountInProfile({ actorUrl, accountName, kind, outbox });
74
74
  }
75
75
 
76
+ /** The same link taken out again, for an account retired for good. */
77
+ export async function unlinkInWebIdProfile(pod, { actorUrl, outbox = null }) {
78
+ return pod.unlinkAccountInProfile({ actorUrl, outbox });
79
+ }
80
+
76
81
  // ---- anyone at all ----
77
82
 
78
83
  /**
@@ -534,6 +534,34 @@ export class PodTransport {
534
534
  return true;
535
535
  }
536
536
 
537
+ /**
538
+ * The account taken out of the WebID profile again, as linkAccountInProfile
539
+ * wrote it: the foaf:account link, what the profile says of the actor, and
540
+ * `outbox` when it is the one named. From every document of the profile that
541
+ * holds any of it, each write checked. Returns how many documents changed.
542
+ */
543
+ async unlinkAccountInProfile({ actorUrl, outbox = null }) {
544
+ const me = $rdf.sym(this.webId);
545
+ const actor = $rdf.sym(actorUrl);
546
+ let changed = 0;
547
+ for (const d of await this.profileDocs(podBaseOfWebId(this.webId))) {
548
+ if (!d.g) continue;
549
+ const gone = [
550
+ ...d.g.statementsMatching(me, FOAF('account'), actor),
551
+ ...d.g.statementsMatching(actor, RDF('type'), FOAF('OnlineAccount')),
552
+ ...d.g.statementsMatching(actor, RDF('type'), AS('Person')),
553
+ ...d.g.statementsMatching(actor, RDF('type'), AS('Group')),
554
+ ...d.g.statementsMatching(actor, FOAF('accountName'), null),
555
+ ...(outbox ? d.g.statementsMatching(me, AS('outbox'), $rdf.sym(outbox)) : []),
556
+ ].map((st) => [st.subject, st.predicate, st.object]);
557
+ if (!gone.length) continue;
558
+ const status = await this.writeRdfChecked(d.url, d.g, { deletes: gone, mustDescribe: d.profile ? this.webId : null });
559
+ if (status >= 300) throw new Error(`not written: ${d.url} → ${status}`);
560
+ changed++;
561
+ }
562
+ return changed;
563
+ }
564
+
537
565
  /**
538
566
  * An N3 Patch of exactly these statements, or false when the pod will not
539
567
  * take one and the caller should write the document instead.
@@ -65,6 +65,29 @@ export async function register(pod, indexUrl, actorUrl) {
65
65
  return true;
66
66
  }
67
67
 
68
+ /**
69
+ * Take the account's registration out again. A registration that names other
70
+ * instances too keeps them and loses only this one. True when one was there.
71
+ */
72
+ export async function unregister(pod, indexUrl, actorUrl) {
73
+ const g = await pod.readRdf(indexUrl);
74
+ if (!g) return false;
75
+ const actor = pod.sym(actorUrl);
76
+ const instance = pod.sym(SOLID + 'instance');
77
+ const gone = [];
78
+ for (const reg of g.each(null, instance, actor)) {
79
+ if (!g.holds(reg, pod.sym(SOLID + 'forClass'), pod.sym(AS_ACTOR))) continue;
80
+ const others = g.each(reg, instance, null).filter((i) => i.value !== actorUrl);
81
+ for (const st of others.length ? g.statementsMatching(reg, instance, actor) : g.statementsMatching(reg, null, null)) {
82
+ gone.push([st.subject, st.predicate, st.object]);
83
+ }
84
+ }
85
+ if (!gone.length) return false;
86
+ const status = await pod.writeRdfChecked(indexUrl, g, { deletes: gone, mustDescribe: indexUrl });
87
+ if (status >= 300) throw new Error(`the type index at ${indexUrl} did not let the registration go (${status})`);
88
+ return true;
89
+ }
90
+
68
91
  /**
69
92
  * Make a public type index and name it from the profile. Only ever called
70
93
  * after the person has said yes. An index already at the usual place is kept,
@@ -396,7 +396,7 @@ export async function startEmbeddedAgent({
396
396
  basePath: uiPath,
397
397
  mount,
398
398
  publicOrigin: base,
399
- scheme: new URL(base).protocol,
399
+ scheme: new URL(base).protocol.slice(0, -1),
400
400
  });
401
401
 
402
402
  // A delivery landing in the inbox — through the door, or from any other