@rmdes/indiekit-endpoint-activitypub 0.1.7 → 0.1.9

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 CHANGED
@@ -9,7 +9,7 @@ import { dashboardController } from "./lib/controllers/dashboard.js";
9
9
  import { followersController } from "./lib/controllers/followers.js";
10
10
  import { followingController } from "./lib/controllers/following.js";
11
11
  import { activitiesController } from "./lib/controllers/activities.js";
12
- import { migrateGetController, migratePostController } from "./lib/controllers/migrate.js";
12
+ import { migrateGetController, migratePostController, migrateImportController } from "./lib/controllers/migrate.js";
13
13
 
14
14
  const defaults = {
15
15
  mountPath: "/activitypub",
@@ -180,6 +180,7 @@ export default class ActivityPubEndpoint {
180
180
  router.get("/admin/activities", activitiesController(mp));
181
181
  router.get("/admin/migrate", migrateGetController(mp, this.options));
182
182
  router.post("/admin/migrate", migratePostController(mp, this.options));
183
+ router.post("/admin/migrate/import", express.json({ limit: "5mb" }), migrateImportController(mp, this.options));
183
184
 
184
185
  return router;
185
186
  }
@@ -2,7 +2,8 @@
2
2
  * Migration controller — handles Mastodon account migration UI.
3
3
  *
4
4
  * GET: shows the 3-step migration page
5
- * POST: processes alias update or CSV file import
5
+ * POST /admin/migrate: alias update (small form POST)
6
+ * POST /admin/migrate/import: CSV import (JSON via fetch, bypasses body size limit)
6
7
  */
7
8
 
8
9
  import {
@@ -30,69 +31,16 @@ export function migrateGetController(mountPath, pluginOptions) {
30
31
  export function migratePostController(mountPath, pluginOptions) {
31
32
  return async (request, response, next) => {
32
33
  try {
33
- const { application } = request.app.locals;
34
- const action = request.body.action;
35
34
  let result = null;
36
35
 
37
- if (action === "alias") {
38
- // Update alsoKnownAs on the actor config
39
- const aliasUrl = request.body.aliasUrl?.trim();
40
- if (aliasUrl) {
41
- pluginOptions.alsoKnownAs = aliasUrl;
42
- result = {
43
- type: "success",
44
- text: response.locals.__("activitypub.migrate.aliasSuccess"),
45
- };
46
- }
47
- }
48
-
49
- if (action === "import") {
50
- const followingCollection =
51
- application?.collections?.get("ap_following");
52
- const followersCollection =
53
- application?.collections?.get("ap_followers");
54
-
55
- const importFollowing = request.body.importTypes?.includes("following");
56
- const importFollowers = request.body.importTypes?.includes("followers");
57
-
58
- // Read file content (submitted as text via client-side FileReader)
59
- const fileContent = request.body.csvContent?.trim();
60
- if (!fileContent) {
61
- result = {
62
- type: "error",
63
- text: response.locals.__("activitypub.migrate.errorNoFile"),
64
- };
65
- } else {
66
- let followingResult = { imported: 0, failed: 0 };
67
- let followersResult = { imported: 0, failed: 0 };
68
-
69
- if (importFollowing && followingCollection) {
70
- const handles = parseMastodonFollowingCsv(fileContent);
71
- followingResult = await bulkImportFollowing(
72
- handles,
73
- followingCollection,
74
- );
75
- }
76
-
77
- if (importFollowers && followersCollection) {
78
- const entries = parseMastodonFollowersList(fileContent);
79
- followersResult = await bulkImportFollowers(
80
- entries,
81
- followersCollection,
82
- );
83
- }
84
-
85
- const totalFailed =
86
- followingResult.failed + followersResult.failed;
87
- result = {
88
- type: "success",
89
- text: response.locals
90
- .__("activitypub.migrate.success")
91
- .replace("%d", followingResult.imported)
92
- .replace("%d", followersResult.imported)
93
- .replace("%d", totalFailed),
94
- };
95
- }
36
+ // Only handles alias updates (small payload, regular form POST)
37
+ const aliasUrl = request.body.aliasUrl?.trim();
38
+ if (aliasUrl) {
39
+ pluginOptions.alsoKnownAs = aliasUrl;
40
+ result = {
41
+ type: "success",
42
+ text: response.locals.__("activitypub.migrate.aliasSuccess"),
43
+ };
96
44
  }
97
45
 
98
46
  response.render("activitypub-migrate", {
@@ -107,3 +55,63 @@ export function migratePostController(mountPath, pluginOptions) {
107
55
  };
108
56
  }
109
57
 
58
+ /**
59
+ * JSON endpoint for CSV import — receives { csvContent, importTypes }
60
+ * via fetch() to bypass Express's app-level urlencoded body size limit.
61
+ */
62
+ export function migrateImportController(mountPath, pluginOptions) {
63
+ return async (request, response, next) => {
64
+ try {
65
+ const { application } = request.app.locals;
66
+ const { csvContent, importTypes } = request.body;
67
+
68
+ if (!csvContent?.trim()) {
69
+ return response.status(400).json({
70
+ type: "error",
71
+ text: "No CSV content provided.",
72
+ });
73
+ }
74
+
75
+ const followingCollection =
76
+ application?.collections?.get("ap_following");
77
+ const followersCollection =
78
+ application?.collections?.get("ap_followers");
79
+
80
+ const importFollowing = importTypes?.includes("following");
81
+ const importFollowers = importTypes?.includes("followers");
82
+
83
+ let followingResult = { imported: 0, failed: 0, errors: [] };
84
+ let followersResult = { imported: 0, failed: 0, errors: [] };
85
+
86
+ if (importFollowing && followingCollection) {
87
+ const handles = parseMastodonFollowingCsv(csvContent);
88
+ console.log(`[ActivityPub] Migration: parsed ${handles.length} following handles from CSV`);
89
+ followingResult = await bulkImportFollowing(handles, followingCollection);
90
+ }
91
+
92
+ if (importFollowers && followersCollection) {
93
+ const entries = parseMastodonFollowersList(csvContent);
94
+ console.log(`[ActivityPub] Migration: parsed ${entries.length} follower entries from CSV`);
95
+ followersResult = await bulkImportFollowers(entries, followersCollection);
96
+ }
97
+
98
+ const totalFailed = followingResult.failed + followersResult.failed;
99
+ const totalImported = followingResult.imported + followersResult.imported;
100
+ const allErrors = [...followingResult.errors, ...followersResult.errors];
101
+
102
+ return response.json({
103
+ type: totalFailed > 0 && totalImported === 0 ? "error" : "success",
104
+ followingImported: followingResult.imported,
105
+ followersImported: followersResult.imported,
106
+ failed: totalFailed,
107
+ errors: allErrors,
108
+ });
109
+ } catch (error) {
110
+ console.error("[ActivityPub] Migration import error:", error.message);
111
+ return response.status(500).json({
112
+ type: "error",
113
+ text: error.message,
114
+ });
115
+ }
116
+ };
117
+ }
package/lib/migration.js CHANGED
@@ -54,7 +54,10 @@ export function parseMastodonFollowersList(text) {
54
54
  */
55
55
  export async function resolveHandleViaWebFinger(handle) {
56
56
  const [user, domain] = handle.split("@");
57
- if (!user || !domain) return null;
57
+ if (!user || !domain) {
58
+ console.warn(`[ActivityPub] Migration: invalid handle "${handle}" — skipping`);
59
+ return null;
60
+ }
58
61
 
59
62
  try {
60
63
  // WebFinger lookup
@@ -64,14 +67,20 @@ export async function resolveHandleViaWebFinger(handle) {
64
67
  signal: AbortSignal.timeout(10_000),
65
68
  });
66
69
 
67
- if (!wfResponse.ok) return null;
70
+ if (!wfResponse.ok) {
71
+ console.warn(`[ActivityPub] Migration: WebFinger failed for ${handle} (HTTP ${wfResponse.status})`);
72
+ return null;
73
+ }
68
74
 
69
75
  const jrd = await wfResponse.json();
70
76
  const selfLink = jrd.links?.find(
71
77
  (l) => l.rel === "self" && l.type === "application/activity+json",
72
78
  );
73
79
 
74
- if (!selfLink?.href) return null;
80
+ if (!selfLink?.href) {
81
+ console.warn(`[ActivityPub] Migration: no ActivityPub self link for ${handle}`);
82
+ return null;
83
+ }
75
84
 
76
85
  // Fetch actor document for inbox and profile
77
86
  const actorResponse = await fetch(selfLink.href, {
@@ -79,7 +88,10 @@ export async function resolveHandleViaWebFinger(handle) {
79
88
  signal: AbortSignal.timeout(10_000),
80
89
  });
81
90
 
82
- if (!actorResponse.ok) return null;
91
+ if (!actorResponse.ok) {
92
+ console.warn(`[ActivityPub] Migration: actor fetch failed for ${handle} (HTTP ${actorResponse.status})`);
93
+ return null;
94
+ }
83
95
 
84
96
  const actor = await actorResponse.json();
85
97
  return {
@@ -89,7 +101,8 @@ export async function resolveHandleViaWebFinger(handle) {
89
101
  name: actor.name || actor.preferredUsername || handle,
90
102
  handle: actor.preferredUsername || user,
91
103
  };
92
- } catch {
104
+ } catch (error) {
105
+ console.warn(`[ActivityPub] Migration: resolve failed for ${handle}: ${error.message}`);
93
106
  return null;
94
107
  }
95
108
  }
@@ -99,16 +112,23 @@ export async function resolveHandleViaWebFinger(handle) {
99
112
  *
100
113
  * @param {string[]} handles - Array of handles to import
101
114
  * @param {Collection} collection - MongoDB ap_following collection
102
- * @returns {Promise<{imported: number, failed: number}>}
115
+ * @returns {Promise<{imported: number, failed: number, errors: string[]}>}
103
116
  */
104
117
  export async function bulkImportFollowing(handles, collection) {
105
118
  let imported = 0;
106
119
  let failed = 0;
120
+ const errors = [];
121
+
122
+ console.log(`[ActivityPub] Migration: importing ${handles.length} following entries...`);
123
+
124
+ for (let i = 0; i < handles.length; i++) {
125
+ const handle = handles[i];
126
+ console.log(`[ActivityPub] Migration: resolving following ${i + 1}/${handles.length}: ${handle}`);
107
127
 
108
- for (const handle of handles) {
109
128
  const resolved = await resolveHandleViaWebFinger(handle);
110
129
  if (!resolved) {
111
130
  failed++;
131
+ errors.push(handle);
112
132
  continue;
113
133
  }
114
134
 
@@ -130,7 +150,12 @@ export async function bulkImportFollowing(handles, collection) {
130
150
  imported++;
131
151
  }
132
152
 
133
- return { imported, failed };
153
+ console.log(`[ActivityPub] Migration: following import complete — ${imported} imported, ${failed} failed`);
154
+ if (errors.length > 0) {
155
+ console.log(`[ActivityPub] Migration: failed handles: ${errors.join(", ")}`);
156
+ }
157
+
158
+ return { imported, failed, errors };
134
159
  }
135
160
 
136
161
  /**
@@ -140,15 +165,24 @@ export async function bulkImportFollowing(handles, collection) {
140
165
  *
141
166
  * @param {string[]} entries - Array of handles or actor URLs
142
167
  * @param {Collection} collection - MongoDB ap_followers collection
143
- * @returns {Promise<{imported: number, failed: number}>}
168
+ * @returns {Promise<{imported: number, failed: number, errors: string[]}>}
144
169
  */
145
170
  export async function bulkImportFollowers(entries, collection) {
146
171
  let imported = 0;
147
172
  let failed = 0;
173
+ const errors = [];
174
+
175
+ console.log(`[ActivityPub] Migration: importing ${entries.length} follower entries...`);
148
176
 
149
- for (const entry of entries) {
177
+ for (let i = 0; i < entries.length; i++) {
178
+ const entry = entries[i];
150
179
  // If it's a URL, store directly; if it's a handle, resolve via WebFinger
151
180
  const isUrl = entry.startsWith("http");
181
+
182
+ if (!isUrl) {
183
+ console.log(`[ActivityPub] Migration: resolving follower ${i + 1}/${entries.length}: ${entry}`);
184
+ }
185
+
152
186
  let actorData;
153
187
 
154
188
  if (isUrl) {
@@ -159,6 +193,7 @@ export async function bulkImportFollowers(entries, collection) {
159
193
 
160
194
  if (!actorData) {
161
195
  failed++;
196
+ errors.push(entry);
162
197
  continue;
163
198
  }
164
199
 
@@ -180,5 +215,10 @@ export async function bulkImportFollowers(entries, collection) {
180
215
  imported++;
181
216
  }
182
217
 
183
- return { imported, failed };
218
+ console.log(`[ActivityPub] Migration: follower import complete — ${imported} imported, ${failed} failed`);
219
+ if (errors.length > 0) {
220
+ console.log(`[ActivityPub] Migration: failed entries: ${errors.join(", ")}`);
221
+ }
222
+
223
+ return { imported, failed, errors };
184
224
  }
package/locales/en.json CHANGED
@@ -43,6 +43,8 @@
43
43
  "step3Desc": "Once you have saved your alias and imported your data, go to your Mastodon instance → Preferences → Account → <strong>Move to a different account</strong>. Enter your new fediverse handle and confirm. Mastodon will notify all your followers, and those whose servers support it will automatically re-follow you here. This step is irreversible — your old account will become a redirect.",
44
44
  "errorNoFile": "Please select a CSV file before importing.",
45
45
  "success": "Imported %d following, %d followers (%d failed).",
46
+ "failedList": "Could not resolve: %s",
47
+ "failedListSummary": "Failed handles",
46
48
  "aliasSuccess": "Alias saved — your actor document now includes this account as alsoKnownAs."
47
49
  }
48
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",
@@ -47,9 +47,7 @@
47
47
  {{ heading({ text: __("activitypub.migrate.step2Title"), level: 2 }) }}
48
48
  {{ prose({ text: __("activitypub.migrate.step2Desc") }) }}
49
49
 
50
- <form method="post" novalidate x-data="csvImport()">
51
- <input type="hidden" name="action" value="import">
52
- <input type="hidden" name="csvContent" :value="csvContent">
50
+ <div x-data="csvImport('{{ mountPath }}')">
53
51
 
54
52
  {{ checkboxes({
55
53
  name: "importTypes",
@@ -86,8 +84,31 @@
86
84
  </template>
87
85
  </div>
88
86
 
89
- {{ button({ text: __("activitypub.migrate.importButton") }) }}
90
- </form>
87
+ <button class="button" type="button"
88
+ :disabled="importing || !csvContent"
89
+ @click="startImport()">
90
+ <span x-show="!importing">{{ __("activitypub.migrate.importButton") }}</span>
91
+ <span x-show="importing" x-text="statusText"></span>
92
+ </button>
93
+
94
+ {# Result notification #}
95
+ <template x-if="resultType">
96
+ <div :class="'notification-banner notification-banner--' + resultType"
97
+ role="alert" style="margin-top: 1em">
98
+ <p x-text="resultText"></p>
99
+ <template x-if="resultErrors.length > 0">
100
+ <details style="margin-top: 0.5em">
101
+ <summary>{{ __("activitypub.migrate.failedListSummary") }} (<span x-text="resultErrors.length"></span>)</summary>
102
+ <ul style="margin-top: 0.5em; font-size: 0.875em">
103
+ <template x-for="err in resultErrors" :key="err">
104
+ <li x-text="err"></li>
105
+ </template>
106
+ </ul>
107
+ </details>
108
+ </template>
109
+ </div>
110
+ </template>
111
+ </div>
91
112
 
92
113
  <hr>
93
114
 
@@ -96,12 +117,17 @@
96
117
  {{ prose({ text: __("activitypub.migrate.step3Desc") }) }}
97
118
 
98
119
  <script>
99
- function csvImport() {
120
+ function csvImport(mountPath) {
100
121
  return {
101
122
  csvContent: '',
102
123
  fileName: '',
103
124
  lineCount: 0,
104
125
  fileError: '',
126
+ importing: false,
127
+ statusText: '',
128
+ resultType: '',
129
+ resultText: '',
130
+ resultErrors: [],
105
131
 
106
132
  readFile(event) {
107
133
  var self = this;
@@ -109,6 +135,9 @@
109
135
  self.fileName = '';
110
136
  self.lineCount = 0;
111
137
  self.fileError = '';
138
+ self.resultType = '';
139
+ self.resultText = '';
140
+ self.resultErrors = [];
112
141
 
113
142
  var file = event.target.files[0];
114
143
  if (!file) return;
@@ -130,6 +159,58 @@
130
159
  self.fileError = 'Could not read file';
131
160
  };
132
161
  reader.readAsText(file);
162
+ },
163
+
164
+ async startImport() {
165
+ var self = this;
166
+ self.importing = true;
167
+ self.resultType = '';
168
+ self.resultText = '';
169
+ self.resultErrors = [];
170
+ self.statusText = 'Importing\u2026';
171
+
172
+ // Collect checked import types
173
+ var checkboxes = document.querySelectorAll('input[name="importTypes"]:checked');
174
+ var importTypes = [];
175
+ for (var i = 0; i < checkboxes.length; i++) {
176
+ importTypes.push(checkboxes[i].value);
177
+ }
178
+
179
+ if (importTypes.length === 0) {
180
+ self.importing = false;
181
+ self.resultType = 'error';
182
+ self.resultText = 'Please select at least one import type.';
183
+ return;
184
+ }
185
+
186
+ try {
187
+ var res = await fetch(mountPath + '/admin/migrate/import', {
188
+ method: 'POST',
189
+ headers: { 'Content-Type': 'application/json' },
190
+ body: JSON.stringify({
191
+ csvContent: self.csvContent,
192
+ importTypes: importTypes
193
+ })
194
+ });
195
+
196
+ if (!res.ok) {
197
+ var errBody = await res.text();
198
+ throw new Error('Server error (' + res.status + '): ' + errBody);
199
+ }
200
+
201
+ var data = await res.json();
202
+ self.resultType = data.type;
203
+ self.resultText = 'Imported ' + (data.followingImported || 0) + ' following, '
204
+ + (data.followersImported || 0) + ' followers'
205
+ + (data.failed > 0 ? ' (' + data.failed + ' failed)' : '') + '.';
206
+ self.resultErrors = data.errors || [];
207
+ } catch (err) {
208
+ self.resultType = 'error';
209
+ self.resultText = err.message;
210
+ }
211
+
212
+ self.importing = false;
213
+ self.statusText = '';
133
214
  }
134
215
  };
135
216
  }