mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
@@ -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, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user