Files
svemagie 0acd324070 fix: normalize aliasUrl to absolute URL before storing alsoKnownAs
Without https://, jsonld rejects the value as a relative object
reference during signature verification, breaking Mastodon migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 08:24:41 +01:00

157 lines
5.1 KiB
JavaScript

/**
* Migration controller — handles Mastodon account migration UI.
*
* GET: shows the 3-step migration page
* POST /admin/migrate: alias update (small form POST)
* POST /admin/migrate/import: CSV import (JSON via fetch, bypasses body size limit)
*/
import {
bulkImportFollowing,
bulkImportFollowers,
} from "../migration.js";
export function migrateGetController(mountPath, pluginOptions) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const profileCollection = application?.collections?.get("ap_profile");
const profile = profileCollection
? (await profileCollection.findOne({})) || {}
: {};
const currentAlias = profile.alsoKnownAs?.[0] || "";
response.render("activitypub-migrate", {
title: response.locals.__("activitypub.migrate.title"),
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
mountPath,
currentAlias,
result: null,
});
} catch (error) {
next(error);
}
};
}
export function migratePostController(mountPath, pluginOptions) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const profileCollection = application?.collections?.get("ap_profile");
let result = null;
let aliasUrl = request.body.aliasUrl?.trim();
// Ensure aliasUrl is an absolute URL — prepend https:// if missing scheme
if (aliasUrl && !/^https?:\/\//i.test(aliasUrl)) {
aliasUrl = `https://${aliasUrl}`;
}
const submittedAliasField = Object.prototype.hasOwnProperty.call(
request.body || {},
"aliasUrl",
);
// allow clearing alsoKnownAs alias by submitting empty value
if (profileCollection && submittedAliasField) {
if (aliasUrl) {
await profileCollection.updateOne(
{},
{ $set: { alsoKnownAs: [aliasUrl] } },
{ upsert: true },
);
result = {
type: "success",
text: response.locals.__("activitypub.migrate.aliasSuccess"),
};
} else {
await profileCollection.updateOne(
{},
{ $set: { alsoKnownAs: [] } },
{ upsert: true },
);
result = {
type: "success",
text: "Alias removed - alsoKnownAs is now empty.",
};
}
}
const profile = profileCollection
? (await profileCollection.findOne({})) || {}
: {};
const currentAlias = profile.alsoKnownAs?.[0] || "";
response.render("activitypub-migrate", {
title: response.locals.__("activitypub.migrate.title"),
parent: { href: mountPath, text: response.locals.__("activitypub.title") },
mountPath,
currentAlias,
result,
});
} catch (error) {
next(error);
}
};
}
/**
* JSON endpoint for import — receives { handles, importTypes }.
* CSV is parsed client-side to extract handles only, keeping the
* JSON payload small enough for Express's default body parser limit.
*/
export function migrateImportController(mountPath, pluginOptions) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const { handles, importTypes } = request.body;
if (!Array.isArray(handles) || handles.length === 0) {
return response.status(400).json({
type: "error",
text: "No handles 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) {
console.log(`[ActivityPub] Migration: importing ${handles.length} following handles`);
followingResult = await bulkImportFollowing(handles, followingCollection);
}
if (importFollowers && followersCollection) {
console.log(`[ActivityPub] Migration: importing ${handles.length} follower entries`);
followersResult = await bulkImportFollowers(handles, 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,
});
}
};
}