fedipod 1.48.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 +12 -71
- package/gateway.md +3 -3
- package/gui.md +2 -1
- package/lib/core/place.mjs +10 -0
- package/lib/core/publisher/index.mjs +14 -2
- package/lib/core/store.mjs +6 -2
- package/lib/core/wire.mjs +3 -1
- package/lib/gateway/front-core.mjs +9 -10
- package/lib/gateway/held-mail.mjs +23 -0
- package/lib/gateway/masto-gateway.mjs +50 -5
- package/lib/gateway/quiet.mjs +10 -0
- package/lib/pod/actor.mjs +5 -0
- package/lib/pod/transport.mjs +28 -0
- package/lib/pod/type-index.mjs +23 -0
- package/package.json +1 -1
- package/web/admin/index.html +2 -1
- package/web/app/agent.mjs +29 -4
- package/web/app/dist/boot.js +27 -0
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +106 -10
- package/web/app/dist/sw.js.map +3 -3
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
|
|
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
|
|
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
|
|
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).
|
|
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
|
|
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.
|
|
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,
|
|
246
|
-
FediPod
|
|
247
|
-
|
|
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
|
|
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.
|
package/lib/core/place.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/core/store.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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,
|
|
589
|
-
const
|
|
590
|
-
|
|
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
|
-
|
|
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) });
|
package/lib/gateway/quiet.mjs
CHANGED
|
@@ -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
|
/**
|
package/lib/pod/transport.mjs
CHANGED
|
@@ -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.
|
package/lib/pod/type-index.mjs
CHANGED
|
@@ -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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.48.
|
|
3
|
+
"version": "1.48.1",
|
|
4
4
|
"description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/web/admin/index.html
CHANGED
|
@@ -527,7 +527,8 @@ pre { overflow-x: auto; background: #0001; padding: .6rem; border-radius: .3rem;
|
|
|
527
527
|
<p><strong>This cannot be undone.</strong> A Delete goes to every follower's inbox telling
|
|
528
528
|
those servers to drop the account and the posts they cached, the actor document is
|
|
529
529
|
replaced with a Tombstone, and this agent will refuse to start again for this pod. Your
|
|
530
|
-
|
|
530
|
+
WebID profile and public type index stop naming the account. Your posts and RDF stay on
|
|
531
|
+
the pod; the identity does not come back.</p>
|
|
531
532
|
<p>Two things that are not this, and can be done from here:
|
|
532
533
|
<button type="button" id="go-park" title=" Park instead: keeps the handle, just stops the mail" class="inline">Park it instead</button>
|
|
533
534
|
keeps the handle resolving and just stops the mail — setting status back to
|
package/web/app/agent.mjs
CHANGED
|
@@ -152,8 +152,9 @@ export class BrowserAgent {
|
|
|
152
152
|
|
|
153
153
|
// One call to the gateway's owner API, proved with the pod session. What
|
|
154
154
|
// comes back on 200 or 410 is the account's standing there, kept for the
|
|
155
|
-
// manage page; anything else is logged and forgotten.
|
|
156
|
-
|
|
155
|
+
// manage page; anything else is logged and forgotten. `standing: false` for
|
|
156
|
+
// a call whose answer says nothing about the account's standing.
|
|
157
|
+
async tellGateway(what, body, { standing = true } = {}) {
|
|
157
158
|
if (!this.gatewayApi) return null;
|
|
158
159
|
let res;
|
|
159
160
|
try {
|
|
@@ -163,15 +164,38 @@ export class BrowserAgent {
|
|
|
163
164
|
} catch (e) { this.log(`gateway ${what}: ${e.message}`); return null; }
|
|
164
165
|
const json = await res.json().catch(() => ({}));
|
|
165
166
|
const out = { status: res.status, ...json };
|
|
166
|
-
if (res.status === 200 || res.status === 410) this.gatewayStanding = out;
|
|
167
|
+
if (res.status === 200 || res.status === 410) { if (standing) this.gatewayStanding = out; }
|
|
167
168
|
else this.log(`gateway ${what}: ${res.status} ${json.error || ''}`);
|
|
168
169
|
return out;
|
|
169
170
|
}
|
|
170
|
-
|
|
171
|
+
// The account's picture goes with it, for the address's WebFinger answer;
|
|
172
|
+
// said only once the account has been read, so "none" is never a guess.
|
|
173
|
+
openAtGateway() {
|
|
174
|
+
const config = this.store?.getConfig?.();
|
|
175
|
+
return this.tellGateway('open', { handle: this.doorKey, ...(config ? { icon: config.icon || null } : {}) });
|
|
176
|
+
}
|
|
171
177
|
pauseAtGateway(paused) { return this.tellGateway('pause', { handle: this.doorKey, paused: !!paused }); }
|
|
172
178
|
closeAtGateway() { return this.tellGateway('close', { handle: this.doorKey, confirm: true }); }
|
|
173
179
|
static OPEN_EVERY_MS = 60 * 60_000;
|
|
174
180
|
|
|
181
|
+
// A notification made here, from mail read while FediPod is open, goes to
|
|
182
|
+
// the phones the owner signed up, as one made while it is closed does
|
|
183
|
+
// (lib/gateway/held-mail.mjs: /api/push). Named a moment later, once written,
|
|
184
|
+
// so a burst is one call. One from another network is not pushed.
|
|
185
|
+
static PUSH_AFTER_MS = 2_000;
|
|
186
|
+
notePush(n) {
|
|
187
|
+
if (!this.gatewayApi || n?.bsky || n?.via) return;
|
|
188
|
+
if (!Object.keys(this.store.read('webpush.json', { subs: {} }).subs || {}).length) return;
|
|
189
|
+
(this._toPush ||= new Set()).add(n.id);
|
|
190
|
+
this._pushTimer ||= setTimeout(async () => {
|
|
191
|
+
const ids = [...this._toPush];
|
|
192
|
+
this._toPush.clear();
|
|
193
|
+
this._pushTimer = null;
|
|
194
|
+
await this.store.flush?.().catch(() => {});
|
|
195
|
+
await this.tellGateway('push', { handle: this.doorKey, ids }, { standing: false });
|
|
196
|
+
}, BrowserAgent.PUSH_AFTER_MS);
|
|
197
|
+
}
|
|
198
|
+
|
|
175
199
|
// The gateway acting for this account while the app is closed, or not. On:
|
|
176
200
|
// the rules on the account's folders name the gateway's pod identity, then
|
|
177
201
|
// the gateway is told. Off: the gateway is told first, so it stops, then the
|
|
@@ -304,6 +328,7 @@ export class BrowserAgent {
|
|
|
304
328
|
if (this.viewer) return; // another agent holds the copy: reading only
|
|
305
329
|
}
|
|
306
330
|
}
|
|
331
|
+
this.store.onEvent = (type, n) => { if (type === 'notification') this.notePush(n); };
|
|
307
332
|
const now = Date.now();
|
|
308
333
|
// Said before the drain starts, so held mail is in the inbox to be read.
|
|
309
334
|
const hereDue = !warm || now - (warm.hereAt || 0) >= BrowserAgent.HERE_EVERY_MS;
|