From e7d432a85f0ecab51a6adb58f5e3583e2e3a6c80 Mon Sep 17 00:00:00 2001 From: Sven Date: Mon, 11 May 2026 07:27:47 +0200 Subject: [PATCH] patch: short Mastodon posts skip Micropub blog pipeline --- .../patch-ap-mastodon-short-post-bypass.mjs | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 scripts/patch-ap-mastodon-short-post-bypass.mjs diff --git a/scripts/patch-ap-mastodon-short-post-bypass.mjs b/scripts/patch-ap-mastodon-short-post-bypass.mjs new file mode 100644 index 00000000..491be99a --- /dev/null +++ b/scripts/patch-ap-mastodon-short-post-bypass.mjs @@ -0,0 +1,157 @@ +/** + * Patch: skip Micropub blog post creation for short standalone Mastodon posts. + * + * Very short status texts (≤ 280 chars, no media, no reply) are fediverse-only + * interactions — they federate via AP but must NOT create a permanent blog entry. + * Posts with in_reply_to, media, or text > 280 characters go through the full + * Micropub pipeline as before. + * + * Short posts get a canonical URL at /activitypub/objects/note/{uuid} (same as + * DMs), are stored in ap_timeline, and delivered to followers via Fedify's + * ctx.sendActivity({ "followers" }) — the same mechanism the AP syndicator uses. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +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 MARKER = "// [patch] short-post-bypass"; + +const OLD = ` // Resolve in_reply_to URL from status ID (cursor or ObjectId) + let inReplyTo = null; + if (inReplyToId) {`; + +const NEW = ` // Short standalone posts (≤ 280 chars, no media, no reply) skip Micropub + // and go directly to AP — no blog entry is created. // [patch] short-post-bypass + const _shortPostLimit = 280; + const _isShortPost = + !inReplyToId && + (!mediaIds || mediaIds.length === 0) && + visibility !== "direct" && + (statusText || "").trim().length > 0 && + (statusText || "").trim().length <= _shortPostLimit; + + if (_isShortPost) { + const _spPluginOptions = req.app.locals.mastodonPluginOptions || {}; + const _spPublicationUrl = (_spPluginOptions.publicationUrl || baseUrl).replace(/\\/+$/, ""); + const _spFederation = _spPluginOptions.federation; + const _spHandle = _spPluginOptions.handle || "user"; + const _spHostname = (() => { try { return new URL(_spPublicationUrl).hostname; } catch { return ""; } })(); + const _spProfile = await collections.ap_profile.findOne({}); + + const { randomUUID } = await import("node:crypto"); + const _spUuid = randomUUID(); + const _spNoteId = \`\${_spPublicationUrl}/activitypub/objects/note/\${_spUuid}\`; + const _spNow = new Date().toISOString(); + + const _spText = (statusText || "").trim(); + const _spHtml = _spText + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/(https?:\\/\\/[^\\s<>&"')\\]]+)/g, '$1') + .replace(/\\n/g, "
"); + + const _spTlItem = await addTimelineItem(collections, { + uid: _spNoteId, + url: _spNoteId, + type: "note", + content: { text: _spText, html: \`

\${_spHtml}

\` }, + author: { + name: _spProfile?.name || _spHandle, + url: _spProfile?.url || _spPublicationUrl, + photo: _spProfile?.icon || "", + handle: \`@\${_spHandle}@\${_spHostname}\`, + emojis: [], + bot: false, + }, + published: _spNow, + createdAt: _spNow, + inReplyTo: null, + visibility: visibility || "public", + sensitive: sensitive === true || sensitive === "true", + category: [], + counts: { likes: 0, boosts: 0, replies: 0 }, + }); + + if (_spFederation) { + try { + const { Note, Create } = await import("@fedify/fedify/vocab"); + const _spCtx = _spFederation.createContext( + new URL(_spPublicationUrl + "/"), + { handle: _spHandle, publicationUrl: _spPublicationUrl }, + ); + const _spActorUri = _spCtx.getActorUri(_spHandle); + const _spNote = new Note({ + id: new URL(_spNoteId), + attributedTo: _spActorUri, + content: \`

\${_spHtml}

\`, + published: new Date(_spNow), + }); + const _spCreate = new Create({ + id: new URL(\`\${_spNoteId}#create\`), + actor: _spActorUri, + object: _spNote, + }); + await _spCtx.sendActivity( + { identifier: _spHandle }, + "followers", + _spCreate, + { preferSharedInbox: true, syncCollection: true, orderingKey: _spNoteId }, + ); + console.info(\`[Mastodon API] Short post federated to followers (no blog entry): \${_spNoteId}\`); + } catch (_spApErr) { + console.warn(\`[Mastodon API] Short post AP federation error: \${_spApErr.message}\`); + } + } + + const _spStatus = serializeStatus(_spTlItem, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }); + + if (idempotencyKey && collections.ap_idempotency) { + const { createHash } = await import("node:crypto"); + const _spKey = createHash("sha256").update(\`\${baseUrl}:\${idempotencyKey}\`).digest("hex"); + await collections.ap_idempotency.insertOne({ key: _spKey, response: _spStatus, createdAt: new Date() }).catch(() => {}); + } + + return res.json(_spStatus); + } + + // Resolve in_reply_to URL from status ID (cursor or ObjectId) + let inReplyTo = null; + if (inReplyToId) {`; + +async function exists(p) { + try { await access(p); return true; } catch { return false; } +} + +let patched = false; +for (const filePath of candidates) { + if (!(await exists(filePath))) continue; + const src = await readFile(filePath, "utf8"); + if (src.includes(MARKER)) { + console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: already applied in ${filePath}`); + patched = true; + break; + } + if (!src.includes(OLD)) { + console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: target snippet not found in ${filePath}`); + continue; + } + await writeFile(filePath, src.replace(OLD, NEW), "utf8"); + console.log(`[postinstall] patch-ap-mastodon-short-post-bypass: applied to ${filePath}`); + patched = true; + break; +} + +if (!patched) { + console.log("[postinstall] patch-ap-mastodon-short-post-bypass: no target file found"); +}