refactor: delete 27 consolidated/dead AP patch scripts
Deploy Indiekit Server / deploy (push) Successful in 1m19s
Deploy Indiekit Server / deploy (push) Successful in 1m19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: check ap_actor_cache in GET /api/v1/accounts/:id before returning 404.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* resolveActorData() only searches followers, following, and ap_timeline.
|
|
||||||
* When a user searches for a brand-new remote account (resolve=true), the
|
|
||||||
* search call populates ap_actor_cache but the actor isn't in any of those
|
|
||||||
* three collections yet. So the next request — GET /api/v1/accounts/:id from
|
|
||||||
* Phanpy to load the profile page — returns 404, leaving the follow button
|
|
||||||
* non-functional even though the cache entry is present.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* After resolveActorData returns null, check the in-memory idToUrl map and
|
|
||||||
* ap_actor_cache (both populated by resolveRemoteAccount). If a URL is found,
|
|
||||||
* call resolveRemoteAccount directly and return the result.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-account-lookup-cache-fallback";
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` return res.status(404).json({ error: "Record not found" });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` // Check ap_actor_cache — populated by resolveRemoteAccount after search/lookup [patch] ap-account-lookup-cache-fallback
|
|
||||||
const actorCacheUrl = getActorUrlFromId(id)
|
|
||||||
|| (collections.ap_actor_cache ? (await collections.ap_actor_cache.findOne({ _id: id }))?.actorUrl : null);
|
|
||||||
if (actorCacheUrl) {
|
|
||||||
const remoteAccount = await resolveRemoteAccount(actorCacheUrl, pluginOptions, baseUrl, collections);
|
|
||||||
if (remoteAccount) return res.json(remoteAccount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(404).json({ error: "Record not found" });
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checked += 1;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-account-lookup-cache-fallback: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upstream may have already added a cachedUrl block in GET /api/v1/accounts/:id
|
|
||||||
// (e.g. fix: ap actor cache commit). If so, the functionality is already present — skip.
|
|
||||||
if (source.includes("const cachedUrl = getActorUrlFromId(id)")) {
|
|
||||||
console.log(`[postinstall] patch-ap-account-lookup-cache-fallback: already fixed upstream in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] patch-ap-account-lookup-cache-fallback: target snippet not found in ${filePath} — skipping`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-account-lookup-cache-fallback: no changes applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-account-lookup-cache-fallback to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-account-lookup-cache-fallback: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-account-lookup-cache-fallback: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] patch-ap-account-lookup-cache-fallback: patched ${patched}/${checked} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: await the ap_actor_cache write in resolveRemoteAccount.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* resolveRemoteAccount() populates the in-memory idToUrl map synchronously
|
|
||||||
* (via cacheAccountStats), so same-session follow requests find the actor.
|
|
||||||
* But the ap_actor_cache MongoDB write is fire-and-forget. If the server
|
|
||||||
* restarts between search and follow (or if the client caches the search
|
|
||||||
* result), the in-memory cache is gone and the MongoDB fallback may not
|
|
||||||
* have been written yet → resolveActorUrl returns null → 404.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* Await the ap_actor_cache upsert (still catch errors so it's non-fatal).
|
|
||||||
* This ensures the entry is in MongoDB before the search response is sent,
|
|
||||||
* making follow reliable even after server restarts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-actor-cache-await";
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts
|
|
||||||
if (collections?.ap_actor_cache && actorUrl) {
|
|
||||||
const hashId = remoteActorId(actorUrl);
|
|
||||||
collections.ap_actor_cache.updateOne(
|
|
||||||
{ _id: hashId },
|
|
||||||
{ $set: { actorUrl, updatedAt: new Date() } },
|
|
||||||
{ upsert: true },
|
|
||||||
).catch(() => {}); // fire-and-forget, non-fatal
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts
|
|
||||||
// [patch] ap-actor-cache-await
|
|
||||||
if (collections?.ap_actor_cache && actorUrl) {
|
|
||||||
const hashId = remoteActorId(actorUrl);
|
|
||||||
await collections.ap_actor_cache.updateOne(
|
|
||||||
{ _id: hashId },
|
|
||||||
{ $set: { actorUrl, updatedAt: new Date() } },
|
|
||||||
{ upsert: true },
|
|
||||||
).catch(() => {}); // non-fatal, but now awaited so entry exists before response
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checked += 1;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-actor-cache-await: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] patch-ap-actor-cache-await: target snippet not found in ${filePath} — skipping`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-actor-cache-await: no changes applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-actor-cache-await to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-actor-cache-await: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-actor-cache-await: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] patch-ap-actor-cache-await: patched ${patched}/${checked} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix hardcoded defaultChecked handles in AP reader compose controller.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* composeController() in compose.js sets target.defaultChecked using a
|
|
||||||
* hardcoded name comparison:
|
|
||||||
*
|
|
||||||
* target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
|
|
||||||
*
|
|
||||||
* These are the original developer's handles and will never match any target
|
|
||||||
* on this installation. As a result, ALL syndication checkboxes in the AP
|
|
||||||
* reader compose form are rendered unchecked, so replies composed through the
|
|
||||||
* AP reader are never syndicated to ActivityPub.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* Replace the hardcoded comparison with `target.checked === true`.
|
|
||||||
* The Micropub config endpoint (q=config) already returns each syndicator's
|
|
||||||
* `checked` state. The AP syndicator has `checked: true` in indiekit.config.mjs,
|
|
||||||
* so the AP checkbox will be pre-checked by default, matching the same behaviour
|
|
||||||
* as the microsub reader compose form.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-compose-default-checked";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` // Default-check only AP (Fedify) and Bluesky targets
|
|
||||||
// "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky
|
|
||||||
for (const target of syndicationTargets) {
|
|
||||||
const name = target.name || "";
|
|
||||||
target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net";
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` // Pre-check syndication targets based on their configured checked state ${MARKER}
|
|
||||||
for (const target of syndicationTargets) { ${MARKER}
|
|
||||||
target.defaultChecked = target.checked === true; ${MARKER}
|
|
||||||
} ${MARKER}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-compose-default-checked: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-compose-default-checked: target snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-compose-default-checked: no changes in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-compose-default-checked to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-compose-default-checked: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-compose-default-checked: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-compose-default-checked: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: override Fedify request URL with the configured publication URL.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* fromExpressRequest() in federation-bridge.js builds the Request URL as
|
|
||||||
* `${req.protocol}://${req.get("host")}${req.originalUrl}`. Fedify only handles
|
|
||||||
* requests whose URL matches its configured base URL (https://blog.giersig.eu).
|
|
||||||
* If nginx does not forward `Host: blog.giersig.eu` and `X-Forwarded-Proto: https`,
|
|
||||||
* the URL Fedify sees will be wrong (e.g. http://127.0.0.1:3000/...) and Fedify
|
|
||||||
* calls next() → the request falls through to auth middleware → returns 302 to
|
|
||||||
* the login page. This breaks webfinger, nodeinfo, actor lookups, and AP inbox
|
|
||||||
* delivery for any server that cannot follow the redirect.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* - Add an optional third parameter `publicationUrl` to createFedifyMiddleware().
|
|
||||||
* - Pass it through to fromExpressRequest(), which uses it as the URL base when
|
|
||||||
* provided, ignoring req.protocol / req.get("host") entirely.
|
|
||||||
* - In index.js, pass `this._publicationUrl` to createFedifyMiddleware() so all
|
|
||||||
* Fedify-delegated requests use the correct canonical URL.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// ap-base-url patch";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const indexCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Patches for federation-bridge.js
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const OLD_FROM_EXPRESS_SIG = `export function fromExpressRequest(req) {
|
|
||||||
const url = \`\${req.protocol}://\${req.get("host")}\${req.originalUrl}\`;`;
|
|
||||||
|
|
||||||
const NEW_FROM_EXPRESS_SIG = `export function fromExpressRequest(req, baseUrl) { // ap-base-url patch
|
|
||||||
const url = baseUrl
|
|
||||||
? \`\${baseUrl.replace(/\\/$/, "")}\${req.originalUrl}\` // ap-base-url patch
|
|
||||||
: \`\${req.protocol}://\${req.get("host")}\${req.originalUrl}\`;`;
|
|
||||||
|
|
||||||
const OLD_MIDDLEWARE_SIG = `export function createFedifyMiddleware(federation, contextDataFactory) {`;
|
|
||||||
|
|
||||||
const NEW_MIDDLEWARE_SIG = `export function createFedifyMiddleware(federation, contextDataFactory, publicationUrl) { // ap-base-url patch`;
|
|
||||||
|
|
||||||
const OLD_FROM_EXPRESS_CALL = ` const request = fromExpressRequest(req);`;
|
|
||||||
|
|
||||||
const NEW_FROM_EXPRESS_CALL = ` const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Patch for index.js
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const OLD_INDEX_CALL = ` this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));`;
|
|
||||||
|
|
||||||
const NEW_INDEX_CALL = ` this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}), this._publicationUrl); // ap-base-url patch`;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let patched = 0;
|
|
||||||
let checked = 0;
|
|
||||||
|
|
||||||
// Patch federation-bridge.js
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-federation-bridge-base-url: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated = source;
|
|
||||||
let changed = false;
|
|
||||||
|
|
||||||
if (updated.includes(OLD_FROM_EXPRESS_SIG)) {
|
|
||||||
updated = updated.replace(OLD_FROM_EXPRESS_SIG, NEW_FROM_EXPRESS_SIG);
|
|
||||||
changed = true;
|
|
||||||
} else {
|
|
||||||
console.warn(`[postinstall] patch-ap-federation-bridge-base-url: fromExpressRequest signature not found in ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updated.includes(OLD_MIDDLEWARE_SIG)) {
|
|
||||||
updated = updated.replace(OLD_MIDDLEWARE_SIG, NEW_MIDDLEWARE_SIG);
|
|
||||||
changed = true;
|
|
||||||
} else {
|
|
||||||
console.warn(`[postinstall] patch-ap-federation-bridge-base-url: createFedifyMiddleware signature not found in ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updated.includes(OLD_FROM_EXPRESS_CALL)) {
|
|
||||||
updated = updated.replace(OLD_FROM_EXPRESS_CALL, NEW_FROM_EXPRESS_CALL);
|
|
||||||
changed = true;
|
|
||||||
} else {
|
|
||||||
console.warn(`[postinstall] patch-ap-federation-bridge-base-url: fromExpressRequest call not found in ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!changed || updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-federation-bridge-base-url: no changes applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-federation-bridge-base-url to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch index.js
|
|
||||||
for (const filePath of indexCandidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-federation-bridge-base-url: index.js already patched at ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_INDEX_CALL)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-federation-bridge-base-url: createFedifyMiddleware call not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_INDEX_CALL, NEW_INDEX_CALL);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-federation-bridge-base-url: no changes in index.js at ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-federation-bridge-base-url (index.js) to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-federation-bridge-base-url: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-federation-bridge-base-url: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-federation-bridge-base-url: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: add inbox delivery diagnostics.
|
|
||||||
*
|
|
||||||
* Problems:
|
|
||||||
* 1. The ["fedify","federation","inbox"] LogTape category is hardcoded to
|
|
||||||
* lowestLevel "fatal", hiding HTTP Signature verification failures (401s).
|
|
||||||
* Remote servers that receive 401s stop retrying → activities are lost.
|
|
||||||
* 2. No request-level logging for incoming inbox POSTs, so we can't tell
|
|
||||||
* whether remote servers are even attempting delivery.
|
|
||||||
*
|
|
||||||
* Fix A (federation-setup.js):
|
|
||||||
* Change inbox log category from "fatal" → "error".
|
|
||||||
* Real verification failures (wrong key, clock skew, digest mismatch) surface.
|
|
||||||
* High-volume 404/410 key-fetch warnings from deleted actors stay silent.
|
|
||||||
*
|
|
||||||
* Fix B (federation-bridge.js):
|
|
||||||
* Add a console.info before fromExpressRequest() that logs every POST to an
|
|
||||||
* inbox path (path + content-type + raw body length). Fires BEFORE Fedify's
|
|
||||||
* signature check, confirming whether remote servers reach our inbox at all.
|
|
||||||
* Guarded by AP_LOG_LEVEL=debug or AP_DEBUG=1 to keep production logs quiet.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER_A = "// [patch] ap-inbox-delivery-debug-A";
|
|
||||||
const MARKER_B = "// [patch] ap-inbox-delivery-debug-B";
|
|
||||||
|
|
||||||
// ── Fix A: federation-setup.js — inbox logger level ──────────────────────────
|
|
||||||
|
|
||||||
const setupCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_INBOX_LOGGER = ` {
|
|
||||||
// Noise guard: HTTP Signature verification failures are expected for
|
|
||||||
// incoming activities from servers with expired/gone keys (e.g. deleted
|
|
||||||
// actors, migrated servers). These produce high log volume with no
|
|
||||||
// actionable signal — suppress everything below fatal.
|
|
||||||
category: ["fedify", "federation", "inbox"],
|
|
||||||
sinks: ["console"],
|
|
||||||
lowestLevel: "fatal",
|
|
||||||
},`;
|
|
||||||
|
|
||||||
const NEW_INBOX_LOGGER = ` {
|
|
||||||
// Surfacing real verification failures (wrong key, clock skew, digest
|
|
||||||
// mismatch) at "error" level while keeping high-volume key-fetch
|
|
||||||
// 404/410 warnings from deleted actors silent. ${MARKER_A}
|
|
||||||
category: ["fedify", "federation", "inbox"],
|
|
||||||
sinks: ["console"],
|
|
||||||
lowestLevel: "error",
|
|
||||||
},`;
|
|
||||||
|
|
||||||
// ── Fix B: federation-bridge.js — request-level inbox logging ────────────────
|
|
||||||
|
|
||||||
const bridgeCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Insert a debug log right before "const request = fromExpressRequest(req, publicationUrl);"
|
|
||||||
// This is patched by ap-base-url already so that comment marker is present.
|
|
||||||
const OLD_BRIDGE_REQUEST = ` const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`;
|
|
||||||
|
|
||||||
const NEW_BRIDGE_REQUEST = ` // Log incoming inbox POSTs before Fedify signature check. ${MARKER_B}
|
|
||||||
// Enabled by AP_LOG_LEVEL=debug or AP_DEBUG=1.
|
|
||||||
if (
|
|
||||||
(process.env.AP_LOG_LEVEL === "debug" || process.env.AP_DEBUG === "1") &&
|
|
||||||
req.method === "POST" &&
|
|
||||||
(req.path.includes("/inbox") || req.path.includes("/users/"))
|
|
||||||
) {
|
|
||||||
const _bct = (req.headers["content-type"] || "").split(";")[0].trim();
|
|
||||||
const _bsz = req._rawBody?.length ?? (req.body ? "pre-parsed" : "none");
|
|
||||||
console.info(\`[AP-inbox] POST \${req.path} ct=\${_bct} body=\${_bsz}B\`);
|
|
||||||
}
|
|
||||||
const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`;
|
|
||||||
|
|
||||||
async function exists(p) {
|
|
||||||
try { await access(p); return true; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyPatch(candidates, oldSnippet, newSnippet, label, marker) {
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked++;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
if (source.includes(marker)) {
|
|
||||||
console.log(`[postinstall] patch-ap-inbox-delivery-debug: ${label} already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!source.includes(oldSnippet)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-inbox-delivery-debug: ${label} snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8");
|
|
||||||
patched++;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-inbox-delivery-debug (${label}) to ${filePath}`);
|
|
||||||
}
|
|
||||||
return { checked, patched };
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = await applyPatch(setupCandidates, OLD_INBOX_LOGGER, NEW_INBOX_LOGGER, "inbox-logger-level", MARKER_A);
|
|
||||||
const b = await applyPatch(bridgeCandidates, OLD_BRIDGE_REQUEST, NEW_BRIDGE_REQUEST, "bridge-request-log", MARKER_B);
|
|
||||||
|
|
||||||
const total = a.checked + b.checked;
|
|
||||||
const totalPatched = a.patched + b.patched;
|
|
||||||
|
|
||||||
if (total === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-inbox-delivery-debug: no target files found");
|
|
||||||
} else if (totalPatched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-inbox-delivery-debug: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-inbox-delivery-debug: patched ${totalPatched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix inbound reply/like/boost handling — publicationUrl missing in inbox handlers.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* setupFederation() passes `publicationUrl` to its own scope but does NOT
|
|
||||||
* pass it to registerInboxListeners(). All inbox handlers (handleCreate,
|
|
||||||
* handleLike, handleAnnounce) read `collections._publicationUrl` to gate
|
|
||||||
* notifications and timeline storage, but that property is never set on the
|
|
||||||
* collections object.
|
|
||||||
*
|
|
||||||
* Consequence:
|
|
||||||
* - handleCreate: `if (pubUrl && inReplyTo.startsWith(pubUrl))` is always
|
|
||||||
* false → reply notifications are never created.
|
|
||||||
* - handleAnnounce: boost notifications for our content never created.
|
|
||||||
* - handleCreate: replies to our posts from non-followed accounts are never
|
|
||||||
* stored in ap_timeline → invisible in Mastodon client conversation views.
|
|
||||||
*
|
|
||||||
* Fix A (federation-setup.js):
|
|
||||||
* Set `collections._publicationUrl = publicationUrl` immediately before
|
|
||||||
* registerInboxListeners() so the value flows through to all handlers.
|
|
||||||
*
|
|
||||||
* Fix B (inbox-handlers.js):
|
|
||||||
* In handleCreate, add an else-if branch that stores replies to our own posts
|
|
||||||
* in ap_timeline even when the replier is not in ap_following. This runs only
|
|
||||||
* when pubUrl is correctly set (Fix A).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-inbox-publication-url";
|
|
||||||
|
|
||||||
// ── Fix A: federation-setup.js ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const federationCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_REGISTER = ` registerInboxListeners(inboxChain, {
|
|
||||||
collections,
|
|
||||||
handle,
|
|
||||||
storeRawActivities,
|
|
||||||
});`;
|
|
||||||
|
|
||||||
const NEW_REGISTER = ` // Expose publicationUrl on collections so inbox handlers can gate ${MARKER}
|
|
||||||
// notifications/timeline-storage to our own content only.
|
|
||||||
collections._publicationUrl = publicationUrl;
|
|
||||||
registerInboxListeners(inboxChain, {
|
|
||||||
collections,
|
|
||||||
handle,
|
|
||||||
storeRawActivities,
|
|
||||||
});`;
|
|
||||||
|
|
||||||
// ── Fix B: inbox-handlers.js ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const handlersCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-handlers.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-handlers.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_FOLLOWED_TAGS = ` } else if (collections.ap_followed_tags) {
|
|
||||||
// Not a followed account — check if the post's hashtags match any followed tags`;
|
|
||||||
|
|
||||||
const NEW_FOLLOWED_TAGS = ` } else if (pubUrl && inReplyTo && inReplyTo.startsWith(pubUrl)) {
|
|
||||||
// Reply to our post from a non-followed account — store in timeline ${MARKER}
|
|
||||||
// so it appears in the Mastodon client API's conversation/notification view.
|
|
||||||
try {
|
|
||||||
const timelineItem = await extractObjectData(object, {
|
|
||||||
actorFallback: actorObj,
|
|
||||||
documentLoader: authLoader,
|
|
||||||
});
|
|
||||||
timelineItem.visibility = computeVisibility(object);
|
|
||||||
await addTimelineItem(collections, timelineItem);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[inbox-handlers] Failed to store reply timeline item:", error.message);
|
|
||||||
}
|
|
||||||
} else if (collections.ap_followed_tags) {
|
|
||||||
// Not a followed account — check if the post's hashtags match any followed tags`;
|
|
||||||
|
|
||||||
async function exists(p) {
|
|
||||||
try { await access(p); return true; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function patch(candidates, oldSnippet, newSnippet, label) {
|
|
||||||
let checked = 0; let patched = 0;
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked++;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-inbox-publication-url: ${label} already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!source.includes(oldSnippet)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-inbox-publication-url: ${label} snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8");
|
|
||||||
patched++;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-inbox-publication-url (${label}) to ${filePath}`);
|
|
||||||
}
|
|
||||||
return { checked, patched };
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = await patch(federationCandidates, OLD_REGISTER, NEW_REGISTER, "set _publicationUrl");
|
|
||||||
const b = await patch(handlersCandidates, OLD_FOLLOWED_TAGS, NEW_FOLLOWED_TAGS, "store reply from non-follower");
|
|
||||||
|
|
||||||
const total = a.patched + b.patched;
|
|
||||||
if (a.checked + b.checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-inbox-publication-url: no target files found");
|
|
||||||
} else if (total === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-inbox-publication-url: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-inbox-publication-url: patched ${total} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix interaction state in GET /api/v1/accounts/:id/statuses.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* The account statuses route loads interaction state (liked/boosted/bookmarked)
|
|
||||||
* for the returned timeline items. It builds the lookup URL list correctly
|
|
||||||
* (both item.uid and item.url), but then adds ix.objectUrl directly to the
|
|
||||||
* favouritedIds/rebloggedIds/bookmarkedIds Sets. serializeStatus() checks
|
|
||||||
* favouritedIds.has(item.uid) — so when ix.objectUrl === item.url and
|
|
||||||
* item.url !== item.uid, the check fails and the post shows as not-liked.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* Build a urlToUid map (same approach as loadInteractionState in timelines.js)
|
|
||||||
* and resolve ix.objectUrl to the canonical uid before adding to the Sets.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-interactions-accounts-uid";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean));
|
|
||||||
if (lookupUrls.length > 0) {
|
|
||||||
const interactions = await collections.ap_interactions
|
|
||||||
.find({ objectUrl: { $in: lookupUrls } })
|
|
||||||
.toArray();
|
|
||||||
for (const ix of interactions) {
|
|
||||||
if (ix.type === "like") favouritedIds.add(ix.objectUrl);
|
|
||||||
else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl);
|
|
||||||
else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl);
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` const urlToUid = new Map(); ${MARKER}
|
|
||||||
for (const i of items) {
|
|
||||||
if (i.uid) {
|
|
||||||
urlToUid.set(i.uid, i.uid);
|
|
||||||
if (i.url && i.url !== i.uid) urlToUid.set(i.url, i.uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const lookupUrls = [...urlToUid.keys()];
|
|
||||||
if (lookupUrls.length > 0) {
|
|
||||||
const interactions = await collections.ap_interactions
|
|
||||||
.find({ objectUrl: { $in: lookupUrls } })
|
|
||||||
.toArray();
|
|
||||||
for (const ix of interactions) {
|
|
||||||
const uid = urlToUid.get(ix.objectUrl) || ix.objectUrl;
|
|
||||||
if (ix.type === "like") favouritedIds.add(uid);
|
|
||||||
else if (ix.type === "boost") rebloggedIds.add(uid);
|
|
||||||
else if (ix.type === "bookmark") bookmarkedIds.add(uid);
|
|
||||||
}
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-accounts-uid: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-interactions-accounts-uid: snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-interactions-accounts-uid to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-interactions-accounts-uid: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-interactions-accounts-uid: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-accounts-uid: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: preserve liked/bookmarked/boosted items during timeline cleanup.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* cleanupTimeline() removes old remote posts from ap_timeline and also
|
|
||||||
* deletes their ap_interactions entries. Any post the user has liked,
|
|
||||||
* bookmarked, or boosted disappears from GET /api/v1/favourites and
|
|
||||||
* GET /api/v1/bookmarks after the next daily cleanup run.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* Before deleting, fetch the set of objectUrls that have ap_interactions
|
|
||||||
* entries (likes, bookmarks, boosts). Filter those out of the deletion
|
|
||||||
* candidate list so they are preserved in ap_timeline (and their
|
|
||||||
* ap_interactions entries are never touched).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-interactions-cleanup-preserve";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/timeline-cleanup.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/timeline-cleanup.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
|
|
||||||
|
|
||||||
// Delete old timeline items by UID
|
|
||||||
const deleteResult = await collections.ap_timeline.deleteMany({
|
|
||||||
_id: { $in: toDelete.map((item) => item._id) },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up stale interactions for removed items
|
|
||||||
let interactionsRemoved = 0;
|
|
||||||
if (removedUids.length > 0 && collections.ap_interactions) {
|
|
||||||
const interactionResult = await collections.ap_interactions.deleteMany({
|
|
||||||
objectUrl: { $in: removedUids },
|
|
||||||
});
|
|
||||||
interactionsRemoved = interactionResult.deletedCount || 0;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
|
|
||||||
|
|
||||||
// Preserve items the user has interacted with (liked, bookmarked, boosted). ${MARKER}
|
|
||||||
// Deleting them would silently remove entries from the Favourites/Bookmarks pages.
|
|
||||||
let interactedUids = new Set();
|
|
||||||
if (removedUids.length > 0 && collections.ap_interactions) {
|
|
||||||
const interacted = await collections.ap_interactions.distinct("objectUrl");
|
|
||||||
interactedUids = new Set(interacted);
|
|
||||||
}
|
|
||||||
const itemsToDelete = toDelete.filter((item) => !interactedUids.has(item.uid));
|
|
||||||
const uidsToDelete = itemsToDelete.map((item) => item.uid).filter(Boolean);
|
|
||||||
|
|
||||||
if (!itemsToDelete.length) {
|
|
||||||
return { removed: 0, interactionsRemoved: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old timeline items by UID
|
|
||||||
const deleteResult = await collections.ap_timeline.deleteMany({
|
|
||||||
_id: { $in: itemsToDelete.map((item) => item._id) },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up stale interactions for removed items
|
|
||||||
let interactionsRemoved = 0;
|
|
||||||
if (uidsToDelete.length > 0 && collections.ap_interactions) {
|
|
||||||
const interactionResult = await collections.ap_interactions.deleteMany({
|
|
||||||
objectUrl: { $in: uidsToDelete },
|
|
||||||
});
|
|
||||||
interactionsRemoved = interactionResult.deletedCount || 0;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-cleanup-preserve: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-interactions-cleanup-preserve: snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-interactions-cleanup-preserve to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-interactions-cleanup-preserve: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-interactions-cleanup-preserve: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-cleanup-preserve: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: load real interaction state for thread context ancestors/descendants.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* GET /api/v1/statuses/:id/context serializes all ancestors and descendants
|
|
||||||
* using emptyInteractions (all empty Sets). Any post in the thread that the
|
|
||||||
* user has liked/bookmarked/boosted shows as not-liked/not-bookmarked/not-boosted.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* Replace emptyInteractions with a batch lookup against ap_interactions for
|
|
||||||
* all items in the thread (same approach as loadInteractionState in timelines.js).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-interactions-context-state";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` // Serialize all items
|
|
||||||
const emptyInteractions = {
|
|
||||||
favouritedIds: new Set(),
|
|
||||||
rebloggedIds: new Set(),
|
|
||||||
bookmarkedIds: new Set(),
|
|
||||||
pinnedIds: new Set(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const allItems = [...ancestors, ...descendants];
|
|
||||||
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
|
|
||||||
const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap };`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` // Serialize all items
|
|
||||||
const allItems = [...ancestors, ...descendants];
|
|
||||||
const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems);
|
|
||||||
|
|
||||||
// Load real interaction state for thread context ${MARKER}
|
|
||||||
const ctxFavouritedIds = new Set();
|
|
||||||
const ctxRebloggedIds = new Set();
|
|
||||||
const ctxBookmarkedIds = new Set();
|
|
||||||
if (allItems.length > 0 && collections.ap_interactions) {
|
|
||||||
const ctxUrlToUid = new Map();
|
|
||||||
for (const ci of allItems) {
|
|
||||||
if (ci.uid) { ctxUrlToUid.set(ci.uid, ci.uid); }
|
|
||||||
if (ci.url && ci.url !== ci.uid) { ctxUrlToUid.set(ci.url, ci.uid || ci.url); }
|
|
||||||
}
|
|
||||||
const ctxLookupUrls = [...ctxUrlToUid.keys()];
|
|
||||||
if (ctxLookupUrls.length > 0) {
|
|
||||||
const ctxInteractions = await collections.ap_interactions
|
|
||||||
.find({ objectUrl: { $in: ctxLookupUrls } })
|
|
||||||
.toArray();
|
|
||||||
for (const ci of ctxInteractions) {
|
|
||||||
const uid = ctxUrlToUid.get(ci.objectUrl) || ci.objectUrl;
|
|
||||||
if (ci.type === "like") ctxFavouritedIds.add(uid);
|
|
||||||
else if (ci.type === "boost") ctxRebloggedIds.add(uid);
|
|
||||||
else if (ci.type === "bookmark") ctxBookmarkedIds.add(uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const serializeOpts = { baseUrl, favouritedIds: ctxFavouritedIds, rebloggedIds: ctxRebloggedIds, bookmarkedIds: ctxBookmarkedIds, pinnedIds: new Set(), replyIdMap, replyAccountIdMap };`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-context-state: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-interactions-context-state: snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-interactions-context-state to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-interactions-context-state: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-interactions-context-state: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-context-state: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: guard sendActivity calls in likePost and boostPost with try/catch.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* - likePost: `ctx.sendActivity({ identifier }, recipient, like, ...)` is not
|
|
||||||
* wrapped in try/catch. If Fedify or the underlying queue (Redis) throws, the
|
|
||||||
* error propagates up through the route handler → 500, and the ap_interactions
|
|
||||||
* DB write that follows is never reached (like not recorded locally either).
|
|
||||||
*
|
|
||||||
* - boostPost: `ctx.sendActivity({ identifier }, "followers", announce, ...)` is
|
|
||||||
* the very first await in the function — also not wrapped. Same consequence:
|
|
||||||
* any delivery error aborts the function before the DB write, returning 500.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* Wrap both sendActivity calls in try/catch so federation delivery failures are
|
|
||||||
* non-fatal. The interaction is still recorded in ap_interactions so the client
|
|
||||||
* sees the correct UI state. Delivery of the boost to the original post author is
|
|
||||||
* already guarded (separate try/catch added previously).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-interactions-send-guard";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Change 1: guard likePost sendActivity ─────────────────────────────────────
|
|
||||||
|
|
||||||
const OLD_LIKE_SEND = ` if (recipient) {
|
|
||||||
await ctx.sendActivity({ identifier: handle }, recipient, like, {
|
|
||||||
orderingKey: targetUrl,
|
|
||||||
});
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW_LIKE_SEND = ` if (recipient) {
|
|
||||||
try { ${MARKER}
|
|
||||||
await ctx.sendActivity({ identifier: handle }, recipient, like, {
|
|
||||||
orderingKey: targetUrl,
|
|
||||||
});
|
|
||||||
} catch { /* delivery failed — interaction still recorded locally */ }
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// ── Change 2: guard boostPost sendActivity("followers") ──────────────────────
|
|
||||||
|
|
||||||
const OLD_BOOST_SEND = ` // Send to followers
|
|
||||||
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
|
|
||||||
preferSharedInbox: true,
|
|
||||||
syncCollection: true,
|
|
||||||
orderingKey: targetUrl,
|
|
||||||
});`;
|
|
||||||
|
|
||||||
const NEW_BOOST_SEND = ` // Send to followers
|
|
||||||
try { ${MARKER}
|
|
||||||
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
|
|
||||||
preferSharedInbox: true,
|
|
||||||
syncCollection: true,
|
|
||||||
orderingKey: targetUrl,
|
|
||||||
});
|
|
||||||
} catch { /* delivery failed — interaction still recorded locally */ }`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-send-guard: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated = source;
|
|
||||||
let changes = 0;
|
|
||||||
|
|
||||||
if (!updated.includes(OLD_LIKE_SEND)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-interactions-send-guard: likePost snippet not found in ${filePath}`);
|
|
||||||
} else {
|
|
||||||
updated = updated.replace(OLD_LIKE_SEND, NEW_LIKE_SEND);
|
|
||||||
changes++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!updated.includes(OLD_BOOST_SEND)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-interactions-send-guard: boostPost snippet not found in ${filePath}`);
|
|
||||||
} else {
|
|
||||||
updated = updated.replace(OLD_BOOST_SEND, NEW_BOOST_SEND);
|
|
||||||
changes++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changes === 0) {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-send-guard: no changes in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-interactions-send-guard to ${filePath} (${changes}/2 changes)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-interactions-send-guard: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-interactions-send-guard: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-interactions-send-guard: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix DELETE /api/v1/statuses/:id — two bugs.
|
|
||||||
*
|
|
||||||
* Bug 1 (ReferenceError — primary failure):
|
|
||||||
* Line: await collections.ap_timeline.deleteOne({ _id: objectId });
|
|
||||||
* `objectId` is never defined in the route handler. MongoDB ObjectId is
|
|
||||||
* imported as the class `ObjectId`, not an instance. Every delete request
|
|
||||||
* throws ReferenceError → 500 → the timeline entry is never removed.
|
|
||||||
* Fix: use `item._id` (the document's own _id from findTimelineItemById).
|
|
||||||
*
|
|
||||||
* Bug 2 (AP Delete not broadcast):
|
|
||||||
* The route calls postContent.delete() directly, bypassing the Indiekit
|
|
||||||
* framework that normally invokes syndicator.delete(). No Delete(Note)
|
|
||||||
* activity is ever sent to followers — they keep seeing the post.
|
|
||||||
* Fix:
|
|
||||||
* a) Add broadcastDelete: (url) => pluginRef.broadcastDelete(url) to
|
|
||||||
* mastodonPluginOptions in index.js so the router can reach it.
|
|
||||||
* b) Call req.app.locals.mastodonPluginOptions.broadcastDelete(postUrl)
|
|
||||||
* in the delete route after the timeline entry is removed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-mastodon-delete-fix";
|
|
||||||
|
|
||||||
const indexCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const statusesCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Change A: expose broadcastDelete in mastodonPluginOptions (index.js) ──────
|
|
||||||
|
|
||||||
const OLD_PLUGIN_OPTS = ` loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
|
||||||
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),`;
|
|
||||||
|
|
||||||
const NEW_PLUGIN_OPTS = ` loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
|
||||||
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
|
|
||||||
broadcastDelete: (url) => pluginRef.broadcastDelete(url), ${MARKER}`;
|
|
||||||
|
|
||||||
// ── Change B: fix objectId → item._id (statuses.js) ──────────────────────────
|
|
||||||
|
|
||||||
const OLD_DELETE_ONE = ` // Delete from timeline
|
|
||||||
await collections.ap_timeline.deleteOne({ _id: objectId });`;
|
|
||||||
|
|
||||||
const NEW_DELETE_ONE = ` // Delete from timeline
|
|
||||||
await collections.ap_timeline.deleteOne({ _id: item._id }); ${MARKER}`;
|
|
||||||
|
|
||||||
// ── Change C: call broadcastDelete after timeline removal (statuses.js) ───────
|
|
||||||
// NOTE: Change B (objectId → item._id) was already fixed upstream.
|
|
||||||
// OLD_AFTER_DELETE matches the upstream code directly (no MARKER dependency).
|
|
||||||
|
|
||||||
const OLD_AFTER_DELETE = ` // Delete from timeline
|
|
||||||
await collections.ap_timeline.deleteOne({ _id: item._id });
|
|
||||||
|
|
||||||
// Clean up interactions`;
|
|
||||||
|
|
||||||
const NEW_AFTER_DELETE = ` // Delete from timeline
|
|
||||||
await collections.ap_timeline.deleteOne({ _id: item._id }); ${MARKER}
|
|
||||||
|
|
||||||
// Broadcast AP Delete activity to followers ${MARKER}
|
|
||||||
const _pluginOpts = req.app.locals.mastodonPluginOptions || {};
|
|
||||||
if (_pluginOpts.broadcastDelete && postUrl) {
|
|
||||||
_pluginOpts.broadcastDelete(postUrl).catch((err) =>
|
|
||||||
console.warn(\`[Mastodon API] broadcastDelete failed for \${postUrl}: \${err.message}\`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up interactions`;
|
|
||||||
|
|
||||||
async function exists(p) {
|
|
||||||
try { await access(p); return true; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function patchFile(filePath, replacements) {
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-mastodon-delete-fix: already applied to ${filePath}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated = source;
|
|
||||||
let applied = 0;
|
|
||||||
|
|
||||||
for (const { old: oldSnippet, newSnippet, label } of replacements) {
|
|
||||||
if (!updated.includes(oldSnippet)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-mastodon-delete-fix: snippet "${label}" not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
updated = updated.replace(oldSnippet, newSnippet);
|
|
||||||
applied++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (applied === 0) return false;
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
console.log(`[postinstall] Applied patch-ap-mastodon-delete-fix to ${filePath} (${applied} change(s))`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let totalPatched = 0;
|
|
||||||
let totalChecked = 0;
|
|
||||||
|
|
||||||
// Patch index.js candidates (Change A)
|
|
||||||
for (const filePath of indexCandidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
totalChecked++;
|
|
||||||
const ok = await patchFile(filePath, [
|
|
||||||
{ old: OLD_PLUGIN_OPTS, newSnippet: NEW_PLUGIN_OPTS, label: "broadcastDelete in pluginOptions" },
|
|
||||||
]);
|
|
||||||
if (ok) totalPatched++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch statuses.js candidates (Change C only — Change B already fixed upstream)
|
|
||||||
for (const filePath of statusesCandidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
totalChecked++;
|
|
||||||
const ok = await patchFile(filePath, [
|
|
||||||
{ old: OLD_AFTER_DELETE, newSnippet: NEW_AFTER_DELETE, label: "broadcastDelete call" },
|
|
||||||
]);
|
|
||||||
if (ok) totalPatched++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalChecked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-mastodon-delete-fix: no target files found");
|
|
||||||
} else if (totalPatched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-mastodon-delete-fix: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-mastodon-delete-fix: patched ${totalPatched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: eagerly insert own post into ap_timeline after Mastodon API POST /statuses.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* When a post is created via POST /api/v1/statuses (Mastodon client API), the
|
|
||||||
* handler creates the post through the Micropub pipeline but intentionally does
|
|
||||||
* NOT insert a timeline item immediately. The comment says:
|
|
||||||
*
|
|
||||||
* "No timeline entry is created here — the post will appear in the timeline
|
|
||||||
* after the normal flow: Eleventy rebuild → syndication webhook → AP delivery."
|
|
||||||
*
|
|
||||||
* This means there is a window (typically 30–120 s while Eleventy rebuilds) where
|
|
||||||
* the own post does NOT exist in ap_timeline. If the user tries to reply to their
|
|
||||||
* own newly-created post during this window, POST /api/v1/statuses receives
|
|
||||||
* `in_reply_to_id` for the new post, but `findTimelineItemById` returns null.
|
|
||||||
* With inReplyTo = null, the JF2 object has no "in-reply-to" property, and
|
|
||||||
* post-type-discovery classifies the reply as "note" instead of "reply". The
|
|
||||||
* reply is then saved at /notes/{slug}/ rather than /replies/{slug}/, and
|
|
||||||
* since there is no in-reply-to, the ActivityPub activity has no inReplyTo
|
|
||||||
* field and the thread is broken on remote Mastodon servers.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* After calling postContent.create(), immediately insert a provisional timeline
|
|
||||||
* item into ap_timeline using addTimelineItem() (which uses $setOnInsert —
|
|
||||||
* idempotent). The AP syndicator will later attempt the same upsert after the
|
|
||||||
* build webhook fires, which is a no-op since the document already exists.
|
|
||||||
* This ensures the post is resolvable via in_reply_to_id with zero delay.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-mastodon-reply-threading";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` // Return a minimal status to the Mastodon client.
|
|
||||||
// No timeline entry is created here — the post will appear in the timeline
|
|
||||||
// after the normal flow: Eleventy rebuild → syndication webhook → AP delivery.
|
|
||||||
const profile = await collections.ap_profile.findOne({});
|
|
||||||
const handle = pluginOptions.handle || "user";`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` // Return a minimal status to the Mastodon client. ${MARKER}
|
|
||||||
// Eagerly insert own post into ap_timeline so the Mastodon client can resolve ${MARKER}
|
|
||||||
// in_reply_to_id for this post immediately, without waiting for the build webhook. ${MARKER}
|
|
||||||
// The AP syndicator will upsert the same uid later via $setOnInsert (no-op). ${MARKER}
|
|
||||||
const profile = await collections.ap_profile.findOne({});
|
|
||||||
const handle = pluginOptions.handle || "user";
|
|
||||||
try { ${MARKER}
|
|
||||||
const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })(); ${MARKER}
|
|
||||||
await addTimelineItem(collections, { ${MARKER}
|
|
||||||
uid: postUrl, ${MARKER}
|
|
||||||
url: postUrl, ${MARKER}
|
|
||||||
type: data.properties["post-type"] || "note", ${MARKER}
|
|
||||||
content: { text: contentText, html: \`<p>\${contentHtml}</p>\` }, ${MARKER}
|
|
||||||
author: { ${MARKER}
|
|
||||||
name: profile?.name || handle, ${MARKER}
|
|
||||||
url: profile?.url || publicationUrl, ${MARKER}
|
|
||||||
photo: profile?.icon || "", ${MARKER}
|
|
||||||
handle: \`@\${handle}@\${_ph}\`, ${MARKER}
|
|
||||||
emojis: [], ${MARKER}
|
|
||||||
bot: false, ${MARKER}
|
|
||||||
}, ${MARKER}
|
|
||||||
published: data.properties.published || new Date().toISOString(), ${MARKER}
|
|
||||||
createdAt: new Date().toISOString(), ${MARKER}
|
|
||||||
inReplyTo: inReplyTo || null, ${MARKER}
|
|
||||||
visibility: jf2.visibility || "public", ${MARKER}
|
|
||||||
sensitive: jf2.sensitive === "true", ${MARKER}
|
|
||||||
category: [], ${MARKER}
|
|
||||||
counts: { likes: 0, boosts: 0, replies: 0 }, ${MARKER}
|
|
||||||
}); ${MARKER}
|
|
||||||
} catch (tlErr) { ${MARKER}
|
|
||||||
console.warn(\`[Mastodon API] Failed to pre-insert own post into timeline: \${tlErr.message}\`); ${MARKER}
|
|
||||||
} ${MARKER}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-mastodon-reply-threading: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-mastodon-reply-threading: target snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-mastodon-reply-threading: no changes in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-mastodon-reply-threading to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-mastodon-reply-threading: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-mastodon-reply-threading: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-mastodon-reply-threading: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix POST /api/v1/statuses response ID to match ap_timeline _id.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* The POST /api/v1/statuses handler returns `id: String(Date.now())` — the
|
|
||||||
* wall-clock time when the response is sent. The ap_timeline item inserted by
|
|
||||||
* patch-ap-mastodon-reply-threading uses addTimelineItem(), which stores the
|
|
||||||
* item with a MongoDB-generated ObjectId as _id.
|
|
||||||
*
|
|
||||||
* When Phanpy/Elk receives the creation response and the user then replies to
|
|
||||||
* that post, the client sends `in_reply_to_id: <id from creation response>`.
|
|
||||||
* The handler calls findTimelineItemById which does:
|
|
||||||
* collection.findOne({ _id: new ObjectId(id) })
|
|
||||||
* A String(Date.now()) value is not a valid ObjectId → lookup returns null →
|
|
||||||
* inReplyTo = null → jf2["in-reply-to"] not set → getPostType returns "note"
|
|
||||||
* instead of "reply".
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* 1. Capture the return value of addTimelineItem() into `_tlItem`.
|
|
||||||
* 2. Use `_tlItem?._id?.toString() || String(Date.now())` as the status ID.
|
|
||||||
*
|
|
||||||
* This ensures the creation response ID matches what findTimelineItemById will
|
|
||||||
* resolve in subsequent in_reply_to_id lookups.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-mastodon-status-id";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Change 1: capture return value of addTimelineItem
|
|
||||||
const OLD_TL_INSERT = ` await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading`;
|
|
||||||
const NEW_TL_INSERT = ` _tlItem = await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading ${MARKER}`;
|
|
||||||
|
|
||||||
// Change 2: declare _tlItem before the try block
|
|
||||||
const OLD_TRY = ` try { // [patch] ap-mastodon-reply-threading`;
|
|
||||||
const NEW_TRY = ` let _tlItem = null; ${MARKER}
|
|
||||||
try { // [patch] ap-mastodon-reply-threading`;
|
|
||||||
|
|
||||||
// Change 3: use _tlItem._id as the status response ID
|
|
||||||
const OLD_ID = ` id: String(Date.now()),`;
|
|
||||||
const NEW_ID = ` id: _tlItem?._id?.toString() || String(Date.now()), ${MARKER}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-mastodon-status-id: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let missing = false;
|
|
||||||
for (const [label, snippet] of [
|
|
||||||
["await addTimelineItem", OLD_TL_INSERT],
|
|
||||||
["try block", OLD_TRY],
|
|
||||||
["response id", OLD_ID],
|
|
||||||
]) {
|
|
||||||
if (!source.includes(snippet)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-mastodon-status-id: "${label}" snippet not found in ${filePath}`);
|
|
||||||
missing = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (missing) continue;
|
|
||||||
|
|
||||||
// Apply in order: TRY first (adds let _tlItem before try), then INSERT (changes await to assign), then ID
|
|
||||||
let updated = source
|
|
||||||
.replace(OLD_TRY, NEW_TRY)
|
|
||||||
.replace(OLD_TL_INSERT, NEW_TL_INSERT)
|
|
||||||
.replace(OLD_ID, NEW_ID);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-mastodon-status-id: no changes in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-mastodon-status-id to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-mastodon-status-id: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-mastodon-status-id: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-mastodon-status-id: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: include notif.url in batchFetchStatuses so mention notifications
|
|
||||||
* resolve their associated status correctly.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* batchFetchStatuses() only collected notif.targetUrl for the batch lookup.
|
|
||||||
* serializeNotification() looks up mentions by notif.url (the incoming
|
|
||||||
* reply URL), not notif.targetUrl (the own post being replied to). These
|
|
||||||
* are different URLs, so the statusMap never has an entry for the mention →
|
|
||||||
* fallback fires → status.id = notif._id.toString() (a notification ObjectId,
|
|
||||||
* not a timeline ObjectId) → Phanpy uses that ID for subsequent requests →
|
|
||||||
* GET /api/v1/statuses/:id/context returns 404 because ap_timeline has no
|
|
||||||
* document with that _id.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* Collect both notif.url and notif.targetUrl so the batch covers all URL
|
|
||||||
* shapes used by any notification type.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/notifications.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/notifications.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-notifications-status-lookup";
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` const targetUrls = [
|
|
||||||
...new Set(
|
|
||||||
notifications
|
|
||||||
.map((n) => n.targetUrl)
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
];`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` const targetUrls = [ // [patch] ap-notifications-status-lookup
|
|
||||||
...new Set(
|
|
||||||
notifications
|
|
||||||
.flatMap((n) => [n.targetUrl, n.url])
|
|
||||||
.filter(Boolean),
|
|
||||||
),
|
|
||||||
];`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checked += 1;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-notifications-status-lookup: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] patch-ap-notifications-status-lookup: target snippet not found in ${filePath} — skipping`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-notifications-status-lookup: no changes applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-notifications-status-lookup to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-notifications-status-lookup: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-notifications-status-lookup: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] patch-ap-notifications-status-lookup: patched ${patched}/${checked} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix OG image URL generation in ActivityPub jf2-to-as2.js.
|
|
||||||
*
|
|
||||||
* Root cause (original):
|
|
||||||
* jf2-to-as2.js used a date-based URL regex to extract the post slug, which
|
|
||||||
* never matches this blog's flat URLs (/articles/slug/ vs /articles/2024/.../slug/).
|
|
||||||
* The image property was never set, so no preview card reached Mastodon.
|
|
||||||
*
|
|
||||||
* Fix (v2 — this patch):
|
|
||||||
* For posts with a photo attachment (properties.photo), use the photo URL
|
|
||||||
* directly as the preview image — Eleventy does NOT generate /og/*.png for
|
|
||||||
* photo post types.
|
|
||||||
* For all other post types (replies, bookmarks, articles) fall back to
|
|
||||||
* /og/{slug}.png, which Eleventy does generate.
|
|
||||||
*
|
|
||||||
* Both jf2ToActivityStreams() (plain JSON-LD) and jf2ToAS2Activity() (Fedify
|
|
||||||
* vocab objects) are patched. Handles all known file states:
|
|
||||||
* - Original upstream code (ogMatch / ogMatchF variable names)
|
|
||||||
* - v1 patch (ogSlug / ogSlugF + // og-image fix comments)
|
|
||||||
* - Already v2 (// og-image-v2 marker) → skip
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MARKER = "// og-image-v2";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Match the OG image block in jf2ToActivityStreams.
|
|
||||||
// Handles both the original upstream code (ogMatch) and the v1 patch (ogSlug).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const CN_BLOCK_RE =
|
|
||||||
/ const og(?:Slug|Match) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:Slug|Match)\) \{[\s\S]*?\n \}/;
|
|
||||||
|
|
||||||
// Match the OG image block in jf2ToAS2Activity (ogMatchF / ogSlugF variants).
|
|
||||||
const AS2_BLOCK_RE =
|
|
||||||
/ const og(?:SlugF|MatchF) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:SlugF|MatchF)\) \{[\s\S]*?\n \}/;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// v2 replacements:
|
|
||||||
// 1. Use properties.photo[0] URL for photo posts (resolveMediaUrl handles
|
|
||||||
// relative paths; guessMediaType detects jpeg/png/webp).
|
|
||||||
// 2. Fall back to /og/{slug}.png for replies, bookmarks, articles.
|
|
||||||
//
|
|
||||||
// Template literal escaping (patch string → injected JS source):
|
|
||||||
// \\/ → \/ (regex escaped slash)
|
|
||||||
// [\\\w-] → [\w-] (word-char class)
|
|
||||||
// \`\${ → `${ (start of injected template literal)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const NEW_CN = ` const _ogPhoto = properties.photo && asArray(properties.photo)[0]; // og-image-v2
|
|
||||||
const _ogPhotoUrl = _ogPhoto && (typeof _ogPhoto === "string" ? _ogPhoto : _ogPhoto.url); // og-image-v2
|
|
||||||
const ogSlug = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image-v2
|
|
||||||
const _ogUrl = _ogPhotoUrl
|
|
||||||
? resolveMediaUrl(_ogPhotoUrl, publicationUrl) // og-image-v2
|
|
||||||
: ogSlug ? \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlug}.png\` : null; // og-image-v2
|
|
||||||
if (_ogUrl) { // og-image-v2
|
|
||||||
object.image = {
|
|
||||||
type: "Image",
|
|
||||||
url: _ogUrl, // og-image-v2
|
|
||||||
mediaType: _ogPhotoUrl ? guessMediaType(_ogUrl) : "image/png", // og-image-v2
|
|
||||||
};
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW_AS2 = ` const _ogPhotoF = properties.photo && asArray(properties.photo)[0]; // og-image-v2
|
|
||||||
const _ogPhotoUrlF = _ogPhotoF && (typeof _ogPhotoF === "string" ? _ogPhotoF : _ogPhotoF.url); // og-image-v2
|
|
||||||
const ogSlugF = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image-v2
|
|
||||||
const _ogUrlF = _ogPhotoUrlF
|
|
||||||
? resolveMediaUrl(_ogPhotoUrlF, publicationUrl) // og-image-v2
|
|
||||||
: ogSlugF ? \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlugF}.png\` : null; // og-image-v2
|
|
||||||
if (_ogUrlF) { // og-image-v2
|
|
||||||
noteOptions.image = new Image({
|
|
||||||
url: new URL(_ogUrlF), // og-image-v2
|
|
||||||
mediaType: _ogPhotoUrlF ? guessMediaType(_ogUrlF) : "image/png", // og-image-v2
|
|
||||||
});
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checked += 1;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-og-image: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let updated = source;
|
|
||||||
let changed = false;
|
|
||||||
|
|
||||||
// Fix the jf2ToActivityStreams OG block
|
|
||||||
if (CN_BLOCK_RE.test(updated)) {
|
|
||||||
updated = updated.replace(CN_BLOCK_RE, NEW_CN);
|
|
||||||
changed = true;
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] patch-ap-og-image: jf2ToActivityStreams OG block not found in ${filePath} — skipping`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix the jf2ToAS2Activity OG block
|
|
||||||
if (AS2_BLOCK_RE.test(updated)) {
|
|
||||||
updated = updated.replace(AS2_BLOCK_RE, NEW_AS2);
|
|
||||||
changed = true;
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] patch-ap-og-image: jf2ToAS2Activity OG block not found in ${filePath} — skipping`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!changed || updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-og-image: no changes applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-og-image to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-og-image: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-og-image: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-og-image: patched ${patched}/${checked} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix repost syndication to ActivityPub.
|
|
||||||
*
|
|
||||||
* Previously, any repost sent an Announce activity with the repost-of URL as
|
|
||||||
* the object — even when that URL is a plain web article (not an AP object).
|
|
||||||
* Remote servers receive the Announce but cannot resolve the object, so the
|
|
||||||
* boost never appears on followers' timelines.
|
|
||||||
*
|
|
||||||
* The same problem applies to Likes, where the fix already exists: check
|
|
||||||
* isApUrl() first. This patch applies the same logic to reposts:
|
|
||||||
*
|
|
||||||
* - repost-of is an AP URL → proper Announce with id + cc (native boost)
|
|
||||||
* - repost-of is NOT an AP URL → fall through to Create(Note) which renders
|
|
||||||
* as "🔁 <link>" on the fediverse (same as the with-commentary path)
|
|
||||||
*
|
|
||||||
* Also adds missing `id` and `cc: followers` to the Announce (the interactive
|
|
||||||
* boostPost() method already generates these; the syndication path did not).
|
|
||||||
*/
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-repost-announce-fix";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` // Reposts are always public — upstream @rmdes addressing
|
|
||||||
if (postType === "repost") {
|
|
||||||
const repostOf = properties["repost-of"];
|
|
||||||
if (!repostOf) return null;
|
|
||||||
const repostContent = properties.content?.html || properties.content || "";
|
|
||||||
if (!repostContent) {
|
|
||||||
// Pure repost — send as a native Announce (boost) so remote servers
|
|
||||||
// can display it as a boost of the original post.
|
|
||||||
return new Announce({
|
|
||||||
actor: actorUri,
|
|
||||||
object: new URL(repostOf),
|
|
||||||
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Has commentary — fall through to Create(Note) so the text is federated.
|
|
||||||
// The note content block below handles the "repost" post-type.
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` // Reposts are always public — upstream @rmdes addressing
|
|
||||||
if (postType === "repost") {
|
|
||||||
const repostOf = Array.isArray(properties["repost-of"])
|
|
||||||
? properties["repost-of"][0]
|
|
||||||
: properties["repost-of"];
|
|
||||||
if (!repostOf) return null;
|
|
||||||
const repostContent = properties.content?.html || properties.content || "";
|
|
||||||
if (!repostContent) {
|
|
||||||
// Only send Announce if repost-of is an ActivityPub URL.
|
|
||||||
// Non-AP URLs (web articles) cannot be federated as a boost — fall
|
|
||||||
// through to Create(Note) which renders as "🔁 <link>" on the fediverse.
|
|
||||||
if (await isApUrl(repostOf)) { ${MARKER}
|
|
||||||
const actorPath = new URL(actorUrl).pathname;
|
|
||||||
const mp = actorPath.replace(/\\/users\\/[^/]+$/, "");
|
|
||||||
const postRelPath = (properties.url || "")
|
|
||||||
.replace(publicationUrl.replace(/\\/$/, ""), "")
|
|
||||||
.replace(/^\\//, "")
|
|
||||||
.replace(/\\/$/, "");
|
|
||||||
const announceId = \`\${publicationUrl.replace(/\\/$/, "")}\${mp}/activities/boost/\${postRelPath}\`;
|
|
||||||
return new Announce({
|
|
||||||
id: new URL(announceId),
|
|
||||||
actor: actorUri,
|
|
||||||
object: new URL(repostOf),
|
|
||||||
to: new URL("https://www.w3.org/ns/activitystreams#Public"),
|
|
||||||
cc: new URL(\`\${actorUrl.replace(/\\/$/, "")}/followers\`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Has commentary or non-AP repost-of URL — fall through to Create(Note) so the text is federated.
|
|
||||||
// The note content block below handles the "repost" post-type.
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] Skipping ap-repost-announce-fix patch for ${filePath}: upstream format changed`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] No AP jf2-to-as2 files found for repost announce fix patch");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] ap-repost-announce-fix patch already applied");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] Patched AP repost announce fix in ${patched} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix unhandled rejection crash + parallelise collection count fetches.
|
|
||||||
*
|
|
||||||
* Root cause (crash):
|
|
||||||
* withTimeout() races the original promise against a 5 s timer.
|
|
||||||
* When the timer fires first, the original getFollowers() / getFollowing() /
|
|
||||||
* getOutbox() promise is still running with no .catch() handler. If it later
|
|
||||||
* rejects (e.g. network error, TLS failure), Node.js ≥15 treats it as an
|
|
||||||
* unhandled rejection and crashes the process → nginx sees "Connection refused".
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* Call promise.catch(() => {}) before racing so the rejection is always
|
|
||||||
* "handled", even if we never observe the result.
|
|
||||||
*
|
|
||||||
* Bonus — parallel fetches:
|
|
||||||
* The three collection-count fetches were sequential: worst case 3 × 5 s = 15 s.
|
|
||||||
* Replaced with Promise.allSettled so all three run concurrently; max wait = 5 s.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-resolve-account-timeout-safe";
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` const withTimeout = (promise, ms = 5000) =>
|
|
||||||
Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]);
|
|
||||||
|
|
||||||
let followersCount = 0;
|
|
||||||
let followingCount = 0;
|
|
||||||
let statusesCount = 0;
|
|
||||||
try {
|
|
||||||
const followers = await withTimeout(actor.getFollowers());
|
|
||||||
if (followers?.totalItems != null) followersCount = followers.totalItems;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
try {
|
|
||||||
const following = await withTimeout(actor.getFollowing());
|
|
||||||
if (following?.totalItems != null) followingCount = following.totalItems;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
try {
|
|
||||||
const outbox = await withTimeout(actor.getOutbox());
|
|
||||||
if (outbox?.totalItems != null) statusesCount = outbox.totalItems;
|
|
||||||
} catch { /* ignore */ }`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` const withTimeout = (promise, ms = 5000) => { // [patch] ap-resolve-account-timeout-safe
|
|
||||||
const abort = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms));
|
|
||||||
promise.catch(() => {}); // suppress unhandled rejection if timeout settles first
|
|
||||||
return Promise.race([promise, abort]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch collection counts in parallel (max 5 s each) [patch] ap-resolve-account-timeout-safe
|
|
||||||
const [followersResult, followingResult, outboxResult] = await Promise.allSettled([
|
|
||||||
withTimeout(actor.getFollowers()),
|
|
||||||
withTimeout(actor.getFollowing()),
|
|
||||||
withTimeout(actor.getOutbox()),
|
|
||||||
]);
|
|
||||||
const followersCount = followersResult.status === "fulfilled" && followersResult.value?.totalItems != null ? followersResult.value.totalItems : 0;
|
|
||||||
const followingCount = followingResult.status === "fulfilled" && followingResult.value?.totalItems != null ? followingResult.value.totalItems : 0;
|
|
||||||
const statusesCount = outboxResult.status === "fulfilled" && outboxResult.value?.totalItems != null ? outboxResult.value.totalItems : 0;`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checked += 1;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-resolve-account-timeout-safe: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] patch-ap-resolve-account-timeout-safe: target snippet not found in ${filePath} — skipping`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-resolve-account-timeout-safe: no changes applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-resolve-account-timeout-safe to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-resolve-account-timeout-safe: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-resolve-account-timeout-safe: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] patch-ap-resolve-account-timeout-safe: patched ${patched}/${checked} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix HTTP Signature verification failures by normalising the `host`
|
|
||||||
* header in fromExpressRequest().
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* HTTP Signatures (Mastodon, Pleroma, …) include "host:" in the signed
|
|
||||||
* components string. The signer uses the public hostname, e.g.
|
|
||||||
* "host: blog.giersig.eu". Fedify reconstructs that string from
|
|
||||||
* request.headers.get("host") when verifying.
|
|
||||||
*
|
|
||||||
* In our two-jail setup (nginx → node jail), nginx may proxy to the node
|
|
||||||
* jail with a different Host header than the public hostname (e.g. the
|
|
||||||
* internal IP "10.100.0.20" or "10.100.0.20:3000"). The
|
|
||||||
* patch-ap-federation-bridge-base-url patch already fixed URL routing
|
|
||||||
* (fromExpressRequest builds the correct canonical URL from publicationUrl),
|
|
||||||
* but the host HEADER value in the Headers object was still copied verbatim
|
|
||||||
* from req.headers — meaning Fedify's signature verifier reconstructed the
|
|
||||||
* signed string with the wrong host value and the check always failed.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* After copying headers from the Express request, override "host" with the
|
|
||||||
* hostname extracted from publicationUrl when publicationUrl is provided.
|
|
||||||
* This is safe even when nginx already forwards the correct Host header —
|
|
||||||
* the value is identical and the set() is a no-op in that case.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-signature-host-header";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
// The headers-copy loop is followed immediately by the body reconstruction.
|
|
||||||
// Insert the host-override right after the closing brace of the loop.
|
|
||||||
const OLD_HEADERS_LOOP = ` for (const [key, value] of Object.entries(req.headers)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const v of value) headers.append(key, v);
|
|
||||||
} else if (typeof value === "string") {
|
|
||||||
headers.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let body;`;
|
|
||||||
|
|
||||||
const NEW_HEADERS_LOOP = ` for (const [key, value] of Object.entries(req.headers)) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const v of value) headers.append(key, v);
|
|
||||||
} else if (typeof value === "string") {
|
|
||||||
headers.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalise "host" to the public hostname so Fedify's HTTP Signature
|
|
||||||
// verifier reconstructs the same signed-string the remote server created.
|
|
||||||
// Without this, nginx may forward an internal Host (e.g. "10.100.0.20")
|
|
||||||
// which doesn't match what the sender signed, causing every inbox POST
|
|
||||||
// to fail with "Failed to verify the request's HTTP Signatures". ${MARKER}
|
|
||||||
if (baseUrl) {
|
|
||||||
try {
|
|
||||||
const _canonicalHost = new URL(baseUrl).host; // e.g. "blog.giersig.eu"
|
|
||||||
headers.set("host", _canonicalHost);
|
|
||||||
} catch { /* invalid baseUrl — leave header as-is */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
let body;`;
|
|
||||||
|
|
||||||
async function exists(p) {
|
|
||||||
try { await access(p); return true; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked++;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-signature-host-header: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_HEADERS_LOOP)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-signature-host-header: target snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, source.replace(OLD_HEADERS_LOOP, NEW_HEADERS_LOOP), "utf8");
|
|
||||||
patched++;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-signature-host-header to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-signature-host-header: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-signature-host-header: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-signature-host-header: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: add a post-status === "draft" guard to the ActivityPub syndicator's
|
|
||||||
* syndicate() method, mirroring the existing visibility === "unlisted" guard.
|
|
||||||
*
|
|
||||||
* Without this patch, a draft post that somehow reaches the AP syndicator
|
|
||||||
* directly (bypassing the syndicate-endpoint DB-level filter) would be
|
|
||||||
* federated to followers.
|
|
||||||
*/
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const oldSnippet = ` const visibility = String(properties?.visibility || "").toLowerCase();
|
|
||||||
if (visibility === "unlisted") {
|
|
||||||
console.info(
|
|
||||||
"[ActivityPub] Skipping federation for unlisted post: " +
|
|
||||||
(properties?.url || "unknown"),
|
|
||||||
);
|
|
||||||
await logActivity(self._collections.ap_activities, {
|
|
||||||
direction: "outbound",
|
|
||||||
type: "Syndicate",
|
|
||||||
actorUrl: self._publicationUrl,
|
|
||||||
objectUrl: properties?.url,
|
|
||||||
summary: "Syndication skipped: post visibility is unlisted",
|
|
||||||
}).catch(() => {});
|
|
||||||
return undefined;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const newSnippet = ` const postStatus = String(properties?.["post-status"] || "").toLowerCase();
|
|
||||||
if (postStatus === "draft") {
|
|
||||||
console.info(
|
|
||||||
"[ActivityPub] Skipping federation for draft post: " +
|
|
||||||
(properties?.url || "unknown"),
|
|
||||||
);
|
|
||||||
await logActivity(self._collections.ap_activities, {
|
|
||||||
direction: "outbound",
|
|
||||||
type: "Syndicate",
|
|
||||||
actorUrl: self._publicationUrl,
|
|
||||||
objectUrl: properties?.url,
|
|
||||||
summary: "Syndication skipped: post is a draft",
|
|
||||||
}).catch(() => {});
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibility = String(properties?.visibility || "").toLowerCase();
|
|
||||||
if (visibility === "unlisted") {
|
|
||||||
console.info(
|
|
||||||
"[ActivityPub] Skipping federation for unlisted post: " +
|
|
||||||
(properties?.url || "unknown"),
|
|
||||||
);
|
|
||||||
await logActivity(self._collections.ap_activities, {
|
|
||||||
direction: "outbound",
|
|
||||||
type: "Syndicate",
|
|
||||||
actorUrl: self._publicationUrl,
|
|
||||||
objectUrl: properties?.url,
|
|
||||||
summary: "Syndication skipped: post visibility is unlisted",
|
|
||||||
}).catch(() => {});
|
|
||||||
return undefined;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(newSnippet)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(oldSnippet)) {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] Skipping ap-skip-draft-syndication patch for ${filePath}: upstream format changed`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(oldSnippet, newSnippet);
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] No AP endpoint files found for draft guard patch");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] ap-skip-draft-syndication patch already applied");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] Patched AP draft syndication guard in ${patched} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: fix in_reply_to_id always being null in Mastodon status serializer.
|
|
||||||
*
|
|
||||||
* Bug:
|
|
||||||
* status.js line 207:
|
|
||||||
* in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID
|
|
||||||
*
|
|
||||||
* Both branches of the ternary return null, so in_reply_to_id is ALWAYS null.
|
|
||||||
* Mastodon clients (Phanpy, Elk) use this field to display reply threading —
|
|
||||||
* without it, replies appear as standalone posts with no thread context.
|
|
||||||
*
|
|
||||||
* Fix (two changes):
|
|
||||||
*
|
|
||||||
* A) status.js — use item.inReplyToId (the encoded cursor of the parent post)
|
|
||||||
* instead of the tautological null.
|
|
||||||
*
|
|
||||||
* B) statuses.js POST /api/v1/statuses handler — when pre-inserting own posts
|
|
||||||
* into ap_timeline (reply-threading patch), also store
|
|
||||||
* inReplyToId: inReplyToId || null
|
|
||||||
* (inReplyToId is already in scope as the raw in_reply_to_id param from the
|
|
||||||
* client, which IS a valid encodeCursor value.)
|
|
||||||
*
|
|
||||||
* Note: inbound AP replies from remote servers will still have inReplyToId = null
|
|
||||||
* until a separate patch populates it from ap_timeline lookups. Own replies via
|
|
||||||
* the Mastodon client API are fully fixed by this patch.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-status-reply-id";
|
|
||||||
|
|
||||||
// ── Change A: fix tautological null in status.js ──────────────────────────────
|
|
||||||
|
|
||||||
const statusEntityCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/entities/status.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/entities/status.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_TAUTOLOGY = ` in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID`;
|
|
||||||
const NEW_REPLY_ID = ` in_reply_to_id: item.inReplyToId || null, ${MARKER}`;
|
|
||||||
|
|
||||||
// ── Change B: store inReplyToId in the Mastodon API timeline insert ───────────
|
|
||||||
|
|
||||||
const statusesRouteCandidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading`;
|
|
||||||
const NEW_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading
|
|
||||||
inReplyToId: inReplyToId || null, ${MARKER}`;
|
|
||||||
|
|
||||||
async function exists(p) {
|
|
||||||
try { await access(p); return true; } catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upstream fix indicator for Change A — if present, the tautological null is
|
|
||||||
// already replaced by the upstream's replyIdMap-based lookup (better than our patch).
|
|
||||||
const UPSTREAM_FIX_A = `in_reply_to_id: replyIdMap?.get(item.inReplyTo)`;
|
|
||||||
|
|
||||||
async function applyPatch(candidates, oldSnippet, newSnippet, label, upstreamFix) {
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked++;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-status-reply-id: ${label} already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If upstream has already fixed the issue (better fix), skip silently
|
|
||||||
if (upstreamFix && source.includes(upstreamFix)) {
|
|
||||||
console.log(`[postinstall] patch-ap-status-reply-id: ${label} already fixed upstream in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(oldSnippet)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-status-reply-id: ${label} snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8");
|
|
||||||
patched++;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-status-reply-id (${label}) to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { checked, patched };
|
|
||||||
}
|
|
||||||
|
|
||||||
const a = await applyPatch(statusEntityCandidates, OLD_TAUTOLOGY, NEW_REPLY_ID, "status entity", UPSTREAM_FIX_A);
|
|
||||||
const b = await applyPatch(statusesRouteCandidates, OLD_REPLY_INSERT, NEW_REPLY_INSERT, "timeline insert");
|
|
||||||
|
|
||||||
const totalChecked = a.checked + b.checked;
|
|
||||||
const totalPatched = a.patched + b.patched;
|
|
||||||
|
|
||||||
if (totalChecked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-status-reply-id: no target files found");
|
|
||||||
} else if (totalPatched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-status-reply-id: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-status-reply-id: patched ${totalPatched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: dedup guard in AP syndicator.syndicate() to prevent double-posting.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* The build CI calls POST /syndicate?source_url=X (force=true) after every
|
|
||||||
* Eleventy build. When syndicateToTargets() records the first syndication, it
|
|
||||||
* issues a Micropub update to save the syndication URL — which commits a new
|
|
||||||
* file to Gitea, triggering another build. That second build's CI call also
|
|
||||||
* hits the syndicate endpoint with force=true.
|
|
||||||
*
|
|
||||||
* In force mode with no mp-syndicate-to, syndicateToTargets() re-selects
|
|
||||||
* targets whose origin matches any existing syndication URL. Since the AP
|
|
||||||
* syndicator's UID (publicationUrl, e.g. "https://blog.giersig.eu/") and the
|
|
||||||
* first syndication return value (properties.url, e.g.
|
|
||||||
* "https://blog.giersig.eu/notes/my-post/") share the same origin,
|
|
||||||
* the AP syndicator is matched and called a second time → duplicate Create(Note).
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* At the start of syndicate(), query ap_activities for an existing outbound
|
|
||||||
* Create/Announce/Update for properties.url. If found, log and return the
|
|
||||||
* existing URL without re-federating.
|
|
||||||
*
|
|
||||||
* This is self-contained (no CI or force-mode changes needed) and correct
|
|
||||||
* regardless of how syndication is triggered.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-syndicate-dedup";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD = ` try {
|
|
||||||
const actorUrl = plugin._getActorUrl();`;
|
|
||||||
|
|
||||||
const NEW = ` // Dedup: skip re-federation if we've already sent an activity for this URL. ${MARKER}
|
|
||||||
// ap_activities is the authoritative record of "already federated".
|
|
||||||
try {
|
|
||||||
const existingActivity = await plugin._collections.ap_activities?.findOne({
|
|
||||||
direction: "outbound",
|
|
||||||
type: { $in: ["Create", "Announce", "Update"] },
|
|
||||||
objectUrl: properties.url,
|
|
||||||
});
|
|
||||||
if (existingActivity) {
|
|
||||||
console.info(\`[ActivityPub] Skipping duplicate syndication for \${properties.url} — already sent (\${existingActivity.type})\`);
|
|
||||||
return properties.url || undefined;
|
|
||||||
}
|
|
||||||
} catch { /* DB unavailable — proceed */ }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const actorUrl = plugin._getActorUrl();`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-syndicate-dedup: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-syndicate-dedup: snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, source.replace(OLD, NEW), "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-syndicate-dedup to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-syndicate-dedup: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-syndicate-dedup: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-syndicate-dedup: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: skip AP syndication for location checkins.
|
|
||||||
*
|
|
||||||
* Location checkins arrive via Micropub as regular notes but carry a
|
|
||||||
* `location` property (geo URI or h-card object, normalised by jf2.js).
|
|
||||||
* They are personal log entries and should not be federated to followers.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* At the start of syndicate(), if properties.location is set, log and
|
|
||||||
* return undefined so Indiekit records no syndication URL for the post.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-syndicate-skip-checkin";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD = ` async syndicate(properties) {
|
|
||||||
if (!plugin._federation) {
|
|
||||||
return undefined;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW = ` async syndicate(properties) {
|
|
||||||
if (!plugin._federation) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip location checkins — they have a JF2 \`location\` property. ${MARKER}
|
|
||||||
if (properties.location) {
|
|
||||||
console.info(\`[ActivityPub] Skipping syndication for location checkin: \${properties.url}\`);
|
|
||||||
return undefined;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-syndicate-skip-checkin: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-syndicate-skip-checkin: snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, source.replace(OLD, NEW), "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-syndicate-skip-checkin to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-syndicate-skip-checkin: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-syndicate-skip-checkin: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-syndicate-skip-checkin: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: skip AP syndication for posts with post-status="draft".
|
|
||||||
*
|
|
||||||
* Draft posts should not be federated to followers.
|
|
||||||
* They have `properties["post-status"] === "draft"`.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* At the start of syndicate(), after the checkin guard, if
|
|
||||||
* properties["post-status"] === "draft", log and return undefined so
|
|
||||||
* Indiekit records no syndication URL for the post.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-syndicate-skip-draft";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD = ` // Skip location checkins — they have a JF2 \`location\` property. // [patch] ap-syndicate-skip-checkin
|
|
||||||
if (properties.location) {
|
|
||||||
console.info(\`[ActivityPub] Skipping syndication for location checkin: \${properties.url}\`);
|
|
||||||
return undefined;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW = ` // Skip location checkins — they have a JF2 \`location\` property. // [patch] ap-syndicate-skip-checkin
|
|
||||||
if (properties.location) {
|
|
||||||
console.info(\`[ActivityPub] Skipping syndication for location checkin: \${properties.url}\`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip draft posts — they should not be federated to followers. ${MARKER}
|
|
||||||
if (properties["post-status"] === "draft") {
|
|
||||||
console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`);
|
|
||||||
return undefined;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-syndicate-skip-draft: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-syndicate-skip-draft: snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, source.replace(OLD, NEW), "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-syndicate-skip-draft to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-syndicate-skip-draft: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-syndicate-skip-draft: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-syndicate-skip-draft: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: skip AP syndication for posts with visibility="unlisted".
|
|
||||||
*
|
|
||||||
* Unlisted posts should not be federated to followers.
|
|
||||||
* They have `properties.visibility === "unlisted"`.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* At the start of syndicate(), after the draft guard, if
|
|
||||||
* properties.visibility === "unlisted", log and return undefined so
|
|
||||||
* Indiekit records no syndication URL for the post.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const MARKER = "// [patch] ap-syndicate-skip-unlisted";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const OLD = ` // Skip draft posts — they should not be federated to followers. // [patch] ap-syndicate-skip-draft
|
|
||||||
if (properties["post-status"] === "draft") {
|
|
||||||
console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`);
|
|
||||||
return undefined;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const NEW = ` // Skip draft posts — they should not be federated to followers. // [patch] ap-syndicate-skip-draft
|
|
||||||
if (properties["post-status"] === "draft") {
|
|
||||||
console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip unlisted posts — they should not be federated to followers. ${MARKER}
|
|
||||||
if (properties.visibility === "unlisted") {
|
|
||||||
console.info(\`[ActivityPub] Skipping syndication for unlisted post: \${properties.url}\`);
|
|
||||||
return undefined;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) continue;
|
|
||||||
checked += 1;
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-syndicate-skip-unlisted: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD)) {
|
|
||||||
console.warn(`[postinstall] patch-ap-syndicate-skip-unlisted: snippet not found in ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, source.replace(OLD, NEW), "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-syndicate-skip-unlisted to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-syndicate-skip-unlisted: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-syndicate-skip-unlisted: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(`[postinstall] patch-ap-syndicate-skip-unlisted: patched ${patched} file(s)`);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: serve /.well-known/webfinger (and other /.well-known/ discovery routes)
|
|
||||||
* via Fedify BEFORE indiekit's auth middleware redirects them to the login page.
|
|
||||||
*
|
|
||||||
* Root cause:
|
|
||||||
* indiekit mounts the AP endpoint's `routesWellKnown` router at `/.well-known/`.
|
|
||||||
* Express strips that prefix from req.url, so Fedify sees "/webfinger" instead of
|
|
||||||
* "/.well-known/webfinger" and cannot match its internal route — it calls next().
|
|
||||||
* The request then falls through to indiekit's auth middleware, which issues a 302
|
|
||||||
* redirect to /session/login. Remote servers (e.g. digitalhub.social) receive
|
|
||||||
* the redirect instead of the JSON response and log a Webfinger error, causing all
|
|
||||||
* subsequent ActivityPub deliveries to that instance to fail with 401 Unauthorized.
|
|
||||||
*
|
|
||||||
* Fix:
|
|
||||||
* The AP endpoint also registers `contentNegotiationRoutes` at "/", where Express
|
|
||||||
* does NOT strip any prefix and req.path retains the full original path. This patch
|
|
||||||
* extends the Fedify delegation guard inside that router to also forward any request
|
|
||||||
* whose path starts with "/.well-known/", in addition to the existing "/nodeinfo/"
|
|
||||||
* delegation. Because `contentNegotiationRoutes` is injected before auth by
|
|
||||||
* patch-indiekit-routes-rate-limits.mjs, Fedify handles the request before auth
|
|
||||||
* middleware ever runs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const MARKER = "// ap-webfinger-before-auth patch";
|
|
||||||
|
|
||||||
const OLD_SNIPPET = ` if (!self._fedifyMiddleware) return next();
|
|
||||||
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
|
||||||
// Only delegate to Fedify for NodeInfo data endpoint (/nodeinfo/2.1).
|
|
||||||
// All other paths in this root-mounted router are handled by the
|
|
||||||
// content negotiation catch-all below. Passing arbitrary paths like
|
|
||||||
// /notes/... to Fedify causes harmless but noisy 404 warnings.
|
|
||||||
if (!req.path.startsWith("/nodeinfo/")) return next();
|
|
||||||
return self._fedifyMiddleware(req, res, next);`;
|
|
||||||
|
|
||||||
const NEW_SNIPPET = ` if (!self._fedifyMiddleware) return next();
|
|
||||||
if (req.method !== "GET" && req.method !== "HEAD") return next();
|
|
||||||
// Delegate to Fedify for discovery endpoints:
|
|
||||||
// /.well-known/webfinger — actor/resource identity resolution
|
|
||||||
// /.well-known/nodeinfo — server capabilities advertised to the fediverse
|
|
||||||
// /nodeinfo/2.1 — NodeInfo data document
|
|
||||||
// This router is mounted at "/" so req.url retains the full path, allowing
|
|
||||||
// Fedify to match its internal routes correctly. (routesWellKnown strips
|
|
||||||
// the /.well-known/ prefix, causing Fedify to miss the webfinger route.)
|
|
||||||
// ap-webfinger-before-auth patch
|
|
||||||
const isDiscoveryRoute =
|
|
||||||
req.path.startsWith("/nodeinfo/") ||
|
|
||||||
req.path.startsWith("/.well-known/");
|
|
||||||
if (!isDiscoveryRoute) return next();
|
|
||||||
return self._fedifyMiddleware(req, res, next);`;
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let checked = 0;
|
|
||||||
let patched = 0;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
checked += 1;
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (source.includes(MARKER)) {
|
|
||||||
console.log(`[postinstall] patch-ap-webfinger-before-auth: already applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(OLD_SNIPPET)) {
|
|
||||||
console.warn(
|
|
||||||
`[postinstall] patch-ap-webfinger-before-auth: target snippet not found in ${filePath} — skipping`,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
console.log(`[postinstall] patch-ap-webfinger-before-auth: no changes applied to ${filePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patched += 1;
|
|
||||||
console.log(`[postinstall] Applied patch-ap-webfinger-before-auth to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-webfinger-before-auth: no target files found");
|
|
||||||
} else if (patched === 0) {
|
|
||||||
console.log("[postinstall] patch-ap-webfinger-before-auth: already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] patch-ap-webfinger-before-auth: patched ${patched}/${checked} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: silently ignore PeerTube View (WatchAction) activities in the inbox.
|
|
||||||
*
|
|
||||||
* PeerTube broadcasts a non-standard ActivityStreams `View` activity to all
|
|
||||||
* followers whenever someone watches a video. Fedify has no built-in handler
|
|
||||||
* registered for this type, which causes a noisy
|
|
||||||
* "Unsupported activity type" error in the federation inbox log on every view.
|
|
||||||
*
|
|
||||||
* Fix: register a no-op `.on(View, ...)` handler at the end of the inbox
|
|
||||||
* listener chain so Fedify accepts and silently discards these activities
|
|
||||||
* instead of logging them as errors.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-listeners.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-listeners.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const patchSpecs = [
|
|
||||||
{
|
|
||||||
name: "inbox-ignore-view-activity-import",
|
|
||||||
marker: "// View imported",
|
|
||||||
oldSnippet: ` Undo,
|
|
||||||
Update,
|
|
||||||
} from "@fedify/fedify/vocab";`,
|
|
||||||
newSnippet: ` Undo,
|
|
||||||
Update,
|
|
||||||
View, // View imported
|
|
||||||
} from "@fedify/fedify/vocab";`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "inbox-ignore-view-activity-handler",
|
|
||||||
marker: "// PeerTube View handler",
|
|
||||||
oldSnippet: ` console.info(\`[ActivityPub] Flag received from \${reporterName} — \${reportedIds.length} objects reported\`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[ActivityPub] Flag handler error:", error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}`,
|
|
||||||
newSnippet: ` console.info(\`[ActivityPub] Flag received from \${reporterName} — \${reportedIds.length} objects reported\`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[ActivityPub] Flag handler error:", error.message);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// ── View (PeerTube watch) ─────────────────────────────────────────────
|
|
||||||
// PeerTube broadcasts View (WatchAction) activities to all followers
|
|
||||||
// whenever someone watches a video. Fedify has no built-in handler for
|
|
||||||
// this type, producing noisy "Unsupported activity type" log errors.
|
|
||||||
// Silently accept and discard. // PeerTube View handler
|
|
||||||
.on(View, async () => {});
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkedFiles = new Set();
|
|
||||||
const patchedFiles = new Set();
|
|
||||||
|
|
||||||
for (const spec of patchSpecs) {
|
|
||||||
let foundAnyTarget = false;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foundAnyTarget = true;
|
|
||||||
checkedFiles.add(filePath);
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (spec.marker && source.includes(spec.marker)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(spec.oldSnippet)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(spec.oldSnippet, spec.newSnippet);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patchedFiles.add(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundAnyTarget) {
|
|
||||||
console.log(`[postinstall] ${spec.name}: no target files found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkedFiles.size === 0) {
|
|
||||||
console.log("[postinstall] No inbox-listeners files found for View activity patch");
|
|
||||||
} else if (patchedFiles.size === 0) {
|
|
||||||
console.log("[postinstall] inbox-ignore-view-activity patches already applied");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] Patched inbox-ignore-view-activity in ${patchedFiles.size}/${checkedFiles.size} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
/**
|
|
||||||
* Patch: skip PeerTube View (WatchAction) activities before Fedify parses them.
|
|
||||||
*
|
|
||||||
* PeerTube's View activities embed Schema.org extensions such as
|
|
||||||
* `InteractionCounter` that Fedify's JSON-LD deserializer doesn't recognise.
|
|
||||||
* This causes a hard "Failed to parse activity" error *before* any inbox
|
|
||||||
* listener is reached, so the .on(View, ...) no-op handler added earlier
|
|
||||||
* never fires.
|
|
||||||
*
|
|
||||||
* Root cause of the previous (broken) patch: Express's JSON body parser only
|
|
||||||
* handles `application/json`, not `application/activity+json`. So `req.body`
|
|
||||||
* is always undefined for ActivityPub inbox POSTs, meaning the check
|
|
||||||
* `req.body?.type === "View"` never matched and Fedify still received the raw
|
|
||||||
* stream.
|
|
||||||
*
|
|
||||||
* Fix (two changes to federation-bridge.js):
|
|
||||||
*
|
|
||||||
* 1. In createFedifyMiddleware: for ActivityPub POST requests where the body
|
|
||||||
* hasn't been parsed yet, buffer the raw stream, JSON-parse it, and store
|
|
||||||
* the result on req.body before the guard runs. Then check type === "View"
|
|
||||||
* and return 200 if so (preventing retries from the sender).
|
|
||||||
*
|
|
||||||
* 2. In fromExpressRequest: extend the content-type check to also handle
|
|
||||||
* `application/activity+json` and `application/ld+json` bodies (i.e. use
|
|
||||||
* JSON.stringify(req.body) to reconstruct the stream), so that non-View
|
|
||||||
* ActivityPub activities are forwarded correctly to Fedify.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { access, readFile, writeFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
|
|
||||||
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
const patchSpecs = [
|
|
||||||
// --- Patch 1: extend fromExpressRequest to handle activity+json bodies ---
|
|
||||||
{
|
|
||||||
name: "from-express-request-activity-json-fix",
|
|
||||||
marker: "// PeerTube activity+json body fix",
|
|
||||||
oldSnippet: ` if (ct.includes("application/json")) {
|
|
||||||
body = JSON.stringify(req.body);
|
|
||||||
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
|
|
||||||
newSnippet: ` // PeerTube activity+json body fix
|
|
||||||
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
|
|
||||||
// Use original raw bytes when available (set by createFedifyMiddleware buffer guard).
|
|
||||||
// JSON.stringify() changes byte layout, breaking Fedify's HTTP Signature Digest check.
|
|
||||||
body = req._rawBody || JSON.stringify(req.body); // raw body digest fix
|
|
||||||
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Patch 1b: upgrade fromExpressRequest to use _rawBody (fixes Digest verification) ---
|
|
||||||
// Handles files where Patch 1 was already applied but without the _rawBody fix.
|
|
||||||
{
|
|
||||||
name: "from-express-request-raw-body-fix",
|
|
||||||
marker: "req._rawBody || JSON.stringify",
|
|
||||||
oldSnippet: ` // PeerTube activity+json body fix
|
|
||||||
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
|
|
||||||
body = JSON.stringify(req.body);
|
|
||||||
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
|
|
||||||
newSnippet: ` // PeerTube activity+json body fix
|
|
||||||
if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) {
|
|
||||||
// Use original raw bytes when available (set by createFedifyMiddleware buffer guard).
|
|
||||||
// JSON.stringify() changes byte layout, breaking Fedify's HTTP Signature Digest check.
|
|
||||||
body = req._rawBody || JSON.stringify(req.body); // raw body digest fix
|
|
||||||
} else if (ct.includes("application/x-www-form-urlencoded")) {`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Patch 1c: upgrade middleware buffer guard to set _rawBody (fixes Digest verification) ---
|
|
||||||
// Handles files where Patch 2 was already applied but without the _rawBody fix.
|
|
||||||
{
|
|
||||||
name: "inbox-buffer-raw-body-fix",
|
|
||||||
marker: "req._rawBody = _raw",
|
|
||||||
oldSnippet: ` const _chunks = [];
|
|
||||||
for await (const _chunk of req) {
|
|
||||||
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
req.body = JSON.parse(Buffer.concat(_chunks).toString("utf8"));`,
|
|
||||||
newSnippet: ` const _chunks = [];
|
|
||||||
for await (const _chunk of req) {
|
|
||||||
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
|
|
||||||
}
|
|
||||||
const _raw = Buffer.concat(_chunks); // raw body digest fix
|
|
||||||
req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification
|
|
||||||
try {
|
|
||||||
req.body = JSON.parse(_raw.toString("utf8"));`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Patch 2a: replace the old (broken) v1 guard with the buffering v2 guard ---
|
|
||||||
// Handles the case where the previous version of this script was already run.
|
|
||||||
{
|
|
||||||
name: "inbox-skip-view-activity-parse-v2",
|
|
||||||
marker: "// PeerTube View parse skip v2",
|
|
||||||
oldSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify
|
|
||||||
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
|
|
||||||
// Schema.org extensions (e.g. InteractionCounter), causing a
|
|
||||||
// "Failed to parse activity" error. Return 200 to prevent retries.
|
|
||||||
// PeerTube View parse skip
|
|
||||||
if (req.method === "POST" && req.body?.type === "View") {
|
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
const request = fromExpressRequest(req);`,
|
|
||||||
newSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify
|
|
||||||
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
|
|
||||||
// Schema.org extensions (e.g. InteractionCounter), causing a
|
|
||||||
// "Failed to parse activity" error. Return 200 to prevent retries.
|
|
||||||
// PeerTube View parse skip v2
|
|
||||||
const _apct = req.headers["content-type"] || "";
|
|
||||||
if (
|
|
||||||
req.method === "POST" &&
|
|
||||||
!req.body &&
|
|
||||||
req.readable &&
|
|
||||||
(_apct.includes("activity+json") || _apct.includes("ld+json"))
|
|
||||||
) {
|
|
||||||
// Express doesn't parse application/activity+json, so buffer it ourselves.
|
|
||||||
const _chunks = [];
|
|
||||||
for await (const _chunk of req) {
|
|
||||||
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
|
|
||||||
}
|
|
||||||
const _raw = Buffer.concat(_chunks); // raw body digest fix
|
|
||||||
req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification
|
|
||||||
try {
|
|
||||||
req.body = JSON.parse(_raw.toString("utf8"));
|
|
||||||
} catch {
|
|
||||||
req.body = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (req.method === "POST" && req.body?.type === "View") {
|
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
const request = fromExpressRequest(req);`,
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Patch 2b: apply the buffering v2 guard on a fresh (unpatched) file ---
|
|
||||||
// Handles the case where neither v1 nor v2 patch has been applied yet.
|
|
||||||
{
|
|
||||||
name: "inbox-skip-view-activity-parse-v2-fresh",
|
|
||||||
marker: "// PeerTube View parse skip v2",
|
|
||||||
oldSnippet: ` return async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const request = fromExpressRequest(req);`,
|
|
||||||
newSnippet: ` return async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
// Short-circuit PeerTube View (WatchAction) activities before Fedify
|
|
||||||
// attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's
|
|
||||||
// Schema.org extensions (e.g. InteractionCounter), causing a
|
|
||||||
// "Failed to parse activity" error. Return 200 to prevent retries.
|
|
||||||
// PeerTube View parse skip v2
|
|
||||||
const _apct = req.headers["content-type"] || "";
|
|
||||||
if (
|
|
||||||
req.method === "POST" &&
|
|
||||||
!req.body &&
|
|
||||||
req.readable &&
|
|
||||||
(_apct.includes("activity+json") || _apct.includes("ld+json"))
|
|
||||||
) {
|
|
||||||
// Express doesn't parse application/activity+json, so buffer it ourselves.
|
|
||||||
const _chunks = [];
|
|
||||||
for await (const _chunk of req) {
|
|
||||||
_chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk));
|
|
||||||
}
|
|
||||||
const _raw = Buffer.concat(_chunks); // raw body digest fix
|
|
||||||
req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification
|
|
||||||
try {
|
|
||||||
req.body = JSON.parse(_raw.toString("utf8"));
|
|
||||||
} catch {
|
|
||||||
req.body = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (req.method === "POST" && req.body?.type === "View") {
|
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
const request = fromExpressRequest(req);`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function exists(filePath) {
|
|
||||||
try {
|
|
||||||
await access(filePath);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkedFiles = new Set();
|
|
||||||
const patchedFiles = new Set();
|
|
||||||
|
|
||||||
for (const spec of patchSpecs) {
|
|
||||||
let foundAnyTarget = false;
|
|
||||||
|
|
||||||
for (const filePath of candidates) {
|
|
||||||
if (!(await exists(filePath))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foundAnyTarget = true;
|
|
||||||
checkedFiles.add(filePath);
|
|
||||||
|
|
||||||
const source = await readFile(filePath, "utf8");
|
|
||||||
|
|
||||||
if (spec.marker && source.includes(spec.marker)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!source.includes(spec.oldSnippet)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = source.replace(spec.oldSnippet, spec.newSnippet);
|
|
||||||
|
|
||||||
if (updated === source) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(filePath, updated, "utf8");
|
|
||||||
patchedFiles.add(filePath);
|
|
||||||
console.log(`[postinstall] Applied ${spec.name} to ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!foundAnyTarget) {
|
|
||||||
console.log(`[postinstall] ${spec.name}: no target files found`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkedFiles.size === 0) {
|
|
||||||
console.log("[postinstall] No federation-bridge files found for View activity parse-skip patch");
|
|
||||||
} else if (patchedFiles.size === 0) {
|
|
||||||
console.log("[postinstall] inbox-skip-view-activity-parse patch already up to date");
|
|
||||||
} else {
|
|
||||||
console.log(
|
|
||||||
`[postinstall] Patched inbox-skip-view-activity-parse in ${patchedFiles.size}/${checkedFiles.size} file(s)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user