diff --git a/index.js b/index.js index 963a5d8..75312d3 100644 --- a/index.js +++ b/index.js @@ -9,7 +9,7 @@ import { dashboardController } from "./lib/controllers/dashboard.js"; import { followersController } from "./lib/controllers/followers.js"; import { followingController } from "./lib/controllers/following.js"; import { activitiesController } from "./lib/controllers/activities.js"; -import { migrateGetController, migratePostController } from "./lib/controllers/migrate.js"; +import { migrateGetController, migratePostController, migrateImportController } from "./lib/controllers/migrate.js"; const defaults = { mountPath: "/activitypub", @@ -179,7 +179,8 @@ export default class ActivityPubEndpoint { router.get("/admin/following", followingController(mp)); router.get("/admin/activities", activitiesController(mp)); router.get("/admin/migrate", migrateGetController(mp, this.options)); - router.post("/admin/migrate", express.urlencoded({ extended: true, limit: "5mb" }), migratePostController(mp, this.options)); + router.post("/admin/migrate", migratePostController(mp, this.options)); + router.post("/admin/migrate/import", express.json({ limit: "5mb" }), migrateImportController(mp, this.options)); return router; } diff --git a/lib/controllers/migrate.js b/lib/controllers/migrate.js index 1677279..3f36296 100644 --- a/lib/controllers/migrate.js +++ b/lib/controllers/migrate.js @@ -2,7 +2,8 @@ * Migration controller — handles Mastodon account migration UI. * * GET: shows the 3-step migration page - * POST: processes alias update or CSV file import + * POST /admin/migrate: alias update (small form POST) + * POST /admin/migrate/import: CSV import (JSON via fetch, bypasses body size limit) */ import { @@ -30,86 +31,16 @@ export function migrateGetController(mountPath, pluginOptions) { export function migratePostController(mountPath, pluginOptions) { return async (request, response, next) => { try { - const { application } = request.app.locals; - const action = request.body.action; let result = null; - if (action === "alias") { - // Update alsoKnownAs on the actor config - const aliasUrl = request.body.aliasUrl?.trim(); - if (aliasUrl) { - pluginOptions.alsoKnownAs = aliasUrl; - result = { - type: "success", - text: response.locals.__("activitypub.migrate.aliasSuccess"), - }; - } - } - - if (action === "import") { - const followingCollection = - application?.collections?.get("ap_following"); - const followersCollection = - application?.collections?.get("ap_followers"); - - const importFollowing = request.body.importTypes?.includes("following"); - const importFollowers = request.body.importTypes?.includes("followers"); - - // Read file content (submitted as text via client-side FileReader) - const fileContent = request.body.csvContent?.trim(); - if (!fileContent) { - result = { - type: "error", - text: response.locals.__("activitypub.migrate.errorNoFile"), - }; - } else { - let followingResult = { imported: 0, failed: 0, errors: [] }; - let followersResult = { imported: 0, failed: 0, errors: [] }; - - if (importFollowing && followingCollection) { - const handles = parseMastodonFollowingCsv(fileContent); - console.log(`[ActivityPub] Migration: parsed ${handles.length} following handles from CSV`); - followingResult = await bulkImportFollowing( - handles, - followingCollection, - ); - } - - if (importFollowers && followersCollection) { - const entries = parseMastodonFollowersList(fileContent); - console.log(`[ActivityPub] Migration: parsed ${entries.length} follower entries from CSV`); - followersResult = await bulkImportFollowers( - entries, - followersCollection, - ); - } - - const totalFailed = - followingResult.failed + followersResult.failed; - const allErrors = [ - ...followingResult.errors, - ...followersResult.errors, - ]; - - let text = response.locals - .__("activitypub.migrate.success") - .replace("%d", followingResult.imported) - .replace("%d", followersResult.imported) - .replace("%d", totalFailed); - - if (allErrors.length > 0) { - text += " " + response.locals - .__("activitypub.migrate.failedList") - .replace("%s", allErrors.join(", ")); - } - - result = { - type: totalFailed > 0 && followingResult.imported + followersResult.imported === 0 - ? "error" - : "success", - text, - }; - } + // Only handles alias updates (small payload, regular form POST) + const aliasUrl = request.body.aliasUrl?.trim(); + if (aliasUrl) { + pluginOptions.alsoKnownAs = aliasUrl; + result = { + type: "success", + text: response.locals.__("activitypub.migrate.aliasSuccess"), + }; } response.render("activitypub-migrate", { @@ -124,3 +55,63 @@ export function migratePostController(mountPath, pluginOptions) { }; } +/** + * JSON endpoint for CSV import — receives { csvContent, importTypes } + * via fetch() to bypass Express's app-level urlencoded body size limit. + */ +export function migrateImportController(mountPath, pluginOptions) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + const { csvContent, importTypes } = request.body; + + if (!csvContent?.trim()) { + return response.status(400).json({ + type: "error", + text: "No CSV content provided.", + }); + } + + const followingCollection = + application?.collections?.get("ap_following"); + const followersCollection = + application?.collections?.get("ap_followers"); + + const importFollowing = importTypes?.includes("following"); + const importFollowers = importTypes?.includes("followers"); + + let followingResult = { imported: 0, failed: 0, errors: [] }; + let followersResult = { imported: 0, failed: 0, errors: [] }; + + if (importFollowing && followingCollection) { + const handles = parseMastodonFollowingCsv(csvContent); + console.log(`[ActivityPub] Migration: parsed ${handles.length} following handles from CSV`); + followingResult = await bulkImportFollowing(handles, followingCollection); + } + + if (importFollowers && followersCollection) { + const entries = parseMastodonFollowersList(csvContent); + console.log(`[ActivityPub] Migration: parsed ${entries.length} follower entries from CSV`); + followersResult = await bulkImportFollowers(entries, followersCollection); + } + + const totalFailed = followingResult.failed + followersResult.failed; + const totalImported = followingResult.imported + followersResult.imported; + const allErrors = [...followingResult.errors, ...followersResult.errors]; + + return response.json({ + type: totalFailed > 0 && totalImported === 0 ? "error" : "success", + followingImported: followingResult.imported, + followersImported: followersResult.imported, + failed: totalFailed, + errors: allErrors, + }); + } catch (error) { + console.error("[ActivityPub] Migration import error:", error.message); + return response.status(500).json({ + type: "error", + text: error.message, + }); + } + }; +} diff --git a/locales/en.json b/locales/en.json index dc96b12..b2ed946 100644 --- a/locales/en.json +++ b/locales/en.json @@ -44,6 +44,7 @@ "errorNoFile": "Please select a CSV file before importing.", "success": "Imported %d following, %d followers (%d failed).", "failedList": "Could not resolve: %s", + "failedListSummary": "Failed handles", "aliasSuccess": "Alias saved — your actor document now includes this account as alsoKnownAs." } } diff --git a/package.json b/package.json index a180233..28247e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "0.1.8", + "version": "0.1.9", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-migrate.njk b/views/activitypub-migrate.njk index c103e1d..39c1f23 100644 --- a/views/activitypub-migrate.njk +++ b/views/activitypub-migrate.njk @@ -47,9 +47,7 @@ {{ heading({ text: __("activitypub.migrate.step2Title"), level: 2 }) }} {{ prose({ text: __("activitypub.migrate.step2Desc") }) }} -
+ + + {# Result notification #} + +