@rmdes/indiekit-endpoint-activitypub 0.1.9 → 0.2.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/index.js +155 -174
- package/lib/controllers/migrate.js +32 -17
- package/lib/controllers/profile.js +71 -0
- package/lib/federation-bridge.js +119 -0
- package/lib/federation-setup.js +321 -0
- package/lib/inbox-listeners.js +215 -0
- package/lib/jf2-to-as2.js +262 -63
- package/lib/kv-store.js +55 -0
- package/locales/en.json +18 -0
- package/package.json +2 -1
- package/views/activitypub-dashboard.njk +4 -0
- package/views/activitypub-migrate.njk +29 -7
- package/views/activitypub-profile.njk +74 -0
- package/lib/actor.js +0 -75
- package/lib/federation.js +0 -410
- package/lib/inbox.js +0 -291
- package/lib/keys.js +0 -39
- package/lib/webfinger.js +0 -43
package/lib/jf2-to-as2.js
CHANGED
|
@@ -1,18 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Convert Indiekit JF2 post properties to ActivityStreams 2.0 objects.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Two export flavors:
|
|
5
|
+
* - jf2ToActivityStreams() — returns plain JSON-LD objects (for content negotiation)
|
|
6
|
+
* - jf2ToAS2Activity() — returns Fedify vocab instances (for outbox + syndicator)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Temporal } from "@js-temporal/polyfill";
|
|
10
|
+
import {
|
|
11
|
+
Announce,
|
|
12
|
+
Article,
|
|
13
|
+
Audio,
|
|
14
|
+
Create,
|
|
15
|
+
Hashtag,
|
|
16
|
+
Image,
|
|
17
|
+
Like,
|
|
18
|
+
Note,
|
|
19
|
+
Video,
|
|
20
|
+
} from "@fedify/fedify";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Plain JSON-LD (content negotiation on individual post URLs)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert JF2 properties to a plain ActivityStreams JSON-LD object.
|
|
6
28
|
*
|
|
7
|
-
* @param {object} properties - JF2 post properties
|
|
8
|
-
* @param {string} actorUrl -
|
|
29
|
+
* @param {object} properties - JF2 post properties
|
|
30
|
+
* @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
|
|
9
31
|
* @param {string} publicationUrl - Publication base URL with trailing slash
|
|
10
32
|
* @returns {object} ActivityStreams activity (Create, Like, or Announce)
|
|
11
33
|
*/
|
|
12
34
|
export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
|
13
35
|
const postType = properties["post-type"];
|
|
14
36
|
|
|
15
|
-
// Like — not wrapped in Create, stands alone
|
|
16
37
|
if (postType === "like") {
|
|
17
38
|
return {
|
|
18
39
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
@@ -22,7 +43,6 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
|
|
22
43
|
};
|
|
23
44
|
}
|
|
24
45
|
|
|
25
|
-
// Repost/boost — Announce activity
|
|
26
46
|
if (postType === "repost") {
|
|
27
47
|
return {
|
|
28
48
|
"@context": "https://www.w3.org/ns/activitystreams",
|
|
@@ -32,7 +52,6 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
|
|
32
52
|
};
|
|
33
53
|
}
|
|
34
54
|
|
|
35
|
-
// Everything else is wrapped in a Create activity
|
|
36
55
|
const isArticle = postType === "article" && properties.name;
|
|
37
56
|
const postUrl = resolvePostUrl(properties.url, publicationUrl);
|
|
38
57
|
|
|
@@ -43,10 +62,9 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
|
|
43
62
|
published: properties.published,
|
|
44
63
|
url: postUrl,
|
|
45
64
|
to: ["https://www.w3.org/ns/activitystreams#Public"],
|
|
46
|
-
cc: [`${actorUrl.replace(/\/$/, "")}/
|
|
65
|
+
cc: [`${actorUrl.replace(/\/$/, "")}/followers`],
|
|
47
66
|
};
|
|
48
67
|
|
|
49
|
-
// Content — bookmarks get special treatment
|
|
50
68
|
if (postType === "bookmark") {
|
|
51
69
|
const bookmarkUrl = properties["bookmark-of"];
|
|
52
70
|
const commentary = properties.content?.html || properties.content || "";
|
|
@@ -71,19 +89,163 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
|
|
71
89
|
}
|
|
72
90
|
}
|
|
73
91
|
|
|
74
|
-
// Reply
|
|
75
92
|
if (properties["in-reply-to"]) {
|
|
76
93
|
object.inReplyTo = properties["in-reply-to"];
|
|
77
94
|
}
|
|
78
95
|
|
|
79
|
-
|
|
96
|
+
const attachments = buildPlainAttachments(properties, publicationUrl);
|
|
97
|
+
if (attachments.length > 0) {
|
|
98
|
+
object.attachment = attachments;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tags = buildPlainTags(properties, publicationUrl, object.tag);
|
|
102
|
+
if (tags.length > 0) {
|
|
103
|
+
object.tag = tags;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"@context": "https://www.w3.org/ns/activitystreams",
|
|
108
|
+
type: "Create",
|
|
109
|
+
actor: actorUrl,
|
|
110
|
+
object,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Fedify vocab objects (outbox dispatcher + syndicator delivery)
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Convert JF2 properties to a Fedify Activity object.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} properties - JF2 post properties
|
|
122
|
+
* @param {string} actorUrl - Actor URL (e.g. "https://example.com/activitypub/users/rick")
|
|
123
|
+
* @param {string} publicationUrl - Publication base URL with trailing slash
|
|
124
|
+
* @returns {import("@fedify/fedify").Activity | null}
|
|
125
|
+
*/
|
|
126
|
+
export function jf2ToAS2Activity(properties, actorUrl, publicationUrl) {
|
|
127
|
+
const postType = properties["post-type"];
|
|
128
|
+
const actorUri = new URL(actorUrl);
|
|
129
|
+
|
|
130
|
+
if (postType === "like") {
|
|
131
|
+
const likeOf = properties["like-of"];
|
|
132
|
+
if (!likeOf) return null;
|
|
133
|
+
return new Like({
|
|
134
|
+
actor: actorUri,
|
|
135
|
+
object: new URL(likeOf),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (postType === "repost") {
|
|
140
|
+
const repostOf = properties["repost-of"];
|
|
141
|
+
if (!repostOf) return null;
|
|
142
|
+
return new Announce({
|
|
143
|
+
actor: actorUri,
|
|
144
|
+
object: new URL(repostOf),
|
|
145
|
+
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const isArticle = postType === "article" && properties.name;
|
|
150
|
+
const postUrl = resolvePostUrl(properties.url, publicationUrl);
|
|
151
|
+
const followersUrl = `${actorUrl.replace(/\/$/, "")}/followers`;
|
|
152
|
+
|
|
153
|
+
const noteOptions = {
|
|
154
|
+
attributedTo: actorUri,
|
|
155
|
+
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
|
|
156
|
+
cc: new URL(followersUrl),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (postUrl) {
|
|
160
|
+
noteOptions.id = new URL(postUrl);
|
|
161
|
+
noteOptions.url = new URL(postUrl);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (properties.published) {
|
|
165
|
+
try {
|
|
166
|
+
noteOptions.published = Temporal.Instant.from(properties.published);
|
|
167
|
+
} catch {
|
|
168
|
+
// Invalid date format — skip
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Content
|
|
173
|
+
if (postType === "bookmark") {
|
|
174
|
+
const bookmarkUrl = properties["bookmark-of"];
|
|
175
|
+
const commentary = properties.content?.html || properties.content || "";
|
|
176
|
+
noteOptions.content = commentary
|
|
177
|
+
? `${commentary}<br><br>\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`
|
|
178
|
+
: `\u{1F516} <a href="${bookmarkUrl}">${bookmarkUrl}</a>`;
|
|
179
|
+
} else {
|
|
180
|
+
noteOptions.content = properties.content?.html || properties.content || "";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (isArticle) {
|
|
184
|
+
noteOptions.name = properties.name;
|
|
185
|
+
if (properties.summary) {
|
|
186
|
+
noteOptions.summary = properties.summary;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (properties["in-reply-to"]) {
|
|
191
|
+
noteOptions.inReplyTo = new URL(properties["in-reply-to"]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Attachments
|
|
195
|
+
const fedifyAttachments = buildFedifyAttachments(properties, publicationUrl);
|
|
196
|
+
if (fedifyAttachments.length > 0) {
|
|
197
|
+
noteOptions.attachments = fedifyAttachments;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Hashtags
|
|
201
|
+
const fedifyTags = buildFedifyTags(properties, publicationUrl, postType);
|
|
202
|
+
if (fedifyTags.length > 0) {
|
|
203
|
+
noteOptions.tags = fedifyTags;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const object = isArticle
|
|
207
|
+
? new Article(noteOptions)
|
|
208
|
+
: new Note(noteOptions);
|
|
209
|
+
|
|
210
|
+
return new Create({
|
|
211
|
+
actor: actorUri,
|
|
212
|
+
object,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// URL resolution helpers
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Resolve a post URL, ensuring it's absolute.
|
|
222
|
+
* @param {string} url - Post URL (may be relative or absolute)
|
|
223
|
+
* @param {string} publicationUrl - Base publication URL
|
|
224
|
+
* @returns {string} Absolute URL
|
|
225
|
+
*/
|
|
226
|
+
export function resolvePostUrl(url, publicationUrl) {
|
|
227
|
+
if (!url) return "";
|
|
228
|
+
if (url.startsWith("http")) return url;
|
|
229
|
+
const base = publicationUrl.replace(/\/$/, "");
|
|
230
|
+
return `${base}/${url.replace(/^\//, "")}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function resolveMediaUrl(url, publicationUrl) {
|
|
234
|
+
if (!url) return "";
|
|
235
|
+
if (url.startsWith("http")) return url;
|
|
236
|
+
const base = publicationUrl.replace(/\/$/, "");
|
|
237
|
+
return `${base}/${url.replace(/^\//, "")}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Attachment builders
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function buildPlainAttachments(properties, publicationUrl) {
|
|
80
245
|
const attachments = [];
|
|
81
246
|
|
|
82
247
|
if (properties.photo) {
|
|
83
|
-
const
|
|
84
|
-
? properties.photo
|
|
85
|
-
: [properties.photo];
|
|
86
|
-
for (const photo of photos) {
|
|
248
|
+
for (const photo of asArray(properties.photo)) {
|
|
87
249
|
const url = typeof photo === "string" ? photo : photo.url;
|
|
88
250
|
const alt = typeof photo === "string" ? "" : photo.alt || "";
|
|
89
251
|
attachments.push({
|
|
@@ -96,10 +258,7 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
|
|
96
258
|
}
|
|
97
259
|
|
|
98
260
|
if (properties.video) {
|
|
99
|
-
const
|
|
100
|
-
? properties.video
|
|
101
|
-
: [properties.video];
|
|
102
|
-
for (const video of videos) {
|
|
261
|
+
for (const video of asArray(properties.video)) {
|
|
103
262
|
const url = typeof video === "string" ? video : video.url;
|
|
104
263
|
attachments.push({
|
|
105
264
|
type: "Video",
|
|
@@ -110,10 +269,7 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
|
|
110
269
|
}
|
|
111
270
|
|
|
112
271
|
if (properties.audio) {
|
|
113
|
-
const
|
|
114
|
-
? properties.audio
|
|
115
|
-
: [properties.audio];
|
|
116
|
-
for (const audio of audios) {
|
|
272
|
+
for (const audio of asArray(properties.audio)) {
|
|
117
273
|
const url = typeof audio === "string" ? audio : audio.url;
|
|
118
274
|
attachments.push({
|
|
119
275
|
type: "Audio",
|
|
@@ -123,59 +279,102 @@ export function jf2ToActivityStreams(properties, actorUrl, publicationUrl) {
|
|
|
123
279
|
}
|
|
124
280
|
}
|
|
125
281
|
|
|
126
|
-
|
|
127
|
-
|
|
282
|
+
return attachments;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildFedifyAttachments(properties, publicationUrl) {
|
|
286
|
+
const attachments = [];
|
|
287
|
+
|
|
288
|
+
if (properties.photo) {
|
|
289
|
+
for (const photo of asArray(properties.photo)) {
|
|
290
|
+
const url = typeof photo === "string" ? photo : photo.url;
|
|
291
|
+
const alt = typeof photo === "string" ? "" : photo.alt || "";
|
|
292
|
+
attachments.push(
|
|
293
|
+
new Image({
|
|
294
|
+
url: new URL(resolveMediaUrl(url, publicationUrl)),
|
|
295
|
+
mediaType: guessMediaType(url),
|
|
296
|
+
name: alt,
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (properties.video) {
|
|
303
|
+
for (const video of asArray(properties.video)) {
|
|
304
|
+
const url = typeof video === "string" ? video : video.url;
|
|
305
|
+
attachments.push(
|
|
306
|
+
new Video({
|
|
307
|
+
url: new URL(resolveMediaUrl(url, publicationUrl)),
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
128
311
|
}
|
|
129
312
|
|
|
130
|
-
|
|
313
|
+
if (properties.audio) {
|
|
314
|
+
for (const audio of asArray(properties.audio)) {
|
|
315
|
+
const url = typeof audio === "string" ? audio : audio.url;
|
|
316
|
+
attachments.push(
|
|
317
|
+
new Audio({
|
|
318
|
+
url: new URL(resolveMediaUrl(url, publicationUrl)),
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return attachments;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Tag builders
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function buildPlainTags(properties, publicationUrl, existing) {
|
|
332
|
+
const tags = [...(existing || [])];
|
|
131
333
|
if (properties.category) {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
: [properties.category];
|
|
135
|
-
object.tag = [
|
|
136
|
-
...(object.tag || []),
|
|
137
|
-
...categories.map((cat) => ({
|
|
334
|
+
for (const cat of asArray(properties.category)) {
|
|
335
|
+
tags.push({
|
|
138
336
|
type: "Hashtag",
|
|
139
337
|
name: `#${cat.replace(/\s+/g, "")}`,
|
|
140
338
|
href: `${publicationUrl}categories/${encodeURIComponent(cat)}`,
|
|
141
|
-
})
|
|
142
|
-
|
|
339
|
+
});
|
|
340
|
+
}
|
|
143
341
|
}
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
"@context": "https://www.w3.org/ns/activitystreams",
|
|
147
|
-
type: "Create",
|
|
148
|
-
actor: actorUrl,
|
|
149
|
-
object,
|
|
150
|
-
};
|
|
342
|
+
return tags;
|
|
151
343
|
}
|
|
152
344
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
345
|
+
function buildFedifyTags(properties, publicationUrl, postType) {
|
|
346
|
+
const tags = [];
|
|
347
|
+
if (postType === "bookmark") {
|
|
348
|
+
tags.push(
|
|
349
|
+
new Hashtag({
|
|
350
|
+
name: "#bookmark",
|
|
351
|
+
href: new URL(`${publicationUrl}categories/bookmark`),
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (properties.category) {
|
|
356
|
+
for (const cat of asArray(properties.category)) {
|
|
357
|
+
tags.push(
|
|
358
|
+
new Hashtag({
|
|
359
|
+
name: `#${cat.replace(/\s+/g, "")}`,
|
|
360
|
+
href: new URL(
|
|
361
|
+
`${publicationUrl}categories/${encodeURIComponent(cat)}`,
|
|
362
|
+
),
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return tags;
|
|
164
368
|
}
|
|
165
369
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const base = publicationUrl.replace(/\/$/, "");
|
|
173
|
-
return `${base}/${url.replace(/^\//, "")}`;
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Utilities
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
function asArray(value) {
|
|
375
|
+
return Array.isArray(value) ? value : [value];
|
|
174
376
|
}
|
|
175
377
|
|
|
176
|
-
/**
|
|
177
|
-
* Guess MIME type from file extension.
|
|
178
|
-
*/
|
|
179
378
|
function guessMediaType(url) {
|
|
180
379
|
const ext = url.split(".").pop()?.toLowerCase();
|
|
181
380
|
const types = {
|
package/lib/kv-store.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MongoDB-backed KvStore adapter for Fedify.
|
|
3
|
+
*
|
|
4
|
+
* Implements Fedify's KvStore interface using a MongoDB collection.
|
|
5
|
+
* Keys are string arrays (e.g. ["keypair", "rsa", "rick"]) — we serialize
|
|
6
|
+
* them as a joined path string for MongoDB's _id field.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @implements {import("@fedify/fedify").KvStore}
|
|
11
|
+
*/
|
|
12
|
+
export class MongoKvStore {
|
|
13
|
+
/** @param {import("mongodb").Collection} collection */
|
|
14
|
+
constructor(collection) {
|
|
15
|
+
this.collection = collection;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Serialize a Fedify key (string[]) to a MongoDB document _id.
|
|
20
|
+
* @param {string[]} key
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
_serializeKey(key) {
|
|
24
|
+
return key.join("/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string[]} key
|
|
29
|
+
* @returns {Promise<unknown>}
|
|
30
|
+
*/
|
|
31
|
+
async get(key) {
|
|
32
|
+
const doc = await this.collection.findOne({ _id: this._serializeKey(key) });
|
|
33
|
+
return doc ? doc.value : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string[]} key
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
*/
|
|
40
|
+
async set(key, value) {
|
|
41
|
+
const id = this._serializeKey(key);
|
|
42
|
+
await this.collection.updateOne(
|
|
43
|
+
{ _id: id },
|
|
44
|
+
{ $set: { _id: id, value, updatedAt: new Date().toISOString() } },
|
|
45
|
+
{ upsert: true },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string[]} key
|
|
51
|
+
*/
|
|
52
|
+
async delete(key) {
|
|
53
|
+
await this.collection.deleteOne({ _id: this._serializeKey(key) });
|
|
54
|
+
}
|
|
55
|
+
}
|
package/locales/en.json
CHANGED
|
@@ -19,6 +19,24 @@
|
|
|
19
19
|
"direction": "Direction",
|
|
20
20
|
"directionInbound": "Received",
|
|
21
21
|
"directionOutbound": "Sent",
|
|
22
|
+
"profile": {
|
|
23
|
+
"title": "Profile",
|
|
24
|
+
"intro": "Edit how your actor appears to other fediverse users. Changes take effect immediately.",
|
|
25
|
+
"nameLabel": "Display name",
|
|
26
|
+
"nameHint": "Your name as shown on your fediverse profile",
|
|
27
|
+
"summaryLabel": "Bio",
|
|
28
|
+
"summaryHint": "A short description of yourself. HTML is allowed.",
|
|
29
|
+
"urlLabel": "Website URL",
|
|
30
|
+
"urlHint": "Your website address, shown as a link on your profile",
|
|
31
|
+
"iconLabel": "Avatar URL",
|
|
32
|
+
"iconHint": "URL to your profile picture (square, at least 400x400px recommended)",
|
|
33
|
+
"imageLabel": "Header image URL",
|
|
34
|
+
"imageHint": "URL to a banner image shown at the top of your profile",
|
|
35
|
+
"manualApprovalLabel": "Manually approve followers",
|
|
36
|
+
"manualApprovalHint": "When enabled, follow requests require your approval before they take effect",
|
|
37
|
+
"save": "Save profile",
|
|
38
|
+
"saved": "Profile saved. Changes are now visible to the fediverse."
|
|
39
|
+
},
|
|
22
40
|
"migrate": {
|
|
23
41
|
"title": "Mastodon migration",
|
|
24
42
|
"intro": "This guide walks you through moving your Mastodon identity to your IndieWeb site. Complete each step in order — your existing followers will be notified and can re-follow you automatically.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@fedify/fedify": "^1.10.0",
|
|
41
41
|
"@fedify/express": "^1.9.0",
|
|
42
|
+
"@js-temporal/polyfill": "^0.5.0",
|
|
42
43
|
"express": "^5.0.0"
|
|
43
44
|
},
|
|
44
45
|
"peerDependencies": {
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
title: __("activitypub.activities"),
|
|
23
23
|
url: mountPath + "/admin/activities"
|
|
24
24
|
},
|
|
25
|
+
{
|
|
26
|
+
title: __("activitypub.profile.title"),
|
|
27
|
+
url: mountPath + "/admin/profile"
|
|
28
|
+
},
|
|
25
29
|
{
|
|
26
30
|
title: __("activitypub.migrate.title"),
|
|
27
31
|
url: mountPath + "/admin/migrate"
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
@change="readFile($event)">
|
|
77
77
|
<template x-if="fileName">
|
|
78
78
|
<p class="hint" style="margin-top: 0.5em">
|
|
79
|
-
<strong x-text="fileName"></strong> — <span x-text="
|
|
79
|
+
<strong x-text="fileName"></strong> — <span x-text="handles.length + ' accounts found'"></span>
|
|
80
80
|
</p>
|
|
81
81
|
</template>
|
|
82
82
|
<template x-if="fileError">
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
</div>
|
|
86
86
|
|
|
87
87
|
<button class="button" type="button"
|
|
88
|
-
:disabled="importing ||
|
|
88
|
+
:disabled="importing || handles.length === 0"
|
|
89
89
|
@click="startImport()">
|
|
90
90
|
<span x-show="!importing">{{ __("activitypub.migrate.importButton") }}</span>
|
|
91
91
|
<span x-show="importing" x-text="statusText"></span>
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
<script>
|
|
120
120
|
function csvImport(mountPath) {
|
|
121
121
|
return {
|
|
122
|
-
|
|
122
|
+
handles: [],
|
|
123
123
|
fileName: '',
|
|
124
124
|
lineCount: 0,
|
|
125
125
|
fileError: '',
|
|
@@ -129,9 +129,14 @@
|
|
|
129
129
|
resultText: '',
|
|
130
130
|
resultErrors: [],
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Parse CSV client-side — extract handles (first column) only.
|
|
134
|
+
* This keeps the JSON payload small (handles only, no raw CSV),
|
|
135
|
+
* avoiding Express's default 100KB body parser limit.
|
|
136
|
+
*/
|
|
132
137
|
readFile(event) {
|
|
133
138
|
var self = this;
|
|
134
|
-
self.
|
|
139
|
+
self.handles = [];
|
|
135
140
|
self.fileName = '';
|
|
136
141
|
self.lineCount = 0;
|
|
137
142
|
self.fileError = '';
|
|
@@ -151,9 +156,19 @@
|
|
|
151
156
|
var reader = new FileReader();
|
|
152
157
|
reader.onload = function(e) {
|
|
153
158
|
var text = e.target.result;
|
|
154
|
-
|
|
159
|
+
var lines = text.split('\n').filter(function(l) { return l.trim(); });
|
|
155
160
|
self.fileName = file.name;
|
|
156
|
-
self.lineCount =
|
|
161
|
+
self.lineCount = lines.length;
|
|
162
|
+
|
|
163
|
+
// Extract handles: skip header, take first CSV column, keep only valid handles
|
|
164
|
+
var parsed = [];
|
|
165
|
+
for (var i = 1; i < lines.length; i++) {
|
|
166
|
+
var handle = lines[i].split(',')[0].trim();
|
|
167
|
+
if (handle && handle.indexOf('@') !== -1) {
|
|
168
|
+
parsed.push(handle);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
self.handles = parsed;
|
|
157
172
|
};
|
|
158
173
|
reader.onerror = function() {
|
|
159
174
|
self.fileError = 'Could not read file';
|
|
@@ -183,12 +198,19 @@
|
|
|
183
198
|
return;
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
if (self.handles.length === 0) {
|
|
202
|
+
self.importing = false;
|
|
203
|
+
self.resultType = 'error';
|
|
204
|
+
self.resultText = 'No valid handles found in the CSV file.';
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
186
208
|
try {
|
|
187
209
|
var res = await fetch(mountPath + '/admin/migrate/import', {
|
|
188
210
|
method: 'POST',
|
|
189
211
|
headers: { 'Content-Type': 'application/json' },
|
|
190
212
|
body: JSON.stringify({
|
|
191
|
-
|
|
213
|
+
handles: self.handles,
|
|
192
214
|
importTypes: importTypes
|
|
193
215
|
})
|
|
194
216
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{% extends "document.njk" %}
|
|
2
|
+
|
|
3
|
+
{% from "heading/macro.njk" import heading with context %}
|
|
4
|
+
{% from "input/macro.njk" import input with context %}
|
|
5
|
+
{% from "textarea/macro.njk" import textarea with context %}
|
|
6
|
+
{% from "checkboxes/macro.njk" import checkboxes with context %}
|
|
7
|
+
{% from "button/macro.njk" import button with context %}
|
|
8
|
+
{% from "notification-banner/macro.njk" import notificationBanner with context %}
|
|
9
|
+
{% from "prose/macro.njk" import prose with context %}
|
|
10
|
+
|
|
11
|
+
{% block content %}
|
|
12
|
+
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
|
13
|
+
|
|
14
|
+
{% if result %}
|
|
15
|
+
{{ notificationBanner({ type: result.type, text: result.text }) }}
|
|
16
|
+
{% endif %}
|
|
17
|
+
|
|
18
|
+
{{ prose({ text: __("activitypub.profile.intro") }) }}
|
|
19
|
+
|
|
20
|
+
<form method="post" novalidate>
|
|
21
|
+
{{ input({
|
|
22
|
+
name: "name",
|
|
23
|
+
label: __("activitypub.profile.nameLabel"),
|
|
24
|
+
hint: __("activitypub.profile.nameHint"),
|
|
25
|
+
value: profile.name
|
|
26
|
+
}) }}
|
|
27
|
+
|
|
28
|
+
{{ textarea({
|
|
29
|
+
name: "summary",
|
|
30
|
+
label: __("activitypub.profile.summaryLabel"),
|
|
31
|
+
hint: __("activitypub.profile.summaryHint"),
|
|
32
|
+
value: profile.summary,
|
|
33
|
+
rows: 4
|
|
34
|
+
}) }}
|
|
35
|
+
|
|
36
|
+
{{ input({
|
|
37
|
+
name: "url",
|
|
38
|
+
label: __("activitypub.profile.urlLabel"),
|
|
39
|
+
hint: __("activitypub.profile.urlHint"),
|
|
40
|
+
value: profile.url,
|
|
41
|
+
type: "url"
|
|
42
|
+
}) }}
|
|
43
|
+
|
|
44
|
+
{{ input({
|
|
45
|
+
name: "icon",
|
|
46
|
+
label: __("activitypub.profile.iconLabel"),
|
|
47
|
+
hint: __("activitypub.profile.iconHint"),
|
|
48
|
+
value: profile.icon,
|
|
49
|
+
type: "url"
|
|
50
|
+
}) }}
|
|
51
|
+
|
|
52
|
+
{{ input({
|
|
53
|
+
name: "image",
|
|
54
|
+
label: __("activitypub.profile.imageLabel"),
|
|
55
|
+
hint: __("activitypub.profile.imageHint"),
|
|
56
|
+
value: profile.image,
|
|
57
|
+
type: "url"
|
|
58
|
+
}) }}
|
|
59
|
+
|
|
60
|
+
{{ checkboxes({
|
|
61
|
+
name: "manuallyApprovesFollowers",
|
|
62
|
+
items: [
|
|
63
|
+
{
|
|
64
|
+
label: __("activitypub.profile.manualApprovalLabel"),
|
|
65
|
+
value: "true",
|
|
66
|
+
hint: __("activitypub.profile.manualApprovalHint")
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
values: ["true"] if profile.manuallyApprovesFollowers else []
|
|
70
|
+
}) }}
|
|
71
|
+
|
|
72
|
+
{{ button({ text: __("activitypub.profile.save") }) }}
|
|
73
|
+
</form>
|
|
74
|
+
{% endblock %}
|