fix: Mastodon API status creation — links, CW, timeline timing

- Provide content as {text, html} with linkified URLs (Micropub's
  markdown-it doesn't have linkify enabled)
- Use content-warning field (not summary) to match native reader and
  AP syndicator expectations
- Remove premature addTimelineItem — post appears in timeline after
  syndication round-trip, not immediately
- Remove processStatusContent (unused after addTimelineItem removal)
- Remove addTimelineItem import
This commit is contained in:
Ricardo
2026-03-26 15:33:38 +01:00
parent 80ef9bca11
commit 1bfeabeaf3
2 changed files with 61 additions and 126 deletions

View File

@@ -21,7 +21,6 @@ import {
boostPost, unboostPost, boostPost, unboostPost,
bookmarkPost, unbookmarkPost, bookmarkPost, unbookmarkPost,
} from "../helpers/interactions.js"; } from "../helpers/interactions.js";
import { addTimelineItem } from "../../storage/timeline.js";
import { tokenRequired } from "../middleware/token-required.js"; import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js"; import { scopeRequired } from "../middleware/scope-required.js";
@@ -166,130 +165,105 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
} }
} }
// Build JF2 properties for the Micropub pipeline // Build JF2 properties for the Micropub pipeline.
// Provide both text and html — linkify URLs since Micropub's markdown-it
// doesn't have linkify enabled. Mentions are preserved as plain text;
// the AP syndicator resolves them via WebFinger for federation delivery.
const contentText = statusText || "";
const contentHtml = contentText
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/(https?:\/\/[^\s<>&"')\]]+)/g, '<a href="$1">$1</a>')
.replace(/\n/g, "<br>");
const jf2 = { const jf2 = {
type: "entry", type: "entry",
content: statusText || "", content: { text: contentText, html: `<p>${contentHtml}</p>` },
}; };
if (inReplyTo) { if (inReplyTo) {
jf2["in-reply-to"] = inReplyTo; jf2["in-reply-to"] = inReplyTo;
} }
if (spoilerText) {
jf2.summary = spoilerText;
}
if (sensitive === true || sensitive === "true") {
jf2.sensitive = "true";
}
if (visibility && visibility !== "public") { if (visibility && visibility !== "public") {
jf2.visibility = visibility; jf2.visibility = visibility;
} }
// Use content-warning (not summary) to match native reader behavior
if (spoilerText) {
jf2["content-warning"] = spoilerText;
jf2.sensitive = "true";
}
if (language) { if (language) {
jf2["mp-language"] = language; jf2["mp-language"] = language;
} }
// Syndicate to AP only — posts from Mastodon clients belong to the fediverse. // Syndicate to AP — posts from Mastodon clients belong to the fediverse
// Never cross-post to Bluesky (conversations stay in their protocol).
// The publication URL is the AP syndicator's uid.
const publicationUrl = pluginOptions.publicationUrl || baseUrl; const publicationUrl = pluginOptions.publicationUrl || baseUrl;
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"]; jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
// Create post via Micropub pipeline (same functions the Micropub endpoint uses) // Create post via Micropub pipeline (same internal functions)
// postData.create() handles: normalization, post type detection, path rendering,
// mp-syndicate-to validated against configured syndicators, MongoDB posts collection
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js"); const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js"); const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
const data = await postData.create(application, publication, jf2); const data = await postData.create(application, publication, jf2);
// postContent.create() handles: template rendering, file creation in store
await postContent.create(publication, data); await postContent.create(publication, data);
const postUrl = data.properties.url; const postUrl = data.properties.url;
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`); console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
// Add to ap_timeline so the post is visible in the Mastodon Client API // 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 profile = await collections.ap_profile.findOne({});
const handle = pluginOptions.handle || "user"; const handle = pluginOptions.handle || "user";
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
// Extract hashtags from status text and merge with any Micropub categories res.json({
const categories = data.properties.category || []; id: String(Date.now()),
const inlineHashtags = (statusText || "").match(/(?:^|\s)#([a-zA-Z_]\w*)/g); created_at: new Date().toISOString(),
if (inlineHashtags) { content: `<p>${contentHtml}</p>`,
const existing = new Set(categories.map((c) => c.toLowerCase()));
for (const match of inlineHashtags) {
const tag = match.trim().slice(1).toLowerCase();
if (!existing.has(tag)) {
existing.add(tag);
categories.push(tag);
}
}
}
// Resolve relative media URLs to absolute
const resolveMedia = (items) => {
if (!items || !items.length) return [];
return items.map((item) => {
if (typeof item === "string") {
return item.startsWith("http") ? item : `${publicationUrl.replace(/\/$/, "")}/${item.replace(/^\//, "")}`;
}
if (item?.url && !item.url.startsWith("http")) {
return { ...item, url: `${publicationUrl.replace(/\/$/, "")}/${item.url.replace(/^\//, "")}` };
}
return item;
});
};
// Process content: linkify URLs and extract @mentions
const rawContent = data.properties.content || { text: statusText || "", html: "" };
const processedContent = processStatusContent(rawContent, statusText || "");
const mentions = extractMentions(statusText || "");
const now = new Date().toISOString();
const timelineItem = await addTimelineItem(collections, {
uid: postUrl,
url: postUrl, url: postUrl,
type: data.properties["post-type"] || "note", uri: postUrl,
content: processedContent,
summary: spoilerText || "",
sensitive: sensitive === true || sensitive === "true",
visibility: visibility || "public", visibility: visibility || "public",
sensitive: sensitive === true || sensitive === "true",
spoiler_text: spoilerText || "",
in_reply_to_id: inReplyToId || null,
in_reply_to_account_id: null,
language: language || null, language: language || null,
inReplyTo, replies_count: 0,
published: data.properties.published || now, reblogs_count: 0,
createdAt: now, favourites_count: 0,
author: { favourited: false,
name: profile?.name || handle, reblogged: false,
bookmarked: false,
account: {
id: "owner",
username: handle,
acct: handle,
display_name: profile?.name || handle,
url: profile?.url || publicationUrl, url: profile?.url || publicationUrl,
photo: profile?.icon || "", avatar: profile?.icon || "",
handle: `@${handle}`, avatar_static: profile?.icon || "",
header: "",
header_static: "",
followers_count: 0,
following_count: 0,
statuses_count: 0,
emojis: [], emojis: [],
bot: false, fields: [],
}, },
photo: resolveMedia(data.properties.photo || []), media_attachments: [],
video: resolveMedia(data.properties.video || []), mentions: extractMentions(contentText).map(m => ({
audio: resolveMedia(data.properties.audio || []), id: "0",
category: categories, username: m.name.split("@")[1] || m.name,
counts: { replies: 0, boosts: 0, likes: 0 }, acct: m.name.replace(/^@/, ""),
linkPreviews: [], url: m.url,
mentions, })),
tags: [],
emojis: [], emojis: [],
}); });
// Serialize and return
const serialized = serializeStatus(timelineItem, {
baseUrl,
favouritedIds: new Set(),
rebloggedIds: new Set(),
bookmarkedIds: new Set(),
pinnedIds: new Set(),
});
res.json(serialized);
} catch (error) { } catch (error) {
next(error); next(error);
} }
@@ -604,45 +578,6 @@ async function loadItemInteractions(collections, item) {
return { favouritedIds, rebloggedIds, bookmarkedIds }; return { favouritedIds, rebloggedIds, bookmarkedIds };
} }
/**
* Process status content: linkify bare URLs and convert @mentions to links.
*
* Mastodon clients send plain text — the server is responsible for
* converting URLs and mentions into HTML links.
*
* @param {object} content - { text, html } from Micropub pipeline
* @param {string} rawText - Original status text from client
* @returns {object} { text, html } with linkified content
*/
function processStatusContent(content, rawText) {
let html = content.html || content.text || rawText || "";
// If the HTML is just plain text wrapped in <p>, process it
// Don't touch HTML that already has links (from Micropub rendering)
if (!html.includes("<a ")) {
// Linkify bare URLs (http/https)
html = html.replace(
/(https?:\/\/[^\s<>"')\]]+)/g,
'<a href="$1" rel="nofollow noopener noreferrer" target="_blank">$1</a>',
);
// Convert @user@domain mentions to profile links
html = html.replace(
/(?:^|\s)(@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g,
(match, full, username, domain) =>
match.replace(
full,
`<span class="h-card"><a href="https://${domain}/@${username}" class="u-url mention" rel="nofollow noopener noreferrer" target="_blank">@${username}@${domain}</a></span>`,
),
);
}
return {
text: content.text || rawText || "",
html,
};
}
/** /**
* Extract @user@domain mentions from text into mention objects. * Extract @user@domain mentions from text into mention objects.
* *

View File

@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.9.3", "version": "3.9.4",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",